package com.booking.replication.augmenter;

import com.booking.replication.augmenter.model.schema.ColumnSchema;
import com.booking.replication.augmenter.model.schema.SchemaAtPositionCache;
import com.booking.replication.augmenter.model.schema.TableSchema;

import com.mysql.jdbc.Driver;

import org.apache.commons.dbcp2.BasicDataSource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.IOException;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;

public class ActiveSchemaManager implements SchemaManager {

    public interface Configuration {
        String FALLBACK_TO_RELPICANT = "augmenter.schema.active.fallback_to_replicant";

        String MYSQL_DRIVER_CLASS   = "augmenter.schema.active.mysql.driver.class";
        String MYSQL_HOSTNAME       = "augmenter.schema.active.mysql.hostname";
        String MYSQL_PORT           = "augmenter.schema.active.mysql.port";
        String MYSQL_ACTIVE_SCHEMA = "augmenter.schema.active.mysql.schema";
        String MYSQL_USERNAME       = "augmenter.schema.active.mysql.username";
        String MYSQL_PASSWORD       = "augmenter.schema.active.mysql.password";

        String BINLOG_MYSQL_HOSTNAME    = "mysql.hostname";
        String BINLOG_MYSQL_PORT        = "mysql.port";
        String BINLOG_MYSQL_SCHEMA      = "mysql.schema";
        String BINLOG_MYSQL_USERNAME    = "mysql.username";
        String BINLOG_MYSQL_PASSWORD    = "mysql.password";
    }

    private static final Logger LOG = LogManager.getLogger(ActiveSchemaManager.class);

    private static final String DEFAULT_MYSQL_DRIVER_CLASS = Driver.class.getName();

    private static final String CONNECTION_URL_FORMAT = "jdbc:mysql://%s:%d/%s";
    private static final String BARE_CONNECTION_URL_FORMAT = "jdbc:mysql://%s:%d";

    public static final String SHOW_CREATE_TABLE_SQL = "SHOW CREATE TABLE %s";

    public static final String LIST_COLUMNS_SQL = "SELECT COLUMN_NAME, COLUMN_TYPE, COLLATION_NAME, IS_NULLABLE, "
            + "COLUMN_KEY, COLUMN_DEFAULT,EXTRA, PRIVILEGES, COLUMN_COMMENT, DATA_TYPE, "
            + "CHARACTER_MAXIMUM_LENGTH, CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, DATETIME_PRECISION "
            + " FROM INFORMATION_SCHEMA.COLUMNS "
            + " WHERE TABLE_SCHEMA  = '%s' AND TABLE_NAME = '%s'";

    private final BasicDataSource activeSchemaDataSource;

    private final BasicDataSource replicantDataSource;

    private final boolean fallbackToReplicant;

    private final Function<String, TableSchema> computeTableSchemaLambda;

    private final SchemaAtPositionCache schemaAtPositionCache;

    private final Map<String, Object> configuration;

    public ActiveSchemaManager(Map<String, Object> configuration) {

        this.configuration = configuration;
        this.activeSchemaDataSource = initDatasource(configuration);
        this.replicantDataSource = initBinlogDatasource(configuration);
        this.schemaAtPositionCache = new SchemaAtPositionCache();

        this.fallbackToReplicant = (boolean) configuration.getOrDefault(Configuration.FALLBACK_TO_RELPICANT, false );

        LOG.warn("fallbackToReplicant set as " + (this.fallbackToReplicant ? "true" : "false") );

        String activeSchemaName = getMysqlActiveSchema(configuration);

        this.computeTableSchemaLambda = (tableName) -> {
            try {
                TableSchema ts = ActiveSchemaHelpers.computeTableSchema(activeSchemaName, tableName, ActiveSchemaManager.this.activeSchemaDataSource, ActiveSchemaManager.this.replicantDataSource, this.fallbackToReplicant);
                return ts;
            } catch (Exception e) {
                throw new RuntimeException( String.format("error listing columns from table \"%s\" : %s", tableName, e.getMessage()) );
            }
        };
    }

    private String getMysqlActiveSchema(Map<String, Object> configuration) {
        Object schema       = configuration.get(Configuration.MYSQL_ACTIVE_SCHEMA);
        Objects.requireNonNull(schema, String.format("Configuration required: %s", Configuration.MYSQL_ACTIVE_SCHEMA));

        return schema.toString();
    }

    private String getReplicantSchema(Map<String, Object> configuration) {
        Object schema       = configuration.get(Configuration.BINLOG_MYSQL_SCHEMA);
        Objects.requireNonNull(schema, String.format("Configuration required: %s", Configuration.BINLOG_MYSQL_SCHEMA));

        return schema.toString();
    }

    public BasicDataSource initDatasource(Map<String, Object> configuration) {
        Object driverClass  = configuration.getOrDefault(Configuration.MYSQL_DRIVER_CLASS, ActiveSchemaManager.DEFAULT_MYSQL_DRIVER_CLASS);
        Object hostname     = configuration.get(Configuration.MYSQL_HOSTNAME);
        Object port         = configuration.getOrDefault(Configuration.MYSQL_PORT, "3306");
        Object schema       = configuration.get(Configuration.MYSQL_ACTIVE_SCHEMA);
        Object username     = configuration.get(Configuration.MYSQL_USERNAME);
        Object password     = configuration.get(Configuration.MYSQL_PASSWORD);

        Objects.requireNonNull(hostname, String.format("Configuration required: %s", Configuration.MYSQL_HOSTNAME));
        Objects.requireNonNull(schema, String.format("Configuration required: %s", Configuration.MYSQL_ACTIVE_SCHEMA));
        Objects.requireNonNull(username, String.format("Configuration required: %s", Configuration.MYSQL_USERNAME));
        Objects.requireNonNull(password, String.format("Configuration required: %s", Configuration.MYSQL_PASSWORD));

        return this.getDataSource(driverClass.toString(), hostname.toString(), Integer.parseInt(port.toString()), schema.toString(), username.toString(), password.toString());
    }

    public BasicDataSource initBinlogDatasource(Map<String, Object> configuration) {
        Object driverClass = configuration.getOrDefault(Configuration.MYSQL_DRIVER_CLASS, ActiveSchemaManager.DEFAULT_MYSQL_DRIVER_CLASS);

        Object hostname = configuration.get(Configuration.BINLOG_MYSQL_HOSTNAME);
        Object port     = configuration.getOrDefault(Configuration.BINLOG_MYSQL_PORT, "3306");
        Object schema   = configuration.get(Configuration.BINLOG_MYSQL_SCHEMA);
        Object username = configuration.get(Configuration.BINLOG_MYSQL_USERNAME);
        Object password = configuration.get(Configuration.BINLOG_MYSQL_PASSWORD);

        Objects.requireNonNull(hostname, String.format("Configuration required: %s", Configuration.BINLOG_MYSQL_HOSTNAME));
        Objects.requireNonNull(schema, String.format("Configuration required: %s", Configuration.BINLOG_MYSQL_SCHEMA));
        Objects.requireNonNull(username, String.format("Configuration required: %s", Configuration.BINLOG_MYSQL_USERNAME));
        Objects.requireNonNull(password, String.format("Configuration required: %s", Configuration.BINLOG_MYSQL_PASSWORD));

        return this.getDataSource(driverClass.toString(), this.getFirst(hostname), Integer.parseInt(port.toString()), schema.toString(), username.toString(), password.toString());
    }

    private BasicDataSource getDataSource(String driverClass, String hostname, int port, String schema, String username, String password) {
        BasicDataSource dataSource = new BasicDataSource();

        dataSource.setDriverClassName(driverClass);
        dataSource.setUrl(String.format(ActiveSchemaManager.CONNECTION_URL_FORMAT, hostname, port, schema));
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        dataSource.setTestOnBorrow(true);
        return dataSource;
    }

    private BasicDataSource getDataSource(String driverClass, String hostname, int port, String username, String password) {
        BasicDataSource dataSource = new BasicDataSource();

        dataSource.setDriverClassName(driverClass);
        dataSource.setUrl(String.format(ActiveSchemaManager.BARE_CONNECTION_URL_FORMAT, hostname, port));
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        dataSource.setTestOnBorrow(true);
        return dataSource;
    }

    public boolean createDbIfNotExists(Map<String, Object> configuration) {
        Object driverClass  = configuration.getOrDefault(Configuration.MYSQL_DRIVER_CLASS, ActiveSchemaManager.DEFAULT_MYSQL_DRIVER_CLASS);
        Object hostname     = configuration.get(Configuration.MYSQL_HOSTNAME);
        Object port         = configuration.getOrDefault(Configuration.MYSQL_PORT, "3306");
        Object schema1      = configuration.get(Configuration.MYSQL_ACTIVE_SCHEMA);
        Object username     = configuration.get(Configuration.MYSQL_USERNAME);
        Object password     = configuration.get(Configuration.MYSQL_PASSWORD);

        Objects.requireNonNull(hostname, String.format("Configuration required: %s", Configuration.MYSQL_HOSTNAME));
        Objects.requireNonNull(schema1, String.format("Configuration required: %s", Configuration.MYSQL_ACTIVE_SCHEMA));
        Objects.requireNonNull(username, String.format("Configuration required: %s", Configuration.MYSQL_USERNAME));
        Objects.requireNonNull(password, String.format("Configuration required: %s", Configuration.MYSQL_PASSWORD));

        String schema = schema1.toString();
        BasicDataSource dataSource = this.getDataSource(driverClass.toString(), this.getFirst(hostname), Integer.parseInt(port.toString()), username.toString(), password.toString());
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("SHOW DATABASES LIKE ?");
            stmt.setString(1, schema);
            ResultSet resultSet = stmt.executeQuery();
            if (resultSet.next()) {
                LOG.info("Database " + schema + " already exists in active schema.");
                return true;
            }

            LOG.info("Database " + schema + " doesn't exists in active schema. Creating ...");
            PreparedStatement createDb = conn.prepareStatement("CREATE DATABASE " + schema);
            return createDb.execute();
        } catch (SQLException e) {
            LOG.error("Could not establist connection to: " + hostname, e);
        }
        return false;
    }

    @SuppressWarnings("unchecked")
    private String getFirst(Object object) {
        if (List.class.isInstance(object)) {
            return ((List<String>) object).get(0);
        } else {
            return object.toString();
        }
    }

    @Override
    public boolean execute(String tableName, String query) {

        LOG.info("Schema change => { tableName => " + tableName + ", query => " +  query + " }");

        String rewrittenQuery = query;

        try (Connection connection = this.activeSchemaDataSource.getConnection();
             Statement statement = connection.createStatement()) {

            if (tableName != null) {
                this.schemaAtPositionCache.removeTableFromCache(tableName);
            }

            String replicantSchemaName = getReplicantSchema(configuration);

            rewrittenQuery = ActiveSchemaHelpers.rewriteActiveSchemaName(query, replicantSchemaName);

            boolean executed = statement.execute(rewrittenQuery);

            if (tableName != null) {
                this.schemaAtPositionCache.reloadTableSchema(
                        tableName,
                        this.computeTableSchemaLambda
                );
            }
            return executed;
        } catch (SQLException exception) {
            throw new RuntimeException(String.format("Cannot sync ActiveSchema! Error executing query \"%s\": %s", rewrittenQuery, exception.getMessage()));
        }
    }

    @Override
    public SchemaAtPositionCache getSchemaAtPositionCache() {
        return this.schemaAtPositionCache;
    }

    @Override
    public List<ColumnSchema> listColumns(String tableName) {
        TableSchema tableSchema =
                this.schemaAtPositionCache.getTableColumns(
                        tableName,
                        this.computeTableSchemaLambda);
        if (tableSchema == null) {
            return null;
        }

        return (List<ColumnSchema>) tableSchema.getColumnSchemas();
    }

    @Override
    public List<String> getActiveSchemaTables() throws SQLException {
        try (Connection conn = this.activeSchemaDataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("SHOW TABLES");
            ArrayList<String> tables = new ArrayList<>();
            ResultSet resultSet = stmt.executeQuery();

            while (resultSet.next()) {
                tables.add(resultSet.getString(1));
            }

            return tables;
        }
    }

    @Override
    public boolean dropTable(String tableName) throws SQLException {
        try (Connection conn = this.activeSchemaDataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("DROP TABLE IF EXISTS " + tableName);
            return stmt.execute();
        }
    }

    @Override
    public String getCreateTable(String tableName) {
        try (Connection connection = this.activeSchemaDataSource.getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(String.format(ActiveSchemaManager.SHOW_CREATE_TABLE_SQL, tableName))) {
            if (resultSet.next()) {
                return resultSet.getString(2);
            } else {
                return null;
            }
        } catch (SQLException exception) {
            ActiveSchemaManager.LOG.warn(String.format("error getting create table from table \"%s\"", tableName, exception.getMessage()));
            return null;
        }
    }

    @Override
    public void close() throws IOException {
        try {
            this.activeSchemaDataSource.close();
            this.replicantDataSource.close();
        } catch (SQLException exception) {
            throw new IOException("error closing active schema loader", exception);
        }
    }

    @Override
    public Function<String, TableSchema> getComputeTableSchemaLambda() {
        return this.computeTableSchemaLambda;
    }

    public void copyTableSchemaFromReplicantToActiveSchema(
            String tableName
    ) throws SQLException {
        try (Connection replicantConnection = this.replicantDataSource.getConnection();
             Connection activeSchemaConnection = this.activeSchemaDataSource.getConnection()) {

            PreparedStatement preparedStatement = replicantConnection.prepareStatement("show create table " + tableName);
            ResultSet showCreateTableResultSet = preparedStatement.executeQuery();
            ResultSetMetaData showCreateTableResultSetMetadata = showCreateTableResultSet.getMetaData();
            String createTableStatement = ActiveSchemaHelpers.getCreateTableStatement(
                    tableName,
                    showCreateTableResultSet,
                    showCreateTableResultSetMetadata
            );
            activeSchemaConnection.createStatement().execute(createTableStatement);
        }
    }
}