/*****************************************************************
 *   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
 *
 *    https://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.cayenne.dbsync.merge;

import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.access.DataNode;
import org.apache.cayenne.configuration.server.ServerRuntime;
import org.apache.cayenne.dba.DbAdapter;
import org.apache.cayenne.dba.TypesMapping;
import org.apache.cayenne.dbsync.DbSyncModule;
import org.apache.cayenne.dbsync.merge.context.MergerContext;
import org.apache.cayenne.dbsync.merge.factory.MergerTokenFactory;
import org.apache.cayenne.dbsync.merge.factory.MergerTokenFactoryProvider;
import org.apache.cayenne.dbsync.merge.token.db.AbstractToDbToken;
import org.apache.cayenne.dbsync.merge.token.MergerToken;
import org.apache.cayenne.dbsync.merge.token.db.SetColumnTypeToDb;
import org.apache.cayenne.dbsync.naming.DefaultObjectNameGenerator;
import org.apache.cayenne.dbsync.naming.NoStemStemmer;
import org.apache.cayenne.dbsync.reverse.dbload.DbLoader;
import org.apache.cayenne.dbsync.reverse.dbload.DbLoaderConfiguration;
import org.apache.cayenne.dbsync.reverse.dbload.LoggingDbLoaderDelegate;
import org.apache.cayenne.dbsync.reverse.filters.FiltersConfig;
import org.apache.cayenne.dbsync.reverse.filters.PatternFilter;
import org.apache.cayenne.dbsync.reverse.filters.TableFilter;
import org.apache.cayenne.di.Inject;
import org.apache.cayenne.map.DataMap;
import org.apache.cayenne.map.DbAttribute;
import org.apache.cayenne.map.DbEntity;
import org.apache.cayenne.map.EntityResolver;
import org.apache.cayenne.test.jdbc.DBHelper;
import org.apache.cayenne.unit.UnitDbAdapter;
import org.apache.cayenne.unit.di.server.CayenneProjects;
import org.apache.cayenne.unit.di.server.ExtraModules;
import org.apache.cayenne.unit.di.server.ServerCase;
import org.apache.cayenne.unit.di.server.ServerCaseDataSourceFactory;
import org.apache.cayenne.unit.di.server.UseServerRuntime;
import org.slf4j.Logger;
import org.junit.Before;
import org.slf4j.LoggerFactory;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;

import static org.junit.Assert.assertEquals;

@UseServerRuntime(CayenneProjects.TESTMAP_PROJECT)
@ExtraModules(DbSyncModule.class)
public abstract class MergeCase extends ServerCase {

    @Inject
    protected EntityResolver resolver;
    @Inject
    protected DataNode node;
    protected DataMap map;
    private Logger logger = LoggerFactory.getLogger(MergeCase.class);
    @Inject
    private DBHelper dbHelper;
    @Inject
    private ServerRuntime runtime;
    @Inject
    protected UnitDbAdapter accessStackAdapter;
    @Inject
    private ServerCaseDataSourceFactory dataSourceFactory;

    @Override
    public void cleanUpDB() throws Exception {
        dbHelper.update("ARTGROUP").set("PARENT_GROUP_ID", null, Types.INTEGER).execute();
        super.cleanUpDB();
    }

    @Before
    public void setUp() throws Exception {

        // this map can't be safely modified in this test, as it is reset by DI
        // container
        // on every test
        map = runtime.getDataDomain().getDataMap("testmap");

        filterDataMap();

        List<MergerToken> tokens = createMergeTokens();
        execute(tokens);

        assertTokensAndExecute(0, 0);
    }

    protected DataMapMerger.Builder merger() {
        return DataMapMerger.builder(mergerFactory());
    }

    protected List<MergerToken> createMergeTokens() {
        return createMergeTokens("ARTIST|GALLERY|PAINTING|NEW_TABLE2?");
    }

    protected List<MergerToken> createMergeTokens(String tableFilterInclude) {

        FiltersConfig filters = FiltersConfig.create(null, null,
                TableFilter.include(tableFilterInclude), PatternFilter.INCLUDE_NOTHING);

        DbLoaderConfiguration loaderConfiguration = new DbLoaderConfiguration();
        loaderConfiguration.setFiltersConfig(filters);

        DataMap dbImport;
        try (Connection conn = node.getDataSource().getConnection();) {
            dbImport = new DbLoader(node.getAdapter(), conn,
                    loaderConfiguration,
                    new LoggingDbLoaderDelegate(LoggerFactory.getLogger(DbLoader.class)),
                    new DefaultObjectNameGenerator(NoStemStemmer.getInstance()))
                    .load();

        } catch (SQLException e) {
            throw new CayenneRuntimeException("Can't doLoad dataMap from db.", e);
        }

        List<MergerToken> tokens = merger().filters(filters).build().createMergeTokens(map, dbImport);
        return filter(tokens);
    }

    private List<MergerToken> filter(List<MergerToken> tokens) {
        return filterEmptyTypeChange(filterEmpty(tokens));
    }

    /**
     * Filter out tokens for db attribute type change when types is same for specific DB
     */
    private List<MergerToken> filterEmptyTypeChange(List<MergerToken> tokens) {
        List<MergerToken> tokensOut = new ArrayList<>();
        for(MergerToken token : tokens) {
            if(!(token instanceof SetColumnTypeToDb)) {
                tokensOut.add(token);
                continue;
            }
            SetColumnTypeToDb setColumnToDb = (SetColumnTypeToDb)token;
            int toType = setColumnToDb.getColumnNew().getType();
            int fromType = setColumnToDb.getColumnOriginal().getType();
            // filter out conversions between date/time types
            if(accessStackAdapter.onlyGenericDateType()) {
                if(isDateTimeType(toType) && isDateTimeType(fromType)){
                    continue;
                }
            }
            // filter out conversions between numeric types
            if(accessStackAdapter.onlyGenericNumberType()) {
                if(TypesMapping.isNumeric(toType) && TypesMapping.isNumeric(fromType)) {
                    continue;
                }
            }
            tokensOut.add(token);
        }

        return tokensOut;
    }

    private static boolean isDateTimeType(int type) {
        return type == Types.DATE || type == Types.TIME || type == Types.TIMESTAMP;
    }

    private List<MergerToken> filterEmpty(List<MergerToken> tokens) {
        List<MergerToken> tokensOut = new ArrayList<>();
        for(MergerToken token : tokens) {
            if(!token.isEmpty()) {
                tokensOut.add(token);
            }
        }
        return tokensOut;
    }

    /**
     * Remote binary pk {@link DbEntity} for {@link DbAdapter} not supporting
     * that and so on.
     */
    private void filterDataMap() {
        // copied from AbstractAccessStack.dbEntitiesInInsertOrder
        boolean excludeBinPK = accessStackAdapter.supportsBinaryPK();

        if (!excludeBinPK) {
            return;
        }

        List<DbEntity> entitiesToRemove = new ArrayList<DbEntity>();

        for (DbEntity ent : map.getDbEntities()) {
            for (DbAttribute attr : ent.getAttributes()) {
                // check for BIN PK or FK to BIN Pk
                if (attr.getType() == Types.BINARY || attr.getType() == Types.VARBINARY
                        || attr.getType() == Types.LONGVARBINARY) {

                    if (attr.isPrimaryKey() || attr.isForeignKey()) {
                        entitiesToRemove.add(ent);
                        break;
                    }
                }
            }
        }

        for (DbEntity e : entitiesToRemove) {
            map.removeDbEntity(e.getName(), true);
        }
    }

    protected void execute(List<MergerToken> tokens) {
        MergerContext mergerContext = MergerContext.builder(map).dataNode(node).build();
        for (MergerToken tok : tokens) {
            tok.execute(mergerContext);
        }
    }

    protected void execute(MergerToken token) throws Exception {
        MergerContext mergerContext = MergerContext.builder(map).dataNode(node).build();
        token.execute(mergerContext);
    }

    private void executeSql(String sql) throws Exception {

        try (Connection conn = dataSourceFactory.getSharedDataSource().getConnection();) {

            try (Statement st = conn.createStatement();) {
                st.execute(sql);
            }
        }
    }

    protected void assertTokens(List<MergerToken> tokens, int expectedToDb, int expectedToModel) {
        int actualToDb = 0;
        int actualToModel = 0;
        for (MergerToken token : tokens) {
            if (token.getDirection().isToDb()) {
                actualToDb++;
            } else if (token.getDirection().isToModel()) {
                actualToModel++;
            }
        }

        assertEquals("tokens to db", expectedToDb, actualToDb);
        assertEquals("tokens to model", expectedToModel, actualToModel);
    }

    protected void assertTokensAndExecute(int expectedToDb, int expectedToModel) {
        List<MergerToken> tokens = createMergeTokens();
        assertTokens(tokens, expectedToDb, expectedToModel);
        execute(tokens);
    }

    protected MergerTokenFactory mergerFactory() {
        return runtime.getInjector().getInstance(MergerTokenFactoryProvider.class).get(node.getAdapter());
    }

    protected void dropTableIfPresent(String tableName) throws Exception {

        // must have a dummy datamap for the dummy table for the downstream code
        // to work
        DataMap map = new DataMap("dummy");
        map.setQuotingSQLIdentifiers(map.isQuotingSQLIdentifiers());
        DbEntity entity = new DbEntity(tableName);
        map.addDbEntity(entity);

        AbstractToDbToken t = (AbstractToDbToken) mergerFactory().createDropTableToDb(entity);

        for (String sql : t.createSql(node.getAdapter())) {

            try {
                executeSql(sql);
            } catch (Exception e) {
                logger.info("Exception dropping table " + tableName + ", probably abscent..");
            }
        }
    }
}
