/**
 * $RCSfile$
 * $Revision$
 * $Date$
 *
 * Copyright (C) 2004 Jive Software. All rights reserved.
 *
 * This software is published under the terms of the GNU Public License (GPL),
 * a copy of which is included in this distribution.
 */

package org.jivesoftware.database;

import org.jivesoftware.util.ClassUtils;
import org.jivesoftware.util.Log;
import org.jivesoftware.util.JiveGlobals;

import java.io.IOException;
import java.sql.*;
import java.util.*;

/**
 * Database connection pool.
 *
 * @author Jive Software
 */
public class ConnectionPool implements Runnable {

    private String driver;
    private String serverURL;
    private String username;
    private String password;
    private int minCon;
    private int maxCon;
    private int conTimeout;
    private boolean mysqlUseUnicode;

    private Thread houseKeeper;
    private boolean shutdownStarted = false;

    private int conCount = 0;
    private int waitingForCon = 0;
    private Connection[] cons;
    private ConnectionWrapper[] wrappers;
    private Object waitLock = new Object();
    private Object conCountLock = new Object();

    public ConnectionPool(String driver, String serverURL, String username,
                          String password, int minCon, int maxCon,
                          double conTimeout, boolean mysqlUseUnicode) throws IOException {
        this.driver = driver;
        this.serverURL = serverURL;
        this.username = username;
        this.password = password;
        this.minCon = minCon;
        this.maxCon = maxCon;
        // Setting the timeout to 3 hours
        this.conTimeout = (int)(conTimeout * 1000 * 60 * 60 * 3); // convert to milliseconds
        this.mysqlUseUnicode = mysqlUseUnicode;

        if (driver == null) {
            Log.error("JDBC driver value is null.");
        }
        try {
            ClassUtils.forName(driver);
            DriverManager.getDriver(serverURL);
        }
        catch (ClassNotFoundException e) {
            Log.error("Could not load JDBC driver class: " + driver);
        }
        catch (SQLException e) {
            Log.error("Error starting connection pool.", e);
        }

        // Setup pool, open minimum number of connections
        wrappers = new ConnectionWrapper[maxCon];
        cons = new Connection[maxCon];

        boolean success = false;
        int maxTry = 3;

        for (int i = 0; i < maxTry; i++) {
            try {
                for (int j = 0; j < minCon; j++) {
                    createCon(j);
                    conCount++;
                }

                success = true;
                break;
            }
            catch (SQLException e) {
                // close any open connections
                for (int j = 0; j < minCon; j++) {
                    if (cons[j] != null) {
                        try {
                            cons[j].close();
                            cons[j] = null;
                            wrappers[j] = null;
                            conCount--;
                        }
                        catch (SQLException e1) { /* ignore */
                        }
                    }
                }

                // let admin know that there was a problem
                Log.error("Failed to create new connections on startup. " +
                        "Attempt " + i + " of " + maxTry, e);

                try {
                    Thread.sleep(10000);
                }
                catch (InterruptedException e1) { /* ignore */
                }
            }
        }

        if (!success) {
            throw new IOException();
        }

        // Start the background housekeeping thread
        houseKeeper = new Thread(this);
        houseKeeper.setDaemon(true);
        houseKeeper.start();
    }

    public Connection getConnection() throws SQLException {
        // if we're shutting down, don't create any connections
        if (shutdownStarted) {
            return null;
        }

        // Check to see if there are any connections available. If not, then enter wait-based
        // retry loop
        ConnectionWrapper con = getCon();

        if (con != null) {
            synchronized (con) {
                con.checkedout = true;
                con.lockTime = System.currentTimeMillis();
            }
            return con;
        }
        else {
            synchronized (waitLock) {
                try {
                    waitingForCon++;
                    while (true) {
                        con = getCon();

                        if (con != null) {
                            --waitingForCon;
                            synchronized (con) {
                                con.checkedout = true;
                                con.lockTime = System.currentTimeMillis();
                            }
                            return con;
                        }
                        else {
                            waitLock.wait();
                        }
                    }
                }
                catch (InterruptedException ex) {
                    --waitingForCon;
                    waitLock.notify();

                    throw new SQLException("Interrupted while waiting for connection to " +
                            "become available.");
                }
            }
        }
    }

    public void freeConnection() {
        synchronized (waitLock) {
            if (waitingForCon > 0) {
                waitLock.notify();
            }
        }
    }

    public void destroy() throws SQLException {
        // set shutdown flag
        shutdownStarted = true;

        // shut down the background housekeeping thread
        houseKeeper.interrupt();

        // wait 1/2 second for housekeeper to die
        try {
            houseKeeper.join(500);
        }
        catch (InterruptedException e) { /* ignore */
        }

        // check to see if there's any currently open connections to close
        for (int i = 0; i < conCount; i++) {
            ConnectionWrapper wrapper = wrappers[i];

            // null means that the connection hasn't been initialized, which will only occur
            // if the current index is greater than the current connection count
            if (wrapper == null) {
                break;
            }

            // if it's currently checked out, wait 1/2 second then close it anyways
            if (wrapper.checkedout) {
                try {
                    Thread.sleep(500);
                }
                catch (InterruptedException e) {/* ignore */
                }

                if (wrapper.checkedout) {
                    Log.info("Forcefully closing connection " + i);
                }
            }

            cons[i].close();
            cons[i] = null;
            wrappers[i] = null;
        }
    }

    public int getSize() {
        return conCount;
    }

    /**
     * Housekeeping thread. This thread runs every 30 seconds and checks connections for the
     * following conditions:<BR>
     * <p/>
     * <ul>
     * <li>Connection has been open too long - it'll be closed and another connection created.
     * <li>Connection hasn't been used for 30 seconds and the number of open connections is
     * greater than the minimum number of connections. The connection will be closed. This
     * is done so that the pool can shrink back to the minimum number of connections if the
     * pool isn't being used extensively.
     * <li>Unable to create a statement with the connection - it'll be reset.
     * </ul>
     */
    public void run() {
        while (true) {
            // print warnings on connections
            for (int i = 0; i < maxCon; i++) {
                if (cons[i] == null) {
                    continue;
                }

                try {
                    SQLWarning warning = cons[i].getWarnings();
                    if (warning != null) {
                        Log.warn("Connection " + i + " had warnings: " + warning);
                        cons[i].clearWarnings();
                    }
                }
                catch (SQLException e) {
                    Log.warn("Unable to get warning for connection: ", e);
                }
            }

            int lastOpen = -1;

            // go over every connection, check it's health
            for (int i = maxCon - 1; i >= 0; i--) {
                if (wrappers[i] == null) {
                    continue;
                }

                try {
                    long time = System.currentTimeMillis();

                    synchronized (wrappers[i]) {
                        if (wrappers[i].checkedout) {
                            if (lastOpen < i) {
                                lastOpen = i;
                            }


                            // if the jive property "database.defaultProvider.checkOpenConnections"
                            // is true check open connections to make sure they haven't been open
                            // for more than XX seconds (600 by default)
                            if ("true".equals(JiveGlobals.getXMLProperty("database.defaultProvider.checkOpenConnections"))
                                    && !wrappers[i].hasLoggedException)
                            {
                                int timeout = 600;
                                try { timeout = Integer.parseInt(JiveGlobals.getXMLProperty("database.defaultProvider.openConnectionTimeLimit")); }
                                catch (Exception e) { /* ignore */ }

                                if (time - wrappers[i].lockTime > timeout * 1000) {
                                    wrappers[i].hasLoggedException = true;
                                    Log.warn("Connection has been held open for too long: ",
                                            wrappers[i].exception);
                                }
                            }

                            continue;
                        }
                        wrappers[i].checkedout = true;
                    }

                    // test health of connection
                    Statement stmt = null;
                    try {
                        stmt = cons[i].createStatement();
                    }
                    finally {
                        if (stmt != null) {
                            stmt.close();
                        }
                    }

                    // Can never tell
                    if (cons[i].isClosed()) {
                        throw new SQLException();
                    }

                    // check the age of the connection
                    if (time - wrappers[i].createTime > conTimeout) {
                        throw new SQLException();
                    }

                    // check to see if it's the last connection and if it's been idle for
                    // more than 60 secounds
                    if ((time - wrappers[i].checkinTime > 60 * 1000) && i > minCon &&
                            lastOpen <= i) {
                        synchronized (conCountLock) {
                            cons[i].close();
                            wrappers[i] = null;
                            cons[i] = null;
                            conCount--;
                        }
                    }

                    // Flag the last open connection
                    lastOpen = i;

                    // Unlock the connection
                    if (wrappers[i] != null) {
                        wrappers[i].checkedout = false;
                    }

                }
                catch (SQLException e) {
                    try {
                        synchronized (conCountLock) {
                            cons[i].close();
                            wrappers[i] = createCon(i);

                            // unlock the connection
                            wrappers[i].checkedout = false;
                        }
                    }
                    catch (SQLException sqle) {
                        Log.warn("Failed to reopen connection", sqle);

                        synchronized (conCountLock) {
                            wrappers[i] = null;
                            cons[i] = null;
                            conCount--;
                        }
                    }
                }
            }

            try {
                Thread.sleep(30 * 1000);
            }
            catch (InterruptedException e) {
                return;
            }
        }
    }

    private synchronized ConnectionWrapper getCon() throws SQLException {
        // check to see if there's a connection already available
        for (int i = 0; i < conCount; i++) {
            ConnectionWrapper wrapper = wrappers[i];

            // null means that the connection hasn't been initialized, which will only occur
            // if the current index is greater than the current connection count
            if (wrapper == null) {
                break;
            }

            synchronized (wrapper) {
                if (!wrapper.checkedout) {
                    wrapper.setConnection(cons[i]);
                    wrapper.checkedout = true;
                    wrapper.lockTime = System.currentTimeMillis();
                    if ("true".equals(JiveGlobals.getXMLProperty("database.defaultProvider.checkOpenConnections"))) {
                        wrapper.exception = new Exception();
                        wrapper.hasLoggedException = false;
                    }

                    return wrapper;
                }
            }
        }

        // won't create more than maxConnections
        synchronized (conCountLock) {
            if (conCount >= maxCon) {
                return null;
            }

            ConnectionWrapper con = createCon(conCount);
            conCount++;
            return con;
        }
    }

    /**
     * Create a connection, wrap it and add it to the array of open wrappers
     */
    private ConnectionWrapper createCon(int index) throws SQLException {
        try {
            Connection con = null;
            ClassUtils.forName(driver);

            if (mysqlUseUnicode) {
                Properties props = new Properties();
                props.put("characterEncoding", "UTF-8");
                props.put("useUnicode", "true");
                if (username != null) {
                    props.put("user", username);
                }
                if (password != null) {
                    props.put("password", password);
                }
                con = DriverManager.getConnection(serverURL, props);
            }
            else {
                con = DriverManager.getConnection(serverURL, username, password);
            }

            if (con == null) {
                throw new SQLException("Unable to retrieve connection from DriverManager");
            }


            try {
                con.setAutoCommit(true);
            }
            catch (SQLException e) {/* ignored */
            }


            // A few people have been having problems because the default transaction
            // isolation level on databases is too high. READ_COMMITTED is a good
            // value for everyone to use because it provides the minimum amount of
            // locking that Jive needs to work well.
            try {
                // Supports transactions?
                if (con.getMetaData().supportsTransactions()) {
                    con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
                }
            }
            catch (SQLException e) {
                // Ignore errors. A few databases don't support setting the transaction
                // isolation level, but ignoring the error shouldn't cause problems.
            }

            // create the wrapper object and mark it as checked out
            ConnectionWrapper wrapper = new ConnectionWrapper(con, this);
            if ("true".equals(JiveGlobals.getXMLProperty("database.defaultProvider.checkOpenConnections"))) {
                wrapper.exception = new Exception();
            }

            synchronized (conCountLock) {
                cons[index] = con;
                wrappers[index] = wrapper;
            }

            return wrapper;
        }
        catch (ClassNotFoundException e) {
            Log.error(e);
            throw new SQLException(e.getMessage());
        }
    }

}