package com.atlassian.stash.internal.hibernate;

import com.atlassian.hibernate.extras.hql.JoinTemporaryTableBulkIdStrategy;
import com.atlassian.stash.internal.db.DbType;
import com.atlassian.stash.internal.jdbc.DataSourceConfiguration;
import com.google.common.annotations.VisibleForTesting;
import org.hibernate.HibernateException;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.event.service.spi.DuplicationStrategy;
import org.hibernate.event.service.spi.EventListenerGroup;
import org.hibernate.event.service.spi.EventListenerRegistry;
import org.hibernate.event.spi.EventType;
import org.hibernate.service.spi.ServiceRegistryImplementor;
import org.springframework.orm.hibernate4.LocalSessionFactoryBean;
import org.springframework.orm.hibernate4.LocalSessionFactoryBuilder;
import org.springframework.util.CollectionUtils;

import javax.sql.DataSource;
import javax.validation.ValidatorFactory;
import java.util.Collection;
import java.util.Map;
import java.util.Properties;

/**
 * Adds a constructor which accepts a {@code DataSource} to the standard Spring {@code AnnotationSessionFactoryBean}.
 * <p>
 * To simplify database migration, it is desirable to reuse all of the Spring configuration that is applied to the
 * initial {@code SessionFactory} that is created when the system is started. The only piece of configuration that
 * will be different is the {@code DataSource} and the corresponding {@link DataSourceConfiguration}. Adding a
 * constructor which accepts those components allows using the Spring
 * {@code ApplicationContext.getBean(String, Object...)} method to override the prototype configured in the context
 * and get a session factory that uses a different one.
 *
 * @since 1.2
 */
public class ExtendedLocalSessionFactoryBean extends LocalSessionFactoryBean {

    private static final DuplicationStrategy USE_DERIVED = new DuplicationStrategy() {

        @Override
        public boolean areMatch(Object added, Object existing) {
            Class<?> existingClass = existing.getClass();
            Class<?> addedClass = added.getClass();

            //If both listeners have the same class, neither is "more derived" than the other and so this strategy does
            //not apply. Otherwise, if the new listener is an instance of the old listener's class but the old listener
            //is not an instance of the new listener's class, it means the new listener is "more derived" than the old
            //listener and should be used instead.
            return !addedClass.equals(existingClass) && existingClass.isInstance(added);
        }

        @Override
        public Action getAction() {
            return Action.REPLACE_ORIGINAL;
        }
    };

    private final DataSourceConfiguration dataSourceConfiguration;

    private Map<EventType, Object> eventListeners;
    private ValidatorFactory validatorFactory;

    public ExtendedLocalSessionFactoryBean(DataSource dataSource, DataSourceConfiguration dataSourceConfiguration) {
        setDataSource(dataSource);
        this.dataSourceConfiguration = dataSourceConfiguration;
    }

    /**
     * Overrides the base class to return {@code SessionFactoryImplementor.class} instead of {@code SessionFactory}.
     * This is not strictly necessary, but it removes an IDEA warning in {@code stash-context.xml}. A significant
     * portion of Hibernate's internals assume that the {@code SessionFactory} they get can be safely cast to
     * {@code SessionFactoryImplementor}, and {@code SessionFactoryImpl}, which is the only {@code SessionFactory}
     * implementation, implements both interfaces.
     * <p>
     * This primarily facilitates injecting the created bean into {@link DelegatingSwappableSessionFactory}, which
     * requires a {@code SessionFactoryImplementor}.
     *
     * @return {@code SessionFactoryImplementor.class}
     */
    @Override
    public Class<SessionFactoryImplementor> getObjectType() {
        //This is more to make IDEA happy than anything; Spring doesn't really care that much.
        return SessionFactoryImplementor.class;
    }

    /**
     * Customized factory method for the {@code SessionFactory}. Applies customizations to the provided
     * {@code configuration}, as well as the constructed session factory before returning it to the clients.
     *
     * @param configuration the configuration to build from
     * @throws HibernateException may be thrown by the superclass; never thrown directly here
     */
    @Override
    protected SessionFactory buildSessionFactory(LocalSessionFactoryBuilder configuration) throws HibernateException {
        customizeConfiguration(configuration);

        SessionFactoryImplementor sessionFactory = (SessionFactoryImplementor) super.buildSessionFactory(configuration);
        registerEventListeners(sessionFactory);

        return sessionFactory;
    }

    /**
     * Customize configuration before building the session factory.
     *
     * @param configuration the configuration to process
     */
    @VisibleForTesting
    void customizeConfiguration(LocalSessionFactoryBuilder configuration) {
        applyMultiTableBulkIdStrategy(configuration);
        applyValidatorFactory(configuration);
    }

    /**
     * Apply custom multi table bulk ID strategy for bulk updates and deletes for MySQL to avoid performance issues
     * it has for {@code where ... in ...} statements that Hibernate produces by default.
     *
     * @param sfb the configuration for the {@code SessionFactory}
     */
    private void applyMultiTableBulkIdStrategy(LocalSessionFactoryBuilder sfb) {
        DbType.forDriver(dataSourceConfiguration.getDriverClassName())
                .filter(dbType -> dbType == DbType.MYSQL)
                .ifPresent(dbType -> {
                    Properties bulkIdStrategyProperties = new Properties();
                    bulkIdStrategyProperties.setProperty(AvailableSettings.HQL_BULK_ID_STRATEGY,
                            JoinTemporaryTableBulkIdStrategy.class.getName());

                    sfb.addProperties(bulkIdStrategyProperties);
                });
    }

    /**
     * If a {@code ValidatorFactory} {@link #setValidatorFactory(javax.validation.ValidatorFactory) was set}, applies
     * it to the properties that will be used to configure the {@code SessionFactory} when it is built.
     *
     * @param sfb the configuration for the {@code SessionFactory}
     */
    private void applyValidatorFactory(LocalSessionFactoryBuilder sfb) {
        if (validatorFactory != null) {
            //It's a bit obtuse how this all works. Hibernate's TypeSafeActivator class (which is package private) has
            //a constant for this property, and it looks it up to potentially get a configured factory. Note that it's
            //not possible to use sfb.setProperty as that is hard-coded for String, and Properties.setProperty uses
            //the same signature. This falls back on the Map.put method Properties has inherited (the internals of
            //TypeSafeActivator use the Map.get method, so its symmetrical to this) from Hashtable.
            Properties validatorProperties = new Properties();
            validatorProperties.put("javax.persistence.validation.factory", validatorFactory);

            sfb.addProperties(validatorProperties);
        }
    }

    /**
     * Registers any event listeners which have been {@link #setEventListeners(java.util.Map) set}.
     * <p>
     * Unlike the implementation in Spring's Hibernate 3-compatible {@code LocalSessionFactoryBean}, which replaces
     * any registered listeners for events included in the map, this implementation appends the listeners instead.
     * To try and prevent redundancies, it attempts to detect when a subclass of a standard Hibernate event listener
     * is registered, allowing it to replace Hibernate's default implementation with the subclass instead of having
     * both listeners registered (which leads to unexpected behaviour).
     *
     * @param sessionFactory the session factory to register the listeners with
     */
    @SuppressWarnings("unchecked")
    private void registerEventListeners(SessionFactoryImplementor sessionFactory) {
        if (!CollectionUtils.isEmpty(eventListeners)) {
            ServiceRegistryImplementor serviceRegistry = sessionFactory.getServiceRegistry();
            EventListenerRegistry listenerRegistry = serviceRegistry.getService(EventListenerRegistry.class);

            for (Map.Entry<EventType, Object> entry : eventListeners.entrySet()) {
                EventListenerGroup group = listenerRegistry.getEventListenerGroup(entry.getKey());
                group.addDuplicationStrategy(USE_DERIVED);

                Object value = entry.getValue();
                if (value instanceof Collection) {
                    group.appendListeners(((Collection) value).toArray());
                } else {
                    group.appendListener(value);
                }
            }
        }
    }

    /**
     * Restores {@code eventListeners} code that was removed in Spring when adding Hibernate 4 support.
     *
     * @param eventListeners the event listeners to register
     * @since 2.0
     * @see #registerEventListeners(org.hibernate.engine.spi.SessionFactoryImplementor)
     */
    public void setEventListeners(Map<EventType, Object> eventListeners) {
        this.eventListeners = eventListeners;
    }

    /**
     * Allows configuring Hibernate to use a {@code ValidatorFactory} configured in Spring instead of the
     * default {@code ValidatorFactory} it would assemble on its own.
     *
     * @param validatorFactory the validator factory to be used by Hibernate when applying JSR-303 annotations
     * @since 1.3
     * @see #applyValidatorFactory(org.springframework.orm.hibernate4.LocalSessionFactoryBuilder)
     */
    public void setValidatorFactory(ValidatorFactory validatorFactory) {
        this.validatorFactory = validatorFactory;
    }
}
