/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.openejb.resource.jdbc;

import org.apache.openejb.cipher.PasswordCipherException;
import org.apache.openejb.cipher.PasswordCipherFactory;
import org.apache.openejb.loader.IO;
import org.apache.openejb.loader.SystemInstance;
import org.apache.openejb.resource.XAResourceWrapper;
import org.apache.openejb.resource.jdbc.dbcp.DbcpDataSourceCreator;
import org.apache.openejb.resource.jdbc.driver.AlternativeDriver;
import org.apache.openejb.resource.jdbc.logging.LoggingSqlDataSource;
import org.apache.openejb.resource.jdbc.plugin.AbstractDataSourcePlugin;
import org.apache.openejb.resource.jdbc.plugin.DataSourcePlugin;
import org.apache.openejb.resource.jdbc.pool.DataSourceCreator;
import org.apache.openejb.resource.jdbc.pool.DefaultDataSourceCreator;
import org.apache.openejb.util.Duration;
import org.apache.openejb.util.LogCategory;
import org.apache.openejb.util.Logger;
import org.apache.openejb.util.SuperProperties;
import org.apache.xbean.recipe.ExecutionContext;
import org.apache.xbean.recipe.ObjectRecipe;
import org.apache.xbean.recipe.Option;
import org.apache.xbean.recipe.Recipe;

import javax.sql.CommonDataSource;
import javax.sql.ConnectionPoolDataSource;
import javax.sql.DataSource;
import javax.sql.XADataSource;
import java.io.Flushable;
import java.io.IOException;
import java.io.Serializable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.sql.Driver;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;

import static java.util.Arrays.asList;

/**
 * @version $Rev$ $Date$
 */
public class DataSourceFactory {

    private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, DataSourceFactory.class);

    public static final String LOG_SQL_PROPERTY = "LogSql";
    public static final String LOG_SQL_PACKAGE_PROPERTY = "LogSqlPackages";
    public static final String FLUSHABLE_PROPERTY = "Flushable";
    public static final String RESET_PROPERTY = "ResetOnError";
    public static final String RESET_METHODS_PROPERTY = "ResetOnErrorMethods";
    public static final String GLOBAL_LOG_SQL_PROPERTY = "openejb.jdbc.log";
    public static final String GLOBAL_LOG_SQL_PACKAGE_PROPERTY = "openejb.jdbc.log.packages";
    public static final String GLOBAL_FLUSH_PROPERTY = "openejb.jdbc.flushable";
    public static final String POOL_PROPERTY = "openejb.datasource.pool";
    public static final String DATA_SOURCE_CREATOR_PROP = "DataSourceCreator";
    public static final String HANDLER_PROPERTY = "TomEEProxyHandler";
    public static final String GLOBAL_HANDLER_PROPERTY = "openejb.jdbc.handler";

    private static final Map<CommonDataSource, AlternativeDriver> driverByDataSource = new HashMap<>();

    private static final Map<CommonDataSource, DataSourceCreator> creatorByDataSource = new HashMap<>();
    private static final Map<String, String> KNOWN_CREATORS = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER) {{
        put("simple", "org.apache.openejb.resource.jdbc.SimpleDataSourceCreator"); // use user provided DS, pooling not supported
        put("dbcp", "org.apache.openejb.resource.jdbc.pool.DefaultDataSourceCreator"); // the original one
        put("dbcp-alternative", "org.apache.openejb.resource.jdbc.dbcp.DbcpDataSourceCreator"); // dbcp for the ds pool only
        put("tomcat", "org.apache.tomee.jdbc.TomEEDataSourceCreator"); // tomee
        put("bonecp", "org.apache.openejb.bonecp.BoneCPDataSourceCreator"); // bonecp
    }};

    public static CommonDataSource create(final String name,
                                          final boolean configuredManaged,
                                          final Class impl,
                                          final String definition,
                                          final Duration maxWaitTime,
                                          final Duration timeBetweenEvictionRuns,
                                          final Duration minEvictableIdleTime) throws IllegalAccessException, InstantiationException, IOException {
        final Properties properties = asProperties(definition);
        final Set<String> originalKeys = properties.stringPropertyNames();

        final String handler = SystemInstance.get().getOptions().get(GLOBAL_HANDLER_PROPERTY, (String) properties.remove(HANDLER_PROPERTY));
        boolean flushable = SystemInstance.get().getOptions().get(GLOBAL_FLUSH_PROPERTY,
            "true".equalsIgnoreCase((String) properties.remove(FLUSHABLE_PROPERTY)));

        convert(properties, maxWaitTime, "maxWaitTime", "maxWait");
        convert(properties, timeBetweenEvictionRuns, "timeBetweenEvictionRuns", "timeBetweenEvictionRunsMillis");
        convert(properties, minEvictableIdleTime, "minEvictableIdleTime", "minEvictableIdleTimeMillis");

        // these can be added and are managed by OpenEJB and not the DataSource itself
        properties.remove("Definition");
        properties.remove("JtaManaged");
        properties.remove("ServiceId");

        boolean managed = configuredManaged;
        if (properties.containsKey("transactional")) {
            managed = Boolean.parseBoolean((String) properties.remove("transactional")) || managed;
        }

        normalizeJdbcUrl(properties);

        final String jdbcUrl = properties.getProperty("JdbcUrl");

        final AlternativeDriver driver;
        if (Driver.class.isAssignableFrom(impl) && jdbcUrl != null) {
            try {
                driver = new AlternativeDriver((Driver) impl.newInstance(), jdbcUrl);
                driver.register();
            } catch (final SQLException e) {
                throw new IllegalStateException(e);
            }
        } else {
            driver = null;
        }

        final boolean logSql = SystemInstance.get().getOptions().get(GLOBAL_LOG_SQL_PROPERTY,
            "true".equalsIgnoreCase((String) properties.remove(LOG_SQL_PROPERTY)));
        final String logPackages = SystemInstance.get().getProperty(GLOBAL_LOG_SQL_PACKAGE_PROPERTY, (String) properties.remove(LOG_SQL_PACKAGE_PROPERTY));
        final DataSourceCreator creator = creator(properties.remove(DATA_SOURCE_CREATOR_PROP), logSql);
        final String resetOnError = (String) properties.remove(RESET_PROPERTY);
        final String resetMethods = (String) properties.remove(RESET_METHODS_PROPERTY); // before setProperties()

        boolean useContainerLoader = "true".equalsIgnoreCase(SystemInstance.get().getProperty("openejb.resources.use-container-loader", "true")) && impl.getClassLoader() == DataSourceFactory.class.getClassLoader();
        final ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
        if (useContainerLoader) {
            final ClassLoader containerLoader = DataSourceFactory.class.getClassLoader();
            Thread.currentThread().setContextClassLoader(containerLoader);
            try {
                useContainerLoader = basicChecksThatDataSourceCanBeCreatedFromContainerLoader(properties, containerLoader);
            } finally {
                Thread.currentThread().setContextClassLoader(oldLoader);
            }
            if (useContainerLoader) {
                Thread.currentThread().setContextClassLoader(containerLoader);
            } else {
                LOGGER.info("Can't use container loader to create datasource " + name + " so using application one");
            }
        }

        try {
            CommonDataSource ds;
            if (createDataSourceFromClass(impl)) { // opposed to "by driver"
                trimNotSupportedDataSourceProperties(properties);

                final ObjectRecipe recipe = new ObjectRecipe(impl);
                recipe.allow(Option.CASE_INSENSITIVE_PROPERTIES);
                recipe.allow(Option.IGNORE_MISSING_PROPERTIES);
                recipe.allow(Option.NAMED_PARAMETERS);
                recipe.allow(Option.PRIVATE_PROPERTIES);
                recipe.setAllProperties(properties);
                if (!properties.containsKey("url") && properties.containsKey("JdbcUrl")) { // depend on the datasource class so add all well known keys
                    recipe.setProperty("url", properties.getProperty("JdbcUrl"));
                }

                final CommonDataSource dataSource = (CommonDataSource) recipe.create();
                final boolean isDs = DataSource.class.isInstance(dataSource);

                if (managed) {
                    if (isDs && usePool(properties)) {
                        ds = creator.poolManaged(name, DataSource.class.cast(dataSource), properties);
                    } else {
                        ds = creator.managed(name, dataSource);
                    }
                } else {
                    if (isDs && usePool(properties)) {
                        ds = creator.pool(name, DataSource.class.cast(dataSource), properties);
                    } else {
                        ds = dataSource;
                    }
                }
            } else { // by driver
                if (managed) {
                    final XAResourceWrapper xaResourceWrapper = SystemInstance.get().getComponent(XAResourceWrapper.class);
                    if (xaResourceWrapper != null) {
                        ds = creator.poolManagedWithRecovery(name, xaResourceWrapper, impl.getName(), properties);
                    } else {
                        ds = creator.poolManaged(name, impl.getName(), properties);
                    }
                } else {
                    ds = creator.pool(name, impl.getName(), properties);
                }
            }

            // ds and creator are associated here, not after the proxying of the next if if active
            setCreatedWith(creator, ds);
            if (driver != null) {
                driverByDataSource.put(ds, driver);
            }

            final boolean doResetOnError = resetOnError != null && !"false".equals(resetOnError);
            if (doResetOnError || logSql || flushable) { // will get proxied
                ObjectRecipe objectRecipe = null;
                ResettableDataSourceHandler existingResettableHandler = null;
                FlushableDataSourceHandler flushableDataSourceHandler = null;
                if (ExecutionContext.isContextSet()) {
                    final ExecutionContext context = ExecutionContext.getContext();
                    final List<Recipe> stack = context.getStack();
                    if (stack.size() > 0) {
                        objectRecipe = ObjectRecipe.class.cast(stack.get(0));
                        existingResettableHandler = ResettableDataSourceHandler.class.cast(objectRecipe.getProperty("resettableHandler"));
                        flushableDataSourceHandler = FlushableDataSourceHandler.class.cast(objectRecipe.getProperty("flushableHandler"));

                        final Map<String, Object> props = objectRecipe.getProperties();
                        for (final String key : originalKeys) {
                            props.remove(key);
                        }

                        // meta properties, not needed here so gain few cycles removing them
                        props.remove("properties");
                        props.remove("Definition");
                        props.remove("ServiceId");
                        props.remove("resettableHandler");
                        props.remove("flushableHandler");

                        //we create a proxy so we cant get txmgr etc in another manner or we cant extend (= break) this method
                        new ObjectRecipe(ds.getClass()) {{
                            allow(Option.CASE_INSENSITIVE_PROPERTIES);
                            allow(Option.IGNORE_MISSING_PROPERTIES);
                            allow(Option.NAMED_PARAMETERS);
                            allow(Option.PRIVATE_PROPERTIES);
                            setAllProperties(props);
                        }}.setProperties(ds);
                    }
                }

                ds = wrapIfNeeded(handler, ds);

                if (logSql) {
                    ds = makeItLogging(ds, logPackages);
                }

                final ResettableDataSourceHandler resettableDataSourceHandler;
                if (doResetOnError) { // needs to be done after flushable
                    // ensure we reuse the same handle instance otherwise we loose state
                    resettableDataSourceHandler = existingResettableHandler != null ?
                        existingResettableHandler :
                        new ResettableDataSourceHandler(ds, resetOnError, resetMethods);
                } else {
                    resettableDataSourceHandler = null;
                }

                if (flushable || doResetOnError) {
                    if (flushableDataSourceHandler == null) {
                        final FlushableDataSourceHandler.FlushConfig flushConfig;
                        properties.remove("flushable"); // don't let it wrap the delegate again

                        final Map<String, Object> recipeProps = new HashMap<>(objectRecipe == null ? new HashMap<String, Object>() : objectRecipe.getProperties());
                        recipeProps.remove("properties");

                        flushConfig = new FlushableDataSourceHandler.FlushConfig(recipeProps);
                        flushableDataSourceHandler = new FlushableDataSourceHandler(ds, flushConfig, resettableDataSourceHandler);
                    } else {
                        flushableDataSourceHandler.updateDataSource(ds);
                    }
                    ds = makeSerializableFlushableDataSourceProxy(ds, flushableDataSourceHandler);
                }
                if (doResetOnError) { // needs to be done after flushable
                    // ensure we reuse the same handle instance otherwise we loose state
                    resettableDataSourceHandler.updateDelegate(ds);
                    ds = makeSerializableFlushableDataSourceProxy(ds, resettableDataSourceHandler);
                }
            } else {
                ds = wrapIfNeeded(handler, ds);
            }

            return ds;
        } finally {
            if (useContainerLoader) {
                Thread.currentThread().setContextClassLoader(oldLoader);
            }
        }
    }

    private static CommonDataSource wrapIfNeeded(final String handler, final CommonDataSource ds) throws InstantiationException, IllegalAccessException {
        if (handler != null) {
            try {
                final Class<?> handlerClass = Thread.currentThread().getContextClassLoader().loadClass(handler);
                InvocationHandler instance;
                try {
                    instance = InvocationHandler.class.cast(handlerClass.getConstructor(DataSource.class).newInstance(ds));
                } catch (final InvocationTargetException e) {
                    throw new IllegalStateException(e.getCause());
                } catch (final NoSuchMethodException e) {
                    try {
                        instance = InvocationHandler.class.cast(handlerClass.getConstructor(CommonDataSource.class).newInstance(ds));
                    } catch (final InvocationTargetException e2) {
                        throw new IllegalStateException(e.getCause());
                    } catch (final NoSuchMethodException e2) {
                        instance = InvocationHandler.class.cast(handlerClass.newInstance());
                    }
                }
                return wrapWithHandler(ds, instance);
            } catch (ClassNotFoundException e) {
                throw new IllegalArgumentException("Can't find handler: " + handler, e);
            }
        }
        return ds;
    }

    public static CommonDataSource makeSerializableFlushableDataSourceProxy(final CommonDataSource ds, final InvocationHandler handler) {
        return (CommonDataSource) Proxy.newProxyInstance(
            Thread.currentThread().getContextClassLoader(),
            new Class<?>[]{DataSource.class.isInstance(ds) ? DataSource.class : XADataSource.class, Serializable.class, Flushable.class},
            handler);
    }

    private static boolean basicChecksThatDataSourceCanBeCreatedFromContainerLoader(final Properties properties, final ClassLoader containerLoader) {
        // check basic some classes can be loaded from container otherwise don't force it
        try {
            for (final String property : asList("JdbcDriver", "driverClassName")) {
                final String value = properties.getProperty(property);
                if (value != null) {
                    Class.forName(value, false, containerLoader);
                }
            }
        } catch (final ClassNotFoundException cnfe) {
            return false;
        } catch (final NoClassDefFoundError cnfe) {
            return false;
        }

        // also password cipher can be loaded from apps
        try {
            final String cipher = properties.getProperty("PasswordCipher");
            if (cipher != null && !"PlainText".equals(cipher) && !"Static3DES".equals(cipher)) {
                PasswordCipherFactory.getPasswordCipher(cipher);
            }
        } catch (final PasswordCipherException cnfe) {
            return false;
        }

        return true;
    }

    public static void setCreatedWith(final DataSourceCreator creator, final CommonDataSource ds) {
        creatorByDataSource.put(ds, creator);
    }

    private static CommonDataSource wrapWithHandler(final CommonDataSource ds, final InvocationHandler instance) {
        return (CommonDataSource) Proxy.newProxyInstance(
            Thread.currentThread().getContextClassLoader(),
            new Class<?>[]{DataSource.class.isInstance(ds) ? DataSource.class : XADataSource.class, Serializable.class},
            instance);
    }

    public static DataSource makeItLogging(final CommonDataSource ds, final String packagesStr) {
        final String[] pck = packagesStr == null ? null : packagesStr.split(" *, *");
        return (DataSource) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
            new Class<?>[]{DataSource.class, Serializable.class}, new LoggingSqlDataSource(ds, pck));
    }

    private static void normalizeJdbcUrl(final Properties properties) {
        final String key = "JdbcUrl";
        final String jdbcUrl = properties.getProperty(key);

        if (jdbcUrl == null) {
            return;
        }

        try {
            // get the plugin
            final DataSourcePlugin helper = BasicDataSourceUtil.getDataSourcePlugin(jdbcUrl);

            // configure this
            if (AbstractDataSourcePlugin.isActive(helper)) {
                final String newUrl = helper.updatedUrl(jdbcUrl);
                properties.setProperty(key, newUrl);
            }
        } catch (final SQLException e) {
            throw new IllegalStateException(e);
        }
    }

    private static void convert(final Properties properties, final Duration duration, final String key, final String oldKey) {
        properties.remove(key);

        // If someone is using the legacy property, use it
        if (properties.contains(oldKey)) {
            return;
        }
        properties.remove(oldKey);

        if (duration == null) {
            return;
        }
        if (duration.getUnit() == null) {
            duration.setUnit(TimeUnit.MILLISECONDS);
        }

        final long milliseconds = TimeUnit.MILLISECONDS.convert(duration.getTime(), duration.getUnit());
        properties.put(oldKey, String.valueOf(milliseconds));
    }

    public static DataSourceCreator creator(final Object creatorName, final boolean willBeProxied) {
        final DataSourceCreator defaultCreator = SystemInstance.get().getComponent(DataSourceCreator.class);
        final ClassLoader loader = Thread.currentThread().getContextClassLoader();
        if (creatorName != null && creatorName instanceof String
            && (defaultCreator == null || !creatorName.equals(defaultCreator.getClass().getName()))) {
            String clazz = KNOWN_CREATORS.get(creatorName);
            if (clazz == null) {
                clazz = (String) creatorName;
            }
            if (willBeProxied && clazz.equals(DefaultDataSourceCreator.class.getName())) {
                clazz = DbcpDataSourceCreator.class.getName();
            }
            try {
                return (DataSourceCreator) loader.loadClass(clazz).newInstance();
            } catch (final Throwable e) {
                LOGGER.error("can't create '" + creatorName + "', the default one will be used: " + defaultCreator, e);
            }
        }
        if (defaultCreator instanceof DefaultDataSourceCreator && willBeProxied) {
            // this one is proxiable, not the default one (legacy)
            return new DbcpDataSourceCreator();
        }
        return defaultCreator;
    }

    private static boolean createDataSourceFromClass(final Class<?> impl) {
        return isDataSource(impl) && !SystemInstance.get().getOptions().get("org.apache.openejb.resource.jdbc.hot.deploy", false);
    }

    private static boolean isDataSource(final Class<?> impl) {
        return DataSource.class.isAssignableFrom(impl) || XADataSource.class.isAssignableFrom(impl) ||
                ConnectionPoolDataSource.class.isAssignableFrom(impl);
    }

    private static boolean usePool(final Properties properties) {
        String property = properties.getProperty(POOL_PROPERTY, SystemInstance.get().getProperty(POOL_PROPERTY));
        if (property != null) {
            properties.remove(POOL_PROPERTY);
        } else { // defined from @DataSourceDefinition and doesn't need pooling
            final String initialPoolSize = properties.getProperty("initialPoolSize");
            final String maxPoolSize = properties.getProperty("maxPoolSize");
            if ((null == initialPoolSize || "-1".equals(initialPoolSize))
                && ("-1".equals(maxPoolSize) || maxPoolSize == null)) {
                property = "false";
            }
        }
        return "true".equalsIgnoreCase(property) || null == property;
    }

    private static Properties asProperties(final String definition) throws IOException {
        final SuperProperties properties = new SuperProperties();
        properties.caseInsensitive(true);
        properties.putAll(IO.readProperties(IO.read(definition), new Properties()));
        return properties;
    }

    public static void trimNotSupportedDataSourceProperties(final Properties properties) {
        properties.remove("LoginTimeout");
    }

    public static boolean knows(final Object object) {
        return object instanceof CommonDataSource && creatorByDataSource.containsKey(CommonDataSource.class.cast(realInstance(object)));
    }

    // TODO: should we get a get and a clear method instead of a single one?
    @SuppressWarnings("SuspiciousMethodCalls")
    public static ObjectRecipe forgetRecipe(final Object rawObject, final ObjectRecipe defaultValue) {
        final Object object = realInstance(rawObject);
        final DataSourceCreator creator = creatorByDataSource.get(object);
        ObjectRecipe recipe = null;
        if (creator != null) {
            recipe = creator.clearRecipe(object);
        }
        if (recipe == null) {
            return defaultValue;
        }
        return recipe;
    }

    @SuppressWarnings("SuspiciousMethodCalls")
    public static void destroy(final Object o) throws Throwable {
        final Object instance = realInstance(o);
        if (instance == null) {
            return;
        }
        final DataSourceCreator remove = creatorByDataSource.remove(instance);
        remove.destroy(instance);

        final AlternativeDriver driver = driverByDataSource.remove(instance);
        if (driver != null) {
            driver.deregister();
        }
    }

    // remove proxy added by us in front of the datasource returned by the creator
    private static Object realInstance(final Object o) {
        if (o == null || !(o instanceof DataSource)) {
            return o;
        }

        Object ds = o;
        while (Proxy.isProxyClass(ds.getClass())) {
            final InvocationHandler handler = Proxy.getInvocationHandler(ds);
            if (DelegatableHandler.class.isInstance(handler)) {
                ds = DelegatableHandler.class.cast(handler).getDelegate();
            } else {
                break;
            }
        }

        return ds;
    }
}
