/*
 * 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.ignite.internal.processors.cache.index;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.cache.CacheException;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.IgniteCheckedException;
import org.apache.ignite.Ignition;
import org.apache.ignite.cache.QueryEntity;
import org.apache.ignite.cache.QueryIndex;
import org.apache.ignite.cache.QueryIndexType;
import org.apache.ignite.cache.query.SqlFieldsQuery;
import org.apache.ignite.cache.query.annotations.QuerySqlField;
import org.apache.ignite.configuration.CacheConfiguration;
import org.apache.ignite.configuration.DataRegionConfiguration;
import org.apache.ignite.configuration.DataStorageConfiguration;
import org.apache.ignite.configuration.IgniteConfiguration;
import org.apache.ignite.internal.IgniteEx;
import org.apache.ignite.internal.IgniteInternalFuture;
import org.apache.ignite.internal.IgniteInterruptedCheckedException;
import org.apache.ignite.internal.binary.BinaryMarshaller;
import org.apache.ignite.internal.processors.odbc.ClientListenerProcessor;
import org.apache.ignite.internal.processors.port.GridPortRecord;
import org.apache.ignite.internal.processors.query.GridQueryProcessor;
import org.apache.ignite.internal.processors.query.GridQueryTypeDescriptor;
import org.apache.ignite.internal.processors.query.IgniteSQLException;
import org.apache.ignite.internal.processors.query.QueryIndexDescriptorImpl;
import org.apache.ignite.internal.processors.query.QueryTypeDescriptorImpl;
import org.apache.ignite.internal.processors.query.QueryUtils;
import org.apache.ignite.internal.util.GridStringBuilder;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.internal.util.typedef.T2;
import org.apache.ignite.internal.util.typedef.internal.SB;
import org.apache.ignite.internal.util.typedef.internal.U;
import org.apache.ignite.lang.IgniteBiTuple;
import org.jetbrains.annotations.Nullable;

import static org.apache.ignite.internal.processors.query.QueryUtils.KEY_FIELD_NAME;

/**
 * Tests for dynamic schema changes.
 */
@SuppressWarnings("unchecked")
public abstract class AbstractSchemaSelfTest extends AbstractIndexingCommonTest {
    /** Cache. */
    protected static final String CACHE_NAME = "cache";

    /** Table name. */
    protected static final String TBL_NAME = tableName(ValueClass.class);

    /** Escaped table name. */
    protected static final String TBL_NAME_ESCAPED = typeName(ValueClass.class);

    /** Index name 1. */
    protected static final String IDX_NAME_1 = "IDX_1";

    /** Index name 1 escaped. */
    protected static final String IDX_NAME_1_ESCAPED = "idx_1";

    /** Index name 2. */
    protected static final String IDX_NAME_2 = "IDX_2";

    /** Index name 2 escaped. */
    protected static final String IDX_NAME_2_ESCAPED = "idx_2";

    /** Index name 2. */
    protected static final String IDX_NAME_LOCAL = "IDX_LOC";

    /** Key ID field. */
    protected static final String FIELD_KEY = "id";

    /** Key alias */
    protected static final String FIELD_KEY_ALIAS = "key";

    /** Field 1. */
    protected static final String FIELD_NAME_1 = "FIELD1";

    /** Field 1 escaped. */
    protected static final String FIELD_NAME_1_ESCAPED = "field1";

    /** Field 2. */
    protected static final String FIELD_NAME_2 = "FIELD2";

    /** Field 2 escaped. */
    protected static final String FIELD_NAME_2_ESCAPED = "field2";

    /**
     * Extra index key fields to override default extra index fields in
     * {@link #indexFields(IgniteCache, String, IgniteBiTuple[])}.
     */
    protected static List<IgniteBiTuple<String, Boolean>> extraIdxKeyFields;

    /** {@inheritDoc} */
    @Override protected void beforeTest() throws Exception {
        super.beforeTest();

        extraIdxKeyFields = null;
    }

    /**
     * Create common node configuration.
     *
     * @param idx Index.
     * @return Configuration.
     * @throws Exception If failed.
     */
    protected IgniteConfiguration commonConfiguration(int idx) throws Exception {
        IgniteConfiguration cfg = super.getConfiguration(getTestIgniteInstanceName(idx));

        cfg.setMarshaller(new BinaryMarshaller());

        DataStorageConfiguration memCfg = new DataStorageConfiguration().setDefaultDataRegionConfiguration(
            new DataRegionConfiguration().setMaxSize(128 * 1024 * 1024));

        cfg.setDataStorageConfiguration(memCfg);

        return optimize(cfg);
    }

    /**
     * Ensure that SQL exception is thrown.
     *
     * @param r Runnable.
     * @param expCode Error code.
     */
    static void assertSqlException(Runnable r, int expCode) {
        try {
            try {
                r.run();
            }
            catch (CacheException e) {
                if (e.getCause() != null)
                    throw (Exception)e.getCause();
                else
                    throw e;
            }
        }
        catch (IgniteSQLException e) {
            assertEquals("Unexpected error code [expected=" + expCode + ", actual=" + e.statusCode() + ']',
                expCode, e.statusCode());

            return;
        }
        catch (Exception e) {
            fail("Unexpected exception: " + e);
        }

        fail(IgniteSQLException.class.getSimpleName() + " is not thrown.");
    }

    /**
     * Get type on the given node for the given cache and table name. Type must exist.
     *
     * @param node Node.
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @return Type.
     */
    static QueryTypeDescriptorImpl typeExisting(IgniteEx node, String cacheName, String tblName) {
        QueryTypeDescriptorImpl res = type(node, cacheName, tblName);

        assertNotNull(res);

        return res;
    }

    /**
     * Get type on the given node for the given cache and table name.
     *
     * @param node Node.
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @return Type.
     */
    @Nullable protected static QueryTypeDescriptorImpl type(IgniteEx node, String cacheName, String tblName) {
        return types(node, cacheName).get(tblName);
    }

    /**
     * Get available types on the given node for the given cache.
     *
     * @param node Node.
     * @param cacheName Cache name.
     * @return Map from table name to type.
     */
    protected static Map<String, QueryTypeDescriptorImpl> types(IgniteEx node, String cacheName) {
        Map<String, QueryTypeDescriptorImpl> res = new HashMap<>();

        Collection<GridQueryTypeDescriptor> descs = node.context().query().types(cacheName);

        for (GridQueryTypeDescriptor desc : descs) {
            QueryTypeDescriptorImpl desc0 = (QueryTypeDescriptorImpl)desc;

            res.put(desc0.tableName(), desc0);
        }

        return res;
    }

    /**
     * Assert index state on all <b>affinity</b> nodes.
     *
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @param idxName Index name.
     * @param inlineSize Inline size.
     * @param fields Fields.
     */
    static void assertIndex(String cacheName, String tblName, String idxName,
        int inlineSize, IgniteBiTuple<String, Boolean>... fields) {
        for (Ignite node : Ignition.allGrids())
            assertIndex(node, cacheName, tblName, idxName, inlineSize, fields);
    }

    /**
     * Assert index state on particular node.
     *
     * @param node Node.
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @param idxName Index name.
     * @param inlineSize Inline size.
     * @param fields Fields.
     */
    static void assertIndex(Ignite node, String cacheName, String tblName,
        String idxName, int inlineSize, IgniteBiTuple<String, Boolean>... fields) {
        awaitCompletion();

        IgniteCache<?, ?> cache = node.cache(cacheName);

        IgniteEx node0 = (IgniteEx)node;

        ArrayList<IgniteBiTuple<String, Boolean>> res = new ArrayList<>();

        try {
            try (Connection c = connect(node0)) {
                try (ResultSet rs = c.getMetaData().getIndexInfo(null, cacheName, tblName, false, false)) {
                    while (rs.next()) {
                        if (F.eq(idxName, rs.getString("INDEX_NAME")))
                            res.add(new T2<>(rs.getString("COLUMN_NAME"), F.eq("A", rs.getString("ASC_OR_DESC"))));
                    }
                }
            }

            assertFalse("Index not found: " + idxName, res.isEmpty());

            assertEquals(indexFields(cache, idxName, fields), res);
        }
        catch (SQLException e) {
            throw new AssertionError(e);
        }

        // Also, let's check internal stuff not visible via JDBC - like inline size.
        QueryTypeDescriptorImpl typeDesc = typeExisting(node0, cacheName, tblName);

        assertInternalIndexParams(typeDesc, idxName, inlineSize);
    }

    /**
     * Forms full list of index fields. It is assumed that cache has the only QueryEntity.
     *
     * @param cache Cache with index.
     * @param idxName Index name.
     * @param fields Index fields specified in configuration or DDL.
     */
    static Collection<IgniteBiTuple<String, Boolean>> indexFields(IgniteCache<?, ?> cache, String idxName,
        IgniteBiTuple<String, Boolean>... fields) {
        Collection<QueryEntity> qryEntities = cache.getConfiguration(CacheConfiguration.class).getQueryEntities();

        assertEquals(1, qryEntities.size());

        QueryEntity qryEntity = qryEntities.iterator().next();

        List<IgniteBiTuple<String, Boolean>> keyFields;

        if (F.isEmpty(extraIdxKeyFields)) {
            // Indexes, created dynamically via DDL contains extra unwrapped primary key fields.
            keyFields = qryEntity.getKeyFields()
                .stream()
                .map(f -> F.t(f.toUpperCase(), true))
                .collect(Collectors.toList());

            // All indexes have system field '_KEY'.
            keyFields.add(F.t(KEY_FIELD_NAME, true));
        }
        else
            keyFields = extraIdxKeyFields;

        // Index fields are followed by primary key fields and _KEY field.
        List<IgniteBiTuple<String, Boolean>> fieldsList = new ArrayList<>(Arrays.asList(fields));
        fieldsList.addAll(keyFields);

        return fieldsList;
    }

    /**
     * Assert index details not available via JDBC.
     * @param typeDesc Type descriptor.
     * @param idxName Index name.
     * @param inlineSize Inline size.
     */
    private static void assertInternalIndexParams(QueryTypeDescriptorImpl typeDesc, String idxName, int inlineSize) {
        QueryIndexDescriptorImpl idxDesc = typeDesc.index(idxName);

        assertNotNull(idxDesc);

        assertEquals(idxName, idxDesc.name());
        assertEquals(typeDesc, idxDesc.typeDescriptor());
        assertEquals(QueryIndexType.SORTED, idxDesc.type());
        assertEquals(inlineSize, idxDesc.inlineSize());
    }

    /**
     * @param node Node to connect to.
     * @return Thin JDBC connection to specified node.
     */
    public static Connection connect(IgniteEx node) {
        Collection<GridPortRecord> recs = node.context().ports().records();

        GridPortRecord cliLsnrRec = null;

        for (GridPortRecord rec : recs) {
            if (rec.clazz() == ClientListenerProcessor.class) {
                cliLsnrRec = rec;

                break;
            }
        }

        assertNotNull(cliLsnrRec);

        try {
            return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1:" + cliLsnrRec.port());
        }
        catch (SQLException e) {
            throw new AssertionError(e);
        }
    }

    /**
     * @param conn Connection.
     * @param sql Statement.
     * @throws SQLException if failed.
     */
    public static void execute(Connection conn, String sql) throws SQLException {
        try (Statement s = conn.createStatement()) {
            s.execute(sql);
        }
    }

    /**
     * Assert index doesn't exist on all nodes.
     *
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @param idxName Index name.
     */
    static void assertNoIndex(String cacheName, String tblName, String idxName) {
        for (Ignite node : Ignition.allGrids())
            assertNoIndex(node, cacheName, tblName, idxName);
    }

    /**
     * Assert index doesn't exist on particular node.
     *
     * @param node Node.
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @param idxName Index name.
     */
    static void assertNoIndex(Ignite node, String cacheName, String tblName, String idxName) {
        awaitCompletion();

        node.cache(cacheName);

        try {
            try (Connection c = connect((IgniteEx)node)) {
                try (ResultSet rs = c.getMetaData().getIndexInfo(null, cacheName, tblName, false, false)) {
                    while (rs.next()) {
                        assertFalse("Index exists, although shouldn't: " + tblName + '.' + idxName,
                            F.eq(idxName, rs.getString("INDEX_NAME")));
                    }
                }
            }
        }
        catch (SQLException e) {
            throw new AssertionError(e);
        }
    }

    /**
     * Await completion (hopefully) of pending operations.
     */
    private static void awaitCompletion() {
        try {
            U.sleep(100);
        }
        catch (IgniteInterruptedCheckedException e) {
            fail();
        }
    }

    /**
     * Get table name for class.
     *
     * @param cls Class.
     * @return Table name.
     */
    protected static String typeName(Class cls) {
        return cls.getSimpleName();
    }

    /**
     * Get table name for class.
     *
     * @param cls Class.
     * @return Table name.
     */
    static String tableName(Class cls) {
        return QueryUtils.normalizeObjectName(typeName(cls), true);
    }

    /**
     * Convenient method for index creation.
     *
     * @param name Name.
     * @param fields Fields.
     * @return Index.
     */
    protected static QueryIndex index(String name, IgniteBiTuple<String, Boolean>... fields) {
        QueryIndex idx = new QueryIndex();

        idx.setName(name);

        LinkedHashMap<String, Boolean> fields0 = new LinkedHashMap<>();

        for (IgniteBiTuple<String, Boolean> field : fields)
            fields0.put(field.getKey(), field.getValue());

        idx.setFields(fields0);

        return idx;
    }

    /**
     * Execute SQL statement on given node.
     *
     * @param node Node.
     * @param sql Statement.
     */
    protected List<List<?>> execute(Ignite node, String sql) {
        return queryProcessor(node).querySqlFields(new SqlFieldsQuery(sql).setSchema("PUBLIC"), true).getAll();
    }

    /**
     * Get query processor.
     *
     * @param node Node.
     * @return Query processor.
     */
    public static GridQueryProcessor queryProcessor(Ignite node) {
        return queryProcessor((IgniteEx)node);
    }

    /**
     * Get query processor.
     *
     * @param node Node.
     * @return Query processor.
     */
    protected static GridQueryProcessor queryProcessor(IgniteEx node) {
        return node.context().query();
    }

    /**
     * Field for index state check (ascending).
     *
     * @param name Name.
     * @return Field.
     */
    protected static IgniteBiTuple<String, Boolean> field(String name) {
        return field(name, true);
    }

    /**
     * Field for index state check.
     *
     * @param name Name.
     * @param asc Ascending flag.
     * @return Field.
     */
    protected static IgniteBiTuple<String, Boolean> field(String name, boolean asc) {
        return F.t(name.toUpperCase(), asc);
    }

    /**
     * @param fieldName Field name.
     * @return Alias.
     */
    protected static String alias(String fieldName) {
        return fieldName + "_alias";
    }

    /**
     * Synchronously create index.
     *
     * @param node Ignite node.
     * @param cacheName Cache name.
     * @param tblName Table name.
     * @param idx Index.
     * @param ifNotExists When set to true operation will fail if index already exists.
     * @param parallel Parallelism level.
     * @throws Exception If failed.
     */
    void dynamicIndexCreate(Ignite node, String cacheName, String tblName, QueryIndex idx,
        boolean ifNotExists, int parallel)
        throws Exception {
        GridStringBuilder sql = new SB("CREATE INDEX ")
            .a(ifNotExists ? "IF NOT EXISTS " : "")
            .a(idx.getName())
            .a(" ON ")
            .a(tblName)
            .a(" (");

        boolean first = true;

        for (Map.Entry<String, Boolean> fieldEntry : idx.getFields().entrySet()) {
            if (first)
                first = false;
            else
                sql.a(", ");

            String name = fieldEntry.getKey();
            boolean asc = fieldEntry.getValue();

            sql.a(name).a(" ").a(asc ? "ASC" : "DESC");
        }

        sql.a(')');

        if (idx.getInlineSize() != QueryIndex.DFLT_INLINE_SIZE)
            sql.a(" INLINE_SIZE ").a(idx.getInlineSize());

        if (parallel != 0)
            sql.a(" PARALLEL ").a(parallel);

        executeSql(node, cacheName, sql.toString());
    }

    /**
     * Synchronously drop index.
     *
     * @param node Ignite node.
     * @param cacheName Cache name.
     * @param idxName Index name.
     * @param ifExists When set to true operation fill fail if index doesn't exists.
     * @throws Exception if failed.
     */
    protected void dynamicIndexDrop(Ignite node, String cacheName, String idxName, boolean ifExists) throws Exception {
        String sql = "DROP INDEX " + (ifExists ? "IF EXISTS " : "") + idxName;

        executeSql(node, cacheName, sql);
    }

    /**
     * Start SQL cache on given node.
     * @param node Node to create cache on.
     * @param ccfg Cache configuration.
     * @return Created cache.
     */
    protected IgniteCache<?, ?> createSqlCache(Ignite node, CacheConfiguration ccfg) throws IgniteCheckedException {
        ((IgniteEx)node).context().cache().dynamicStartSqlCache(ccfg).get();

        IgniteCache<?, ?> res = node.cache(CACHE_NAME);

        assertNotNull(res);

        return res;
    }

    /**
     * Destroy SQL cache on given node.
     *
     * @param node Node to create cache on.
     * @throws IgniteCheckedException if failed.
     */
    protected void destroySqlCache(Ignite node) throws IgniteCheckedException {
        destroySqlCacheFuture(node).get();
    }

    /**
     * Starting destroy SQL cache with return of future.
     *
     * @param node Node to create cache on.
     * @return Future that will be completed when cache is destroyed.
     */
    protected IgniteInternalFuture<Boolean> destroySqlCacheFuture(Ignite node) {
        return ((IgniteEx)node).context().cache().dynamicDestroyCache(CACHE_NAME, true, true, false, null);
    }

    /**
     * Execute SQL.
     *
     * @param node Ignite node.
     * @param cacheName Cache name.
     * @param sql SQL.
     */
    private void executeSql(Ignite node, String cacheName, String sql) {
        log.info("Executing DDL: " + sql);

        node.cache(cacheName).query(new SqlFieldsQuery(sql)).getAll();
    }

    /**
     * Key class.
     */
    public static class KeyClass {
        /** ID. */
        @QuerySqlField
        private long id;

        /**
         * Constructor.
         *
         * @param id ID.
         */
        public KeyClass(long id) {
            this.id = id;
        }

        /**
         * @return ID.
         */
        public long id() {
            return id;
        }

        /** {@inheritDoc} */
        @Override public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            KeyClass keyCls = (KeyClass)o;

            return id == keyCls.id;

        }

        /** {@inheritDoc} */
        @Override public int hashCode() {
            return (int)(id ^ (id >>> 32));
        }
    }

    /**
     * Key class.
     */
    public static class ValueClass {
        /** Field 1. */
        @QuerySqlField
        private Long field1;

        /**
         * Constructor.
         *
         * @param field1 Field 1.
         */
        public ValueClass(Long field1) {
            this.field1 = field1;
        }

        /**
         * @return Field 1
         */
        public Long field1() {
            return field1;
        }
    }

    /**
     * Key class.
     */
    public static class ValueClass2 {
        /** Field 1. */
        @QuerySqlField(name = "field1")
        private String field;

        /**
         * Constructor.
         *
         * @param field Field 1.
         */
        public ValueClass2(String field) {
            this.field = field;
        }

        /**
         * @return Field 1
         */
        public String field() {
            return field;
        }
    }
}
