package com.pandoli365.bibimbap;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.Date;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Time;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.StringJoiner;

class DbUpdateQueryGeneratorTest {

    private static final String SELECT_PROFILE = "dev";
    private static final String UPDATE_PROFILE = "live";
    private static final String SELECT_SCHEMA = "dev";
    private static final String UPDATE_SCHEMA = "live";
    private static final Path OUTPUT_PATH = Path.of("src", "test", "db", "dev-to-live-update.sql");

    @Test
    void printRequiredUpdateQueries() throws Exception {
        DbConfig selectConfig = loadDbConfig(SELECT_PROFILE);
        DbConfig updateConfig = loadDbConfig(UPDATE_PROFILE);

        Class.forName(selectConfig.driverClassName());

        try (
                Connection selectConnection = DriverManager.getConnection(
                        selectConfig.url(), selectConfig.username(), selectConfig.password());
                Connection updateConnection = DriverManager.getConnection(
                        updateConfig.url(), updateConfig.username(), updateConfig.password())
        ) {
            List<String> sqlLines = generateRequiredUpdateQueries(selectConnection, updateConnection);
            Files.createDirectories(OUTPUT_PATH.getParent());
            Files.write(OUTPUT_PATH, sqlLines, StandardCharsets.UTF_8);

            System.out.println("SQL file saved: " + OUTPUT_PATH.toAbsolutePath());
        }
    }

    private List<String> generateRequiredUpdateQueries(Connection selectConnection, Connection updateConnection)
            throws SQLException {
        Map<String, TableInfo> selectTables = loadTables(selectConnection, SELECT_SCHEMA);
        Map<String, TableInfo> updateTables = loadTables(updateConnection, UPDATE_SCHEMA);

        List<String> sqlLines = new ArrayList<>();
        sqlLines.add("-- select = " + SELECT_SCHEMA);
        sqlLines.add("-- update = " + UPDATE_SCHEMA);
        sqlLines.add("-- generated SQL only. Review before running.");
        sqlLines.add("");

        addSchemaDiff(sqlLines, selectTables, updateTables);

        for (TableInfo selectTable : selectTables.values()) {
            TableInfo updateTable = updateTables.get(selectTable.name());
            if (updateTable == null) {
                addCreateAndCopyTableQueries(sqlLines, selectTable);
                continue;
            }

            if (selectTable.primaryKeys().isEmpty()) {
                sqlLines.add("-- SKIP " + SELECT_SCHEMA + "." + selectTable.name()
                        + ": primary key is required to compare rows.");
                continue;
            }

            List<ColumnInfo> commonColumns = selectTable.columns().stream()
                    .filter(column -> updateTable.columnNames().contains(column.name()))
                    .toList();
            List<ColumnInfo> commonPrimaryKeys = commonColumns.stream()
                    .filter(column -> selectTable.primaryKeys().contains(column.name()))
                    .toList();

            if (commonPrimaryKeys.size() != selectTable.primaryKeys().size()) {
                sqlLines.add("-- SKIP " + SELECT_SCHEMA + "." + selectTable.name()
                        + ": update schema does not have all primary key columns.");
                continue;
            }

            addTableDiffComments(sqlLines, selectConnection, updateConnection, selectTable, commonColumns, commonPrimaryKeys);
        }

        return sqlLines;
    }

    private void addCreateAndCopyTableQueries(List<String> sqlLines, TableInfo table) {
        StringJoiner createColumns = new StringJoiner("," + System.lineSeparator());
        for (ColumnInfo column : table.columns()) {
            createColumns.add("    " + columnDefinition(column));
        }

        sqlLines.add("-- " + table.name() + " table does not exist.");
        addSequenceCreateQueries(sqlLines, table);
        sqlLines.add("CREATE TABLE " + tableName(table.name())
                + " (" + System.lineSeparator()
                + createColumns
                + primaryKeyDefinition(table)
                + System.lineSeparator()
                + ");");
        addColumnCommentQueries(sqlLines, table);
        sqlLines.add("");
    }

    private void addSequenceCreateQueries(List<String> sqlLines, TableInfo table) {
        for (ColumnInfo column : table.columns()) {
            String sequenceName = sequenceName(column.defaultValue());
            if (sequenceName == null) {
                continue;
            }
            sqlLines.add("CREATE SEQUENCE IF NOT EXISTS " + quoteIdentifier(sequenceName) + ";");
        }
    }

    private String columnDefinition(ColumnInfo column) {
        StringBuilder definition = new StringBuilder()
                .append(quoteIdentifier(column.name()))
                .append(" ")
                .append(column.dataType());

        if (column.defaultValue() != null && !column.defaultValue().isBlank()) {
            definition.append(" DEFAULT ").append(normalizeDefaultValue(column.defaultValue()));
        }
        if (column.notNull()) {
            definition.append(" NOT NULL");
        }

        return definition.toString();
    }

    private String primaryKeyDefinition(TableInfo table) {
        if (table.primaryKeys().isEmpty()) {
            return "";
        }

        StringJoiner primaryKeys = new StringJoiner(", ");
        for (String primaryKey : table.primaryKeys()) {
            primaryKeys.add(quoteIdentifier(primaryKey));
        }
        return "," + System.lineSeparator() + "    PRIMARY KEY (" + primaryKeys + ")";
    }

    private void addColumnCommentQueries(List<String> sqlLines, TableInfo table) {
        for (ColumnInfo column : table.columns()) {
            if (column.comment() == null || column.comment().isBlank()) {
                continue;
            }
            sqlLines.add("COMMENT ON COLUMN " + tableName(table.name()) + "." + quoteIdentifier(column.name())
                    + " IS '" + escapeSql(column.comment()) + "';");
        }
    }

    private void addSchemaDiff(
            List<String> sqlLines,
            Map<String, TableInfo> selectTables,
            Map<String, TableInfo> updateTables
    ) {
        for (String tableName : selectTables.keySet()) {
            TableInfo updateTable = updateTables.get(tableName);
            if (updateTable == null) {
                sqlLines.add("-- ONLY IN " + SELECT_SCHEMA + ": " + tableName);
                continue;
            }

            TableInfo selectTable = selectTables.get(tableName);
            for (String columnName : selectTable.columnNames()) {
                if (!updateTable.columnNames().contains(columnName)) {
                    sqlLines.add("-- ONLY IN " + SELECT_SCHEMA + "." + tableName + ": " + columnName);
                }
            }
            for (String columnName : updateTable.columnNames()) {
                if (!selectTable.columnNames().contains(columnName)) {
                    sqlLines.add("-- ONLY IN " + UPDATE_SCHEMA + "." + tableName + ": " + columnName);
                }
            }
        }

        for (String tableName : updateTables.keySet()) {
            if (!selectTables.containsKey(tableName)) {
                sqlLines.add("-- ONLY IN " + UPDATE_SCHEMA + ": " + tableName);
            }
        }
        sqlLines.add("");
    }

    private void addTableDiffComments(
            List<String> sqlLines,
            Connection selectConnection,
            Connection updateConnection,
            TableInfo table,
            List<ColumnInfo> columns,
            List<ColumnInfo> primaryKeys
    ) throws SQLException {
        String selectSql = "SELECT " + columnList(columns) + " FROM " + qualified(SELECT_SCHEMA, table.name())
                + " ORDER BY " + columnList(primaryKeys);

        try (
                Statement statement = selectConnection.createStatement();
                ResultSet selectRows = statement.executeQuery(selectSql)
        ) {
            while (selectRows.next()) {
                Row sourceRow = readRow(selectRows, columns);
                Row targetRow = findTargetRow(updateConnection, table, columns, primaryKeys, sourceRow);

                if (targetRow == null) {
                    sqlLines.add("-- DATA DIFF " + table.name() + ": row exists only in " + SELECT_SCHEMA
                            + " where " + primaryKeyComment(primaryKeys, sourceRow));
                    continue;
                }

                List<ColumnInfo> changedColumns = columns.stream()
                        .filter(column -> !table.primaryKeys().contains(column.name()))
                        .filter(column -> !sameValue(sourceRow.values().get(column.name()), targetRow.values().get(column.name())))
                        .toList();

                if (!changedColumns.isEmpty()) {
                    sqlLines.add("-- DATA DIFF " + table.name() + ": row differs where "
                            + primaryKeyComment(primaryKeys, sourceRow)
                            + " columns " + changedColumnComment(changedColumns));
                }
            }
        }
    }

    private String primaryKeyComment(List<ColumnInfo> primaryKeys, Row row) {
        StringJoiner where = new StringJoiner(", ");
        for (ColumnInfo primaryKey : primaryKeys) {
            where.add(primaryKey.name() + "=" + row.values().get(primaryKey.name()));
        }
        return where.toString();
    }

    private String changedColumnComment(List<ColumnInfo> changedColumns) {
        StringJoiner columns = new StringJoiner(", ");
        for (ColumnInfo column : changedColumns) {
            columns.add(column.name());
        }
        return columns.toString();
    }

    private Row findTargetRow(
            Connection updateConnection,
            TableInfo table,
            List<ColumnInfo> columns,
            List<ColumnInfo> primaryKeys,
            Row sourceRow
    ) throws SQLException {
        StringJoiner where = new StringJoiner(" AND ");
        for (ColumnInfo primaryKey : primaryKeys) {
            where.add(quoteIdentifier(primaryKey.name()) + " = "
                    + sqlLiteral(sourceRow.values().get(primaryKey.name()), primaryKey.sqlType()));
        }

        String sql = "SELECT " + columnList(columns) + " FROM " + qualified(UPDATE_SCHEMA, table.name())
                + " WHERE " + where;

        try (
                Statement statement = updateConnection.createStatement();
                ResultSet targetRows = statement.executeQuery(sql)
        ) {
            if (!targetRows.next()) {
                return null;
            }
            return readRow(targetRows, columns);
        }
    }

    private Row readRow(ResultSet resultSet, List<ColumnInfo> columns) throws SQLException {
        Map<String, Object> values = new LinkedHashMap<>();
        for (ColumnInfo column : columns) {
            values.put(column.name(), resultSet.getObject(column.name()));
        }
        return new Row(values);
    }

    private Map<String, TableInfo> loadTables(Connection connection, String schema) throws SQLException {
        Map<String, List<ColumnInfo>> tableColumns = loadColumns(connection, schema);
        Map<String, Set<String>> primaryKeys = loadPrimaryKeys(connection, schema);
        Map<String, TableInfo> tables = new LinkedHashMap<>();

        for (Map.Entry<String, List<ColumnInfo>> entry : tableColumns.entrySet()) {
            tables.put(entry.getKey(), new TableInfo(
                    entry.getKey(),
                    entry.getValue(),
                    primaryKeys.getOrDefault(entry.getKey(), Set.of())
            ));
        }
        return tables;
    }

    private Map<String, List<ColumnInfo>> loadColumns(Connection connection, String schema) throws SQLException {
        String sql = """
                SELECT
                    c.relname AS table_name,
                    a.attname AS column_name,
                    pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
                    a.attnotnull AS not_null,
                    pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
                    pg_catalog.col_description(a.attrelid, a.attnum) AS column_comment
                FROM pg_catalog.pg_namespace n
                JOIN pg_catalog.pg_class c
                  ON c.relnamespace = n.oid
                JOIN pg_catalog.pg_attribute a
                  ON a.attrelid = c.oid
                LEFT JOIN pg_catalog.pg_attrdef ad
                  ON ad.adrelid = a.attrelid
                 AND ad.adnum = a.attnum
                WHERE n.nspname = '%s'
                  AND c.relkind = 'r'
                  AND a.attnum > 0
                  AND NOT a.attisdropped
                ORDER BY c.relname, a.attnum
                """.formatted(escapeSql(schema));

        Map<String, List<ColumnInfo>> tableColumns = new LinkedHashMap<>();
        try (
                Statement statement = connection.createStatement();
                ResultSet resultSet = statement.executeQuery(sql)
        ) {
            while (resultSet.next()) {
                String tableName = resultSet.getString("table_name");
                String columnName = resultSet.getString("column_name");
                int sqlType = resolveSqlType(connection, schema, tableName, columnName);
                tableColumns.computeIfAbsent(tableName, ignored -> new ArrayList<>())
                        .add(new ColumnInfo(
                                columnName,
                                resultSet.getString("data_type"),
                                resultSet.getBoolean("not_null"),
                                resultSet.getString("column_default"),
                                resultSet.getString("column_comment"),
                                sqlType
                        ));
            }
        }
        return tableColumns;
    }

    private Map<String, Set<String>> loadPrimaryKeys(Connection connection, String schema) throws SQLException {
        String sql = """
                SELECT kcu.table_name, kcu.column_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.key_column_usage kcu
                  ON tc.constraint_schema = kcu.constraint_schema
                 AND tc.constraint_name = kcu.constraint_name
                 AND tc.table_name = kcu.table_name
                WHERE tc.table_schema = '%s'
                  AND tc.constraint_type = 'PRIMARY KEY'
                ORDER BY kcu.table_name, kcu.ordinal_position
                """.formatted(escapeSql(schema));

        Map<String, Set<String>> primaryKeys = new LinkedHashMap<>();
        try (
                Statement statement = connection.createStatement();
                ResultSet resultSet = statement.executeQuery(sql)
        ) {
            while (resultSet.next()) {
                primaryKeys.computeIfAbsent(resultSet.getString("table_name"), ignored -> new LinkedHashSet<>())
                        .add(resultSet.getString("column_name"));
            }
        }
        return primaryKeys;
    }

    private int resolveSqlType(Connection connection, String schema, String tableName, String columnName)
            throws SQLException {
        String sql = "SELECT " + quoteIdentifier(columnName) + " FROM " + qualified(schema, tableName) + " WHERE 1 = 0";
        try (
                Statement statement = connection.createStatement();
                ResultSet resultSet = statement.executeQuery(sql)
        ) {
            ResultSetMetaData metaData = resultSet.getMetaData();
            return metaData.getColumnType(1);
        }
    }

    private static DbConfig loadDbConfig(String profile) throws IOException {
        Properties properties = new Properties();
        String path = profile + "/db.properties";
        try (InputStream inputStream = openDbConfig(path)) {
            properties.load(inputStream);
        }

        return new DbConfig(
                properties.getProperty("spring.datasource.driver-class-name"),
                properties.getProperty("spring.datasource.url"),
                properties.getProperty("spring.datasource.username"),
                properties.getProperty("spring.datasource.password")
        );
    }

    private static InputStream openDbConfig(String path) throws IOException {
        InputStream inputStream = DbUpdateQueryGeneratorTest.class.getClassLoader().getResourceAsStream(path);
        if (inputStream != null) {
            return inputStream;
        }

        Path sourcePath = Path.of("src", "main", "resources").resolve(path);
        if (Files.exists(sourcePath)) {
            return Files.newInputStream(sourcePath);
        }

        throw new IOException("Cannot find " + path);
    }

    private static boolean sameValue(Object left, Object right) {
        if (left instanceof BigDecimal leftDecimal && right instanceof BigDecimal rightDecimal) {
            return leftDecimal.compareTo(rightDecimal) == 0;
        }
        return Objects.equals(left, right);
    }

    private static String columnList(List<ColumnInfo> columns) {
        StringJoiner joiner = new StringJoiner(", ");
        for (ColumnInfo column : columns) {
            joiner.add(quoteIdentifier(column.name()));
        }
        return joiner.toString();
    }

    private static String qualified(String schema, String tableName) {
        return quoteIdentifier(schema) + "." + quoteIdentifier(tableName);
    }

    private static String tableName(String tableName) {
        return quoteIdentifier(tableName);
    }

    private static String normalizeDefaultValue(String defaultValue) {
        String normalized = defaultValue.replace("'" + SELECT_SCHEMA + ".", "'");
        normalized = normalized.replace("\"" + SELECT_SCHEMA + "\".", "");
        normalized = normalized.replace(SELECT_SCHEMA + ".", "");
        return normalized;
    }

    private static String sequenceName(String defaultValue) {
        if (defaultValue == null || !defaultValue.startsWith("nextval('")) {
            return null;
        }

        int start = "nextval('".length();
        int end = defaultValue.indexOf("'", start);
        if (end < 0) {
            return null;
        }

        String rawSequenceName = defaultValue.substring(start, end);
        int schemaSeparator = rawSequenceName.lastIndexOf('.');
        if (schemaSeparator >= 0) {
            return rawSequenceName.substring(schemaSeparator + 1);
        }
        return rawSequenceName;
    }

    private static String quoteIdentifier(String identifier) {
        return "\"" + identifier.replace("\"", "\"\"") + "\"";
    }

    private static String sqlLiteral(Object value, int sqlType) {
        if (value == null) {
            return "NULL";
        }

        return switch (sqlType) {
            case Types.BIGINT, Types.DECIMAL, Types.DOUBLE, Types.FLOAT, Types.INTEGER,
                    Types.NUMERIC, Types.REAL, Types.SMALLINT, Types.TINYINT -> value.toString();
            case Types.BOOLEAN, Types.BIT -> Boolean.TRUE.equals(value) ? "TRUE" : "FALSE";
            case Types.BINARY, Types.BLOB, Types.LONGVARBINARY, Types.VARBINARY ->
                    "'\\x" + HexFormat.of().formatHex((byte[]) value) + "'";
            case Types.DATE -> "'" + ((Date) value).toLocalDate() + "'";
            case Types.TIME, Types.TIME_WITH_TIMEZONE -> "'" + value + "'";
            case Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE -> "'" + timestampValue(value) + "'";
            default -> "'" + escapeSql(value.toString()) + "'";
        };
    }

    private static String timestampValue(Object value) {
        if (value instanceof Timestamp timestamp) {
            return timestamp.toInstant().toString();
        }
        if (value instanceof Time time) {
            return time.toLocalTime().toString();
        }
        if (value instanceof TemporalAccessor) {
            return value.toString();
        }
        return escapeSql(value.toString());
    }

    private static String escapeSql(String value) {
        return value.replace("'", "''");
    }

    private record DbConfig(String driverClassName, String url, String username, String password) {
    }

    private record ColumnInfo(
            String name,
            String dataType,
            boolean notNull,
            String defaultValue,
            String comment,
            int sqlType
    ) {
    }

    private record TableInfo(String name, List<ColumnInfo> columns, Set<String> primaryKeys) {
        private Set<String> columnNames() {
            Set<String> names = new LinkedHashSet<>();
            for (ColumnInfo column : columns) {
                names.add(column.name());
            }
            return names;
        }
    }

    private record Row(Map<String, Object> values) {
    }
}
