/*
 * 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.openjpa.jdbc.schema;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Properties;

import javax.sql.DataSource;

import org.apache.openjpa.jdbc.conf.JDBCConfiguration;
import org.apache.openjpa.lib.conf.Configurable;
import org.apache.openjpa.lib.conf.Configuration;
import org.apache.openjpa.lib.conf.Configurations;
import org.apache.openjpa.lib.util.Closeable;

/**
 * Commons DBCP basic pooling driver data source.
 * The commons-dbcp packages must be on the class path for this plugin to work,
 * as it WILL NOT fall back to non-DBCP mode if they are missing. For automatic
 * usage of Commons DBCP when available, use AutoDriverDataSource instead.
 */
public class DBCPDriverDataSource
extends SimpleDriverDataSource implements Configurable, Closeable {

    private static String DBCPPACKAGENAME = "org.apache.commons.dbcp2";
    private static String DBCPBASICDATASOURCENAME = "org.apache.commons.dbcp2.BasicDataSource";
    private static Class<?> _dbcpClass;
    private static Boolean _dbcpAvail;
    private static RuntimeException _dbcpEx;

    protected JDBCConfiguration conf;
    private volatile DataSource _ds;

    @Override
    public Connection getConnection(Properties props) throws SQLException {
        return getDBCPConnection(props);
    }

    @Override
    public void close() throws SQLException {
        try {
            if (_ds != null) {
                if (isDBCPLoaded(getClassLoader())) {
                    ((org.apache.commons.dbcp2.BasicDataSource)_dbcpClass.cast(_ds)).close();
                }
            }
        }
        catch (Throwable e) {
            // no-op
        }
        finally {
            _ds = null;
        }
    }

    protected Connection getDBCPConnection(Properties props) throws SQLException {
        Connection con = getDBCPDataSource(props).getConnection();
        if (con == null) {
            throw new SQLException(_eloc.get("dbcp-ds-null",
                DBCPBASICDATASOURCENAME, getConnectionDriverName(), getConnectionURL()).getMessage());
        }
        return con;
    }

    protected DataSource getDBCPDataSource(Properties props) {
        if (isDBCPLoaded(getClassLoader())) {
            if (_ds == null) {
                try {
                    Properties dbcpProps = updateDBCPProperties(props);
                    _ds = (DataSource) Configurations.newInstance(DBCPBASICDATASOURCENAME, conf,
                        dbcpProps, getClassLoader());
                } catch (Exception e) {
                    _dbcpEx = new RuntimeException(_eloc.get("driver-null", DBCPBASICDATASOURCENAME).getMessage(), e);
                }
                return _ds;
            } else {
                return _ds;
            }
        } else {
            // user choose DBCP, so fail if it isn't on the classpath
            if (_dbcpEx == null)
                _dbcpEx = new RuntimeException(_eloc.get("driver-null", DBCPBASICDATASOURCENAME).getMessage());
            throw _dbcpEx;
        }
    }

    /**
     * This method should not throw an exception, as it is called by
     * AutoDriverDataSource to determine if user already specified
     * to use Commons DBCP.
     * @return true if ConnectionDriverName contains org.apache.commons.dbcp2,
     *         otherwise false
     */
    protected boolean isDBCPDataSource() {
        return (getConnectionDriverName() != null &&
            getConnectionDriverName().toLowerCase(Locale.ENGLISH).indexOf(DBCPPACKAGENAME) >= 0);
    }

    /**
     * This method should not throw an exception, as it is called by
     * AutoDriverDataSource to determine if it should use DBCP or not
     * based on if org.apache.commons.dbcp2.BasicDataSource can be loaded.
     * @return true if Commons DBCP was found on the classpath, otherwise false
     */
    static protected boolean isDBCPLoaded(ClassLoader cl) {
        if (Boolean.TRUE.equals(_dbcpAvail) && (_dbcpClass != null)) {
            return true;
        } else if (Boolean.FALSE.equals(_dbcpAvail)) {
            return false;
        } else {
            // first time checking, so try to load it
            try {
                _dbcpClass = Class.forName(DBCPBASICDATASOURCENAME, true, cl);
                _dbcpAvail = Boolean.TRUE;
                return true;
            } catch (Exception e) {
                _dbcpAvail = Boolean.FALSE;
                // save exception details for later instead of throwing here
                _dbcpEx = new RuntimeException(_eloc.get("driver-null", DBCPBASICDATASOURCENAME).getMessage(), e);
            }
            return _dbcpAvail;
        }
    }

    /**
     * Normalize properties for Commons DBCP.  This should be done for every call from DataSourceFactory,
     * as we do not have a pre-configured Driver to reuse.
     * @param props
     * @return updated properties
     */
    private Properties updateDBCPProperties(Properties props) {
        Properties dbcpProps = mergeConnectionProperties(props);

        // only perform the following check for the first connection attempt (_driverClassName == null),
        // as multiple connections could be requested (like from SchemaTool) and isDBCPDriver() will be true
        if (isDBCPDataSource()) {
            String propDriver = hasProperty(dbcpProps, "driverClassName");
            if (propDriver == null || propDriver.trim().isEmpty()) {
                // if user specified DBCP for the connectionDriverName, then make sure they supplied a DriverClassName
                throw new RuntimeException(_eloc.get("connection-property-invalid", "DriverClassName",
                    propDriver).getMessage());
            }
            propDriver = hasProperty(dbcpProps, "url");
            if (propDriver == null || propDriver.trim().isEmpty()) {
                // if user specified DBCP for the connectionDriverName, then make sure they supplied a Url
                throw new RuntimeException(_eloc.get("connection-property-invalid", "URL",
                    propDriver).getMessage());
            }
        } else {
            // set Commons DBCP expected DriverClassName to the original connection driver name
            dbcpProps.setProperty(hasKey(dbcpProps, "driverClassName", "driverClassName"), getConnectionDriverName());
            // set Commons DBCP expected URL property
            dbcpProps.setProperty(hasKey(dbcpProps, "url", "url"), getConnectionURL());
        }

        // Commons DBCP requires non-Null username/password values in the connection properties
        if (hasKey(dbcpProps, "username") == null) {
            if (getConnectionUserName() != null)
                dbcpProps.setProperty("username", getConnectionUserName());
            else
                dbcpProps.setProperty("username", "");
        }
        // Commons DBCP requires non-Null username/password values in the connection properties
        if (hasKey(dbcpProps, "password") == null) {
            if (getConnectionPassword() != null)
                dbcpProps.setProperty("password", getConnectionPassword());
            else
                dbcpProps.setProperty("password", "");
        }

        // set some default properties for DBCP
        if (hasKey(dbcpProps, "maxActive") == null) {
            dbcpProps.setProperty("maxActive", "10");
        }
        if (hasKey(dbcpProps, "maxIdle") == null) {
            // by default we set maxIdle to the same value as maxActive.
            dbcpProps.setProperty("maxIdle", dbcpProps.getProperty("maxActive"));
        }
        if (hasKey(dbcpProps, "minIdle") == null) {
            dbcpProps.setProperty("minIdle", "0");
        }

        return dbcpProps;
    }

    /**
     * Merge the passed in properties with a copy of the existing _connectionProperties
     * @param props
     * @return Merged properties
     */
    private Properties mergeConnectionProperties(final Properties props) {
        Properties mergedProps = new Properties();
        mergedProps.putAll(getConnectionProperties());

        // need to map "user" to "username" for Commons DBCP
        String uid = removeProperty(mergedProps, "user");
        if (uid != null) {
            mergedProps.setProperty("username", uid);
        }

        // now, merge in any passed in properties
        if (props != null && !props.isEmpty()) {
            for (Object o : props.keySet()) {
                String key = (String) o;
                String value = props.getProperty(key);
                // need to map "user" to "username" for Commons DBCP
                if ("user".equalsIgnoreCase(key)) {
                    key = "username";
                }
                // case-insensitive search for existing key
                String existingKey = hasKey(mergedProps, key);
                if (existingKey != null) {
                    // update existing entry
                    mergedProps.setProperty(existingKey, value);
                }
                else {
                    // add property to the merged set
                    mergedProps.setProperty(key, value);
                }
            }
        }
        return mergedProps;
    }

    /**
     * Case-insensitive search of the given properties for the given key.
     * @param props
     * @param key
     * @return Key name as found in properties or null if it was not found.
     */
    private String hasKey(Properties props, String key) {
        return hasKey(props, key, null);
    }

    /**
     * Case-insensitive search of the given properties for the given key.
     * @param props
     * @param key
     * @param defaultKey
     * @return Key name as found in properties or the given defaultKey if it was not found.
     */
    private String hasKey(Properties props, String key, String defaultKey)
    {
        if (props != null && key != null) {
            for (Object o : props.keySet()) {
                String entry = (String) o;
                if (key.equalsIgnoreCase(entry))
                    return entry;
            }
        }
        return defaultKey;
    }

    private String hasProperty(Properties props, String key) {
        if (props != null && key != null) {
            String entry = hasKey(props, key);
            if (entry != null)
                return props.getProperty(entry);
        }
        return null;

    }

    private String removeProperty(Properties props, String key) {
        if (props != null && key != null) {
            String entry = hasKey(props, key);
            if (entry != null)
                return (String)props.remove(entry);
        }
        return null;
    }

    // Configurable interface methods
    @Override
    public void setConfiguration(Configuration conf) {
        if (conf instanceof JDBCConfiguration)
            this.conf = (JDBCConfiguration)conf;
    }

    @Override
    public void startConfiguration() {
        // no-op
    }

    @Override
    public void endConfiguration() {
        // no-op
    }

}

