package com.atlassian.stash.internal.db;

import com.atlassian.bitbucket.util.Version;
import com.atlassian.util.concurrent.LazyReference;
import com.atlassian.util.concurrent.ResettableLazyReference;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.stereotype.Component;

import javax.annotation.Nonnull;
import javax.annotation.PostConstruct;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.google.common.base.Preconditions.checkNotNull;

/**
 * A default implementation of {@link DatabaseSupplier}.
 * <p>
 * This implementation is able to validate support for the following database vendors:
 * <ul>
 *     <li>HSQL</li>
 *     <li>Microsoft SQL Server</li>
 *     <li>MySQL</li>
 *     <li>Oracle</li>
 *     <li>PostgreSQL</li>
 * </ul>
 * When the server starts, support for the connected database is <i>automatically verified</i> by {@link #validate()}.
 * If the connected database is not supported, an exception will be thrown which will prevent the server from starting.
 * This handles the case where, for example, the system is migrated to a MySQL 5.5 database which is later upgraded to
 * an unsupported version of MySQL 5.6.
 *
 * @since 2.2
 */
@Component("databaseSupplier") //Do _not_ make this a @Service! We don't want the exception translation
public class DefaultDatabaseSupplier implements DatabaseAffixed, DatabaseSupplier {

    public static final String NAME_H2 = "H2";
    public static final String NAME_HSQL = "HSQL";
    public static final String NAME_MYSQL = "MySQL";
    public static final String NAME_ORACLE = "Oracle";
    public static final String NAME_POSTGRESQL = "PostgreSQL";
    public static final String NAME_SQL_SERVER = "Microsoft SQL Server";

    private static final String HSQL_PRODUCT_NAME = "HSQL Database Engine";
    private static final Map<String, Function<Database, DetailedDatabase>> NAMES_TO_DETAILERS =
            ImmutableMap.<String, Function<Database, DetailedDatabase>>builder()
                    .put(NAME_H2, new InternalSupportLevel())
                    .put(NAME_HSQL, new InternalSupportLevel())
                    .put(NAME_SQL_SERVER, new SqlServerSupportLevel())
                    .put(NAME_MYSQL, new MySqlSupportLevel())
                    .put(NAME_ORACLE, new OracleSupportLevel())
                    .put(NAME_POSTGRESQL, new PostgresSupportLevel())
                    .build();
    /**
     * Parses the product version out of Oracle's verbose version strings.
     * <p>
     * Version strings in Oracle look like this: "Oracle Database 11g Express Edition Release 11.2.0.2.0 - Production".
     * Given this example, this pattern will parse out "11.2.0.2.0".
     */
    private static final Pattern PATTERN_ORACLE_VERSION = Pattern.compile(".+\\s([\\d\\.]+)\\s.*", Pattern.DOTALL);

    private static final Logger log = LoggerFactory.getLogger(DefaultDatabaseSupplier.class);

    private final ResettableLazyReference<DetailedDatabase> databaseReference;

    private boolean ignoreUnsupported;

    @Autowired
    public DefaultDatabaseSupplier(final DataSource dataSource) {
        databaseReference = new ResettableLazyReference<DetailedDatabase>() {

            @Override
            protected DetailedDatabase create() { //Implemented API allows checked exceptions; none should be thrown
                return detailsFor(dataSource);
            }
        };
    }

    @Nonnull
    @Override
    public DetailedDatabase get() {
        try {
            return databaseReference.get();
        } catch (LazyReference.InitializationException e) {
            Throwable cause = e.getCause();
            if (cause instanceof ExecutionException) {
                //The only way the cause should end up as an ExecutionException, based on InitializationException's
                //constructor, is if the ExecutionException has no cause Throwable set. As long as those semantics
                //remain, this will always set cause to null. Throwables.propagateIfPossible handles that case and
                //will not throw. That, in turn, will result in the InitializationException getting rethrown.
                //
                //This logic is here _purely_ in case the InitializationException semantics change.
                cause = cause.getCause();
            }
            Throwables.propagateIfPossible(cause);

            //This should never happen, but if the unwrapped exception is a checked exception, just throw the
            //InitializationException after all
            throw e;
        }
    }

    @Nonnull
    @Override
    public DetailedDatabase getForConnection(@Nonnull Connection connection) {
        return detailsFor(databaseFor(checkNotNull(connection, "connection")));
    }

    /**
     * Resets the {@link #databaseReference database reference} to release the old database. Since the reference
     * is lazy, any thread which attempts to retrieve database details after this is called will block on the
     * latch until the new database is available (or the old database is restored).
     *
     * @since 3.5
     */
    @Override
    public void release() {
        databaseReference.reset();
    }

    @Value("${jdbc.ignoreunsupported}")
    public void setIgnoreUnsupported(boolean ignoreUnsupported) {
        this.ignoreUnsupported = ignoreUnsupported;
    }

    /**
     * Ensures the connected {@link #get() database} is not {@link DatabaseSupportLevel#UNSUPPORTED unsupported}.
     * <p>
     * Setup and migration should never allow connecting to an unsupported database. However, the database may be
     * upgraded from a supported version to an unsupported version, such as upgrading MySQL from 5.5 to 5.6, or it
     * may have been configured by directly setting JDBC values in {@code bitbucket.properties}.
     * <p>
     * If the connected database is unsupported, an exception will be thrown to halt startup. This service should
     * always be initialised before Liquibase or Hibernate to ensure the system does not attempt to create, migrate
     * or validate the schema.
     *
     * @throws UnsupportedDatabaseException if the connected database is unsupported
     */
    @PostConstruct
    public void validate() {
        if (ignoreUnsupported) {
            log.warn("Not checking database support level; the check has been disabled");
            return;
        }
        DetailedDatabase database = get();

        if (DatabaseSupportLevel.UNSUPPORTED == database.getSupportLevel()) {
            throw new UnsupportedDatabaseException("The configured database is unsupported", database);
        }
    }

    @Nonnull
    private static Database databaseFor(Connection connection) {
        try {
            DatabaseMetaData metaData = connection.getMetaData();
            String productName = metaData.getDatabaseProductName();
            String productVersion = metaData.getDatabaseProductVersion();
            int majorVersion = metaData.getDatabaseMajorVersion();
            int minorVersion = metaData.getDatabaseMinorVersion();
            Version version = parseVersion(productName, productVersion, majorVersion, minorVersion);

            //For HSQL, simplify the name from "HSQL Database Engine" to simply "HSQL"
            return new JdbcMetadataDatabase(HSQL_PRODUCT_NAME.equals(productName) ? NAME_HSQL : productName,
                    version, majorVersion, minorVersion);
        } catch (SQLException e) {
            throw new DataRetrievalFailureException("Could not load database metadata", e);
        }
    }

    @Nonnull
    private static Database databaseFor(DataSource dataSource) {
        Connection connection = null;
        try {
            connection = DataSourceUtils.getConnection(dataSource);

            return databaseFor(connection);
        } finally {
            DataSourceUtils.releaseConnection(connection, dataSource);
        }
    }

    @Nonnull
    private static DetailedDatabase detailsFor(Database database) {
        Function<Database, DetailedDatabase> detailer = NAMES_TO_DETAILERS.get(database.getName());
        if (detailer == null) {
            log.error("{} is not a supported database", database.getName());

            return new DelegatingDetailedDatabase(database, DatabaseSupportLevel.UNSUPPORTED, null);
        }

        return detailer.apply(database);
    }

    @Nonnull
    private static DetailedDatabase detailsFor(DataSource dataSource) {
        return detailsFor(databaseFor(dataSource));
    }

    private static Version parseVersion(String productName, String productVersion, int majorVersion, int minorVersion) {
        if ("Oracle".equals(productName)) {
            Matcher matcher = PATTERN_ORACLE_VERSION.matcher(productVersion);
            if (matcher.matches()) {
                return new Version(matcher.group(1));
            }

            log.warn("Could not parse Oracle version [{}]; using major and minor versions", productVersion);
            return new Version(majorVersion, minorVersion);
        }

        return new Version(productVersion);
    }

    private abstract static class AbstractSupportLevel implements Function<Database, DetailedDatabase> {

        private final DbType type;

        protected AbstractSupportLevel(DbType type) {
            this.type = type;
        }

        @Override
        public DetailedDatabase apply(Database database) {
            return new DelegatingDetailedDatabase(database, supportFor(database), type);
        }

        @Nonnull
        protected abstract DatabaseSupportLevel supportFor(@Nonnull Database database);
    }

    private static class InternalSupportLevel extends AbstractSupportLevel {

        public InternalSupportLevel() {
            super(null);
        }

        @Nonnull
        @Override
        protected DatabaseSupportLevel supportFor(@Nonnull Database database) {
            return DatabaseSupportLevel.SUPPORTED;
        }
    }

    private static class MySqlSupportLevel extends AbstractSupportLevel {

        public MySqlSupportLevel() {
            super(DbType.MYSQL);
        }

        @Nonnull
        @Override
        protected DatabaseSupportLevel supportFor(@Nonnull Database database) {
            if (database.getMajorVersion() == 5) {
                int minor = database.getMinorVersion();
                if (minor == 0) {
                    //MySQL 5.0 is not part of the matrix build, so it cannot be marked as SUPPORTED, but ad hoc
                    //testing has shown that it works. Since it's not continuously verified, but has been known
                    //to work, the level is left as UNKNOWN rather than UNSUPPORTED
                    return DatabaseSupportLevel.UNKNOWN;
                }
                if (minor == 1 || minor == 5 || (minor == 6 && database.getPatchVersion() >= 16)) {
                    //MySQL 5.1 and 5.5 (all patch versions) are supported
                    //MySQL 5.6.16+ is supported
                    return DatabaseSupportLevel.SUPPORTED;
                }
            }

            //MySQL <= 5.6.10 has a critical bug which affects permission check queries. 5.6.11 includes a fix for
            //that bug, but suffers from another bug which breaks certain order by clauses. 5.6.16 includes a fix
            //for that bug and all existing tests pass on 5.6.16.
            //
            //After this experience with 5.6.x, future releases of MySQL will be handled pessimistically. As a result,
            //5.7.x will be considered unsupported until a final version is released and has been explicitly verified
            //as supported.
            //
            //See http://bugs.mysql.com/bug.php?id=68424 -> Query optimizer bug (affects permissions)
            //See http://bugs.mysql.com/bug.php?id=69005 -> Row ordering bug    (affects repository lists)
            return DatabaseSupportLevel.UNSUPPORTED;
        }
    }

    private static class OracleSupportLevel extends AbstractSupportLevel {

        private static final Set<Integer> SUPPORTED_VERSIONS = ImmutableSet.of(11, 12);

        public OracleSupportLevel() {
            super(DbType.ORACLE);
        }

        @Nonnull
        @Override
        protected DatabaseSupportLevel supportFor(@Nonnull Database database) {
            // We only test against the SUPPORTED_VERSIONS. Earlier versions may work
            // but we aren't sure.
            return SUPPORTED_VERSIONS.contains(database.getMajorVersion()) ?
                    DatabaseSupportLevel.SUPPORTED :
                    DatabaseSupportLevel.UNKNOWN;
        }
    }

    private static class PostgresSupportLevel extends AbstractSupportLevel {

        private static final Version POSTGRES_8_2 = new Version(8, 2);
        private static final Version POSTGRES_9_6 = new Version(9, 6);

        public PostgresSupportLevel() {
            super(DbType.POSTGRES);
        }

        @Nonnull
        @Override
        protected DatabaseSupportLevel supportFor(@Nonnull Database database) {
            return between(database.getVersion(), POSTGRES_8_2, POSTGRES_9_6) ?
                    DatabaseSupportLevel.SUPPORTED :
                    DatabaseSupportLevel.UNKNOWN;
        }

        private static boolean between(Version version, Version lower, Version upper) {
            return version.compareTo(lower) > -1 && version.compareTo(upper) < 0;
        }
    }

    private static class SqlServerSupportLevel extends AbstractSupportLevel {

        private static final int SQL_SERVER_2008 = 10;
        private static final int SQL_SERVER_2016 = 13;

        public SqlServerSupportLevel() {
            super(DbType.MSSQL);
        }

        @Nonnull
        @Override
        public DatabaseSupportLevel supportFor(@Nonnull Database database) {
            int majorVersion = database.getMajorVersion();
            if (majorVersion < SQL_SERVER_2008) {
                //SQL Server 2000 (8) was never supported, SQL Server 2005 (9) was deprecated in 3.10 and
                //support was removed in 4.0
                return DatabaseSupportLevel.UNSUPPORTED;
            }
            if (majorVersion > SQL_SERVER_2016) {
                //Versions after SQL Server 2016 (13) are not tested, so their level of support is unknown
                //and may change in any release without warning.
                return DatabaseSupportLevel.UNKNOWN;
            }

            //SQL Server 2008 (10), 2012 (11), 2014 (12) and 2016 (13) are actively tested/supported
            return DatabaseSupportLevel.SUPPORTED;
        }
    }
}
