package com.atlassian.bitbucket.mesh.dbunit;

import com.atlassian.bitbucket.mesh.AbstractDaoTest;
import com.google.common.collect.Maps;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.*;
import org.dbunit.dataset.xml.FlatDtdWriter;
import org.junit.Test;

import javax.annotation.Resource;
import java.io.File;
import java.io.FileWriter;
import java.net.URL;
import java.security.CodeSource;
import java.util.Arrays;
import java.util.Map;

import static java.util.Optional.ofNullable;

/**
 * Uses the {@link AbstractDaoTest} underpinnings to build the schema in the target database, then uses DbUnit's
 * {@code FlatDtdWriter} to produce an XML DTD describing the schema. The emitted DTD can then be used in the DbUnit
 * XML datasets to ensure the table and column names used are correct, and to get auto-complete assistance from IDEs
 * during development.
 * <p>
 * To run this generator using IntelliJ, just right-click the class name and choose "Run 'SchemaDtdGenerator'". It
 * will attempt to automatically find and update {@code mesh-schema.dtd}. Because this class's name does not end in
 * {@code Test}, Maven and Surefire will not run it automatically (even though it has an {@code @Test} method); it
 * must be run manually. This is intentional, as the generator typically doesn't need to run.
 * <p>
 * Note: The lower-casing classes nested here are designed around the {@code FlatDtdWriter}. They apply lowercase
 * conversions to the names of elements based on the methods the DTD writer actually calls on {@code IDataSet}. That
 * means there are still code paths in them which will return names in their actual case. They're not intended to be
 * drop-in replacements for the real thing, but rather to help this class emit a DTD which is easy to use and correct
 * to the way the Liquibase changesets are written (which always use lowercase names for everything).
 */
public class SchemaDtdGenerator extends AbstractDaoTest {

    private static final String BANNER =
            "<!-- THIS FILE IS AUTO-GENERATED. Don't modify it by hand; run SchemaDtdGenerator instead. -->\n";
    private static final String DTD_NAME = "mesh-schema.dtd";
    private static final String DTD_PATH = "src/test/resources/dbunit/" + DTD_NAME;

    @Resource
    private DatabaseConnectionFactory connectionFactory;

    @Test
    public void writeDtd() throws Exception {
        IDatabaseConnection connection = connectionFactory.newConnection();
        try {
            IDataSet dataSet = new LowerCaseDataSet(connection.createDataSet());

            try (FileWriter fileWriter = new FileWriter(findDtd())) {
                //Help new developers on the team discover this generator
                fileWriter.write(BANNER);

                FlatDtdWriter writer = new FlatDtdWriter(fileWriter);
                writer.setContentModel(FlatDtdWriter.CHOICE);
                writer.write(dataSet);
            }
        } finally {
            connection.close();
        }
    }

    private static File findDtd() {
        //This file is in src/test/java, so its class should end up under target/test-classes. This chain
        //will use that location to determine where src/test/resources is so we can regenerate the DTD
        return ofNullable(SchemaDtdGenerator.class.getProtectionDomain().getCodeSource())
                .map(CodeSource::getLocation)
                .map(URL::getPath)
                .map(File::new)
                //target/test-classes -> ../..; this should be the dao-impl directory
                .map(testClasses -> testClasses.getParentFile().getParentFile())
                //dao-impl -> src/test/resources/.../bitbucket-schema.dtd
                .map(daoImpl -> new File(daoImpl, DTD_PATH))
                .filter(File::isFile)
                //If the DTD can't be found from the CodeSource, just write it wherever we are
                //and leave it to the developer to figure it out
                .orElseGet(() -> new File(DTD_NAME));
    }

    /**
     * A delegating implementation of DbUnit's {@code IDataSet} interface which converts all table names to lowercase.
     * {@code ITableMetadata} instances returned are wrapped in {@link LowerCaseTableMetaData} to lowercase column and
     * primary key names as well.
     */
    private static class LowerCaseDataSet implements IDataSet {

        private final IDataSet delegate;
        private final String[] names;
        private final Map<String, String> tableNames;

        public LowerCaseDataSet(IDataSet delegate) throws DataSetException {
            this.delegate = delegate;

            tableNames = Maps.newHashMap();
            names = Arrays.stream(delegate.getTableNames())
                    .map(name -> {
                        tableNames.put(name.toLowerCase(), name);

                        return name.toLowerCase();
                    })
                    .toArray(String[]::new);
        }

        @Override
        public ITable getTable(String tableName) throws DataSetException {
            return delegate.getTable(tableNames.get(tableName));
        }

        @Override
        public ITableMetaData getTableMetaData(String tableName) throws DataSetException {
            ITableMetaData metaData = delegate.getTableMetaData(tableNames.get(tableName));

            return new LowerCaseTableMetaData(tableName, metaData);
        }

        @Override
        public String[] getTableNames() {
            return names;
        }

        @Deprecated
        @Override
        public ITable[] getTables() throws DataSetException {
            return delegate.getTables();
        }

        @Override
        public boolean isCaseSensitiveTableNames() {
            return false;
        }

        @Override
        public ITableIterator iterator() throws DataSetException {
            return delegate.iterator();
        }

        @Override
        public ITableIterator reverseIterator() throws DataSetException {
            return delegate.reverseIterator();
        }
    }

    /**
     * A delegating implementation of DbUnit's {@code ITableMetaData} interface which converts all column and primary
     * key names to lowercase.
     */
    private static class LowerCaseTableMetaData implements ITableMetaData {

        private final Map<String, String> columnNames;
        private final Column[] columns;
        private final ITableMetaData metaData;
        private final Column[] primaryKeys;
        private final String tableName;

        public LowerCaseTableMetaData(String tableName, ITableMetaData metaData) throws DataSetException {
            this.metaData = metaData;
            this.tableName = tableName;

            columnNames = Maps.newHashMap();
            columns = transformColumns(metaData.getColumns());
            primaryKeys = transformColumns(metaData.getPrimaryKeys());
        }

        @Override
        public int getColumnIndex(String columnName) throws DataSetException {
            return metaData.getColumnIndex(columnNames.get(columnName));
        }

        @Override
        public Column[] getColumns() {
            return columns;
        }

        @Override
        public Column[] getPrimaryKeys() {
            return primaryKeys;
        }

        @Override
        public String getTableName() {
            return tableName;
        }

        private Column[] transformColumns(Column[] columns) {
            return Arrays.stream(columns)
                    .map(column -> {
                        String columnName = column.getColumnName();
                        columnNames.put(columnName.toLowerCase(), columnName);

                        return new Column(columnName.toLowerCase(), column.getDataType(),
                                column.getSqlTypeName(), column.getNullable(), column.getDefaultValue(),
                                column.getRemarks(), column.getAutoIncrement());
                    })
                    .toArray(Column[]::new);
        }
    }
}