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

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

import com.google.common.collect.ImmutableList;

import org.junit.Assert;
import org.junit.Test;
import org.quicktheories.core.Gen;
import org.quicktheories.core.RandomnessSource;
import org.quicktheories.generators.SourceDSL;
import org.quicktheories.impl.JavaRandom;

import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.config.TestDatabaseDescriptor;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.compaction.CursorCompactor;
import org.apache.cassandra.io.sstable.format.SSTableFormat;
import org.apache.cassandra.io.util.DataInputBuffer;
import org.apache.cassandra.io.util.DataOutputBuffer;
import org.apache.cassandra.schema.ColumnMetadata;
import org.apache.cassandra.schema.TableMetadata;
import org.apache.cassandra.tcm.ClusterMetadata;
import org.apache.cassandra.tcm.membership.NodeVersion;
import org.apache.cassandra.utils.AbstractTypeGenerators;
import org.apache.cassandra.utils.AbstractTypeGenerators.TypeGenBuilder;
import org.apache.cassandra.utils.CassandraGenerators;
import org.apache.cassandra.utils.CassandraGenerators.TableMetadataBuilder;
import org.apache.cassandra.utils.FailingConsumer;
import org.apache.cassandra.utils.Generators;

import static org.apache.cassandra.utils.Generators.IDENTIFIER_GEN;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

public class RandomSchemaTest extends CQLTester.InMemory
{
    static final boolean STRESS_CURSOR_COMPACTION = false;
    static
    {
        // make sure blob is always the same
        CassandraRelevantProperties.TEST_BLOB_SHARED_SEED.setInt(42);

        requireNetwork();
    }

    @Test
    public void test()
    {
        // in accord branch there is a much cleaner api for this pattern...
        Gen<AbstractTypeGenerators.ValueDomain> domainGen = SourceDSL.integers().between(1, 100).map(i -> i < 2 ? AbstractTypeGenerators.ValueDomain.NULL : i < 4 ? AbstractTypeGenerators.ValueDomain.EMPTY_BYTES : AbstractTypeGenerators.ValueDomain.NORMAL);

        Gen<SSTableFormat<?, ?>> sstableFormatGen = CassandraGenerators.sstableFormat();
        qt().checkAssert(random -> {
            resetSchema();

            // TODO : when table level override of sstable format is allowed, migrate to that
            if (!STRESS_CURSOR_COMPACTION) TestDatabaseDescriptor.setUnsafeSelectedSSTableFormat(sstableFormatGen.generate(random));

            Gen<String> udtName = Generators.unique(IDENTIFIER_GEN);

            TypeGenBuilder withoutUnsafeEquality = AbstractTypeGenerators.withoutUnsafeEquality()
                                                                         .withUDTNames(udtName);
            TableMetadata metadata;
            do
            {
                metadata = new TableMetadataBuilder()
                           .withKeyspaceName(KEYSPACE)
                           .withTableKinds(TableMetadata.Kind.REGULAR)
                           .withKnownMemtables()
                           .withDefaultTypeGen(AbstractTypeGenerators.builder()
                                                                     .withoutEmpty()
                                                                     .withMaxDepth(2)
                                                                     .withDefaultSetKey(withoutUnsafeEquality)
                                                                     .withoutTypeKinds(AbstractTypeGenerators.TypeKind.COUNTER)
                                                                     .withUDTNames(udtName))
                           .withPartitionColumnsCount(1)
                           .withPrimaryColumnTypeGen(new TypeGenBuilder(withoutUnsafeEquality)
                                                     // map of vector of map crossed the size cut-off for one of the tests, so changed max depth from 2 to 1, so we can't have the second map
                                                     .withMaxDepth(1))
                           .withClusteringColumnsBetween(1, 2)
                           .withRegularColumnsBetween(1, 5)
                           .withStaticColumnsBetween(0, 2)
                           .build(random);
            } while (STRESS_CURSOR_COMPACTION && CursorCompactor.unsupportedMetadata(metadata));
            maybeCreateUDTs(metadata);
            String createTable = metadata.toCqlString(true, false, false);
            // just to make the CREATE TABLE stmt easier to read for CUSTOM types
            createTable = createTable.replaceAll("org.apache.cassandra.db.marshal.", "");
            createTable(KEYSPACE, createTable);
            serde(ClusterMetadata.current(), metadata);

            Gen<ByteBuffer[]> dataGen = CassandraGenerators.data(metadata, domainGen);
            String insertStmt = insertStmt(metadata);
            int partitionColumnCount = metadata.partitionKeyColumns().size();
            int primaryColumnCount = primaryColumnCount(metadata);
            String selectStmt = selectStmt(metadata);
            String tokenStmt = tokenStmt(metadata);

            for (int i = 0; i < 100; i++)
            {
                ByteBuffer[] expected = dataGen.generate(random);
                try
                {
                    ByteBuffer[] partitionKeys = Arrays.copyOf(expected, partitionColumnCount);
                    ByteBuffer[] rowKey = Arrays.copyOf(expected, primaryColumnCount);
                    execute(insertStmt, (Object[]) expected);
                    // check memtable
                    assertRows(execute(selectStmt, (Object[]) rowKey), expected);
                    assertRows(execute(tokenStmt, (Object[]) partitionKeys), partitionKeys);
                    assertRowsNet(executeNet(selectStmt, (Object[]) rowKey), expected);

                    // check sstable
                    flush(KEYSPACE, metadata.name);
                    compact(KEYSPACE, metadata.name);
                    ColumnFamilyStore cfs = getColumnFamilyStore(KEYSPACE, metadata.name);
                    assertRows(execute(selectStmt, (Object[]) rowKey), expected);
                    assertRows(execute(tokenStmt, (Object[]) partitionKeys), partitionKeys);
                    assertRowsNet(executeNet(selectStmt, (Object[]) rowKey), expected);

                    execute("TRUNCATE " + metadata);
                }
                catch (Throwable t)
                {
                    Iterator<ColumnMetadata> it = metadata.allColumnsInSelectOrder();
                    List<String> literals = new ArrayList<>(expected.length);
                    for (int idx = 0; idx < expected.length; idx++)
                    {
                        assert it.hasNext();
                        ColumnMetadata meta = it.next();
                        ByteBuffer expct = expected[idx];
                        literals.add(expct == null ? "null" : !expct.hasRemaining() ? "empty" : meta.type.asCQL3Type().toCQLLiteral(expct));
                    }
                    throw new AssertionError(String.format("Failure at attempt %d with schema\n%s\nAnd SSTable Format %s\nfor values %s", i, createTable, DatabaseDescriptor.getSelectedSSTableFormat(), literals), t);
                }
            }
        });
    }

    private void serde(ClusterMetadata metadata, TableMetadata tableMetadata) throws IOException
    {
        assertTrue(metadata.schema.getKeyspaces().containsKeyspace(tableMetadata.keyspace));
        assertNotNull(metadata.schema.getKeyspace(tableMetadata.keyspace).getColumnFamilyStore(tableMetadata.name));

        DataOutputBuffer dop = new DataOutputBuffer((int) ClusterMetadata.serializer.serializedSize(metadata, NodeVersion.CURRENT_METADATA_VERSION));
        ClusterMetadata.serializer.serialize(metadata, dop, NodeVersion.CURRENT_METADATA_VERSION);

        try (DataInputBuffer dip = new DataInputBuffer(dop.buffer(), true))
        {
            ClusterMetadata deserCm = ClusterMetadata.serializer.deserialize(dip, NodeVersion.CURRENT_METADATA_VERSION);
            if (!metadata.equals(deserCm))
            {
                metadata.dumpDiff(deserCm);
                Assert.fail("Metadata mismatch");
            }

        }
    }

    private static int primaryColumnCount(TableMetadata metadata)
    {
        return metadata.partitionKeyColumns().size() + metadata.clusteringColumns().size();
    }

    private static String tokenStmt(TableMetadata metadata)
    {
        StringBuilder sb = new StringBuilder();
        List<String> columns = metadata.partitionKeyColumns().stream().map(column -> column.name.toCQLString()).collect(Collectors.toList());
        List<String> binds = columns.stream().map(ignore -> "?").collect(Collectors.toList());
        sb.append("SELECT ").append(String.join(", ", columns));
        sb.append(" FROM ").append(metadata);
        sb.append(" WHERE token(").append(String.join(", ", columns)).append(") = token(").append(String.join(", ", binds)).append(')');
        return sb.toString();
    }

    private String selectStmt(TableMetadata metadata)
    {
        StringBuilder sb = new StringBuilder();
        sb.append("SELECT * FROM ").append(metadata).append(" WHERE ");
        for (ColumnMetadata column : ImmutableList.<ColumnMetadata>builder()
                                                  .addAll(metadata.partitionKeyColumns())
                                                  .addAll(metadata.clusteringColumns())
                                                  .build())
        {
            sb.append(column.name.toCQLString()).append(" = ? AND ");
        }
        sb.setLength(sb.length() - " AND ".length());
        return sb.toString();
    }

    private String insertStmt(TableMetadata metadata)
    {
        StringBuilder sb = new StringBuilder();
        sb.append("INSERT INTO ").append(metadata.toString()).append(" (");
        Iterator<ColumnMetadata> cols = metadata.allColumnsInSelectOrder();
        while (cols.hasNext())
            sb.append(cols.next().name.toCQLString()).append(", ");
        sb.setLength(sb.length() - 2); // remove last ", "
        sb.append(") VALUES (");
        for (int i = 0; i < metadata.columns().size(); i++)
        {
            if (i > 0)
                sb.append(", ");
            sb.append('?');
        }
        sb.append(')');
        return sb.toString();
    }

    private static Builder qt()
    {
        return new Builder();
    }

    public static class Builder
    {
        private long seed = System.currentTimeMillis();
        private int examples = 10;

        /**
         * Used to reproduce the test with a fixed seed; main use case is to rerun the same test that failed in CI.
         *
         * This function is dead code, it exists to help authors debug when a failing test is found.
         */
        @SuppressWarnings("unused")
        public Builder withFixedSeed(long seed)
        {
            this.seed = seed;
            return this;
        }

        /**
         * Used to override how many examples to run with.  This is dead code, but exists to allow authors to run the test
         * many times to make sure its stable before committing.
         */
        @SuppressWarnings("unused")
        public Builder withExamples(int examples)
        {
            this.examples = examples;
            return this;
        }

        // copied from java.util.Random
        private static final long multiplier = 0x5DEECE66DL;
        private static final long addend = 0xBL;
        private static final long mask = (1L << 48) - 1;

        public void checkAssert(FailingConsumer<RandomnessSource> test)
        {
            JavaRandom random = new JavaRandom(seed);
            for (int i = 0; i < examples; i++)
            {
                if (i > 0)
                    seed = (seed * multiplier + addend) & mask;
                random.setSeed(seed);
                try
                {
                    test.doAccept(random);
                }
                catch (Throwable e)
                {
                    throw new PropertyError(seed, e);
                }
            }
        }
    }

    public static class PropertyError extends AssertionError
    {
        public PropertyError(long seed, Throwable cause)
        {
            super(message(seed), cause);
        }

        private static String message(long seed)
        {
            return "Failure for seed " + seed;
        }
    }
}
