/*
 * ModeShape (http://www.modeshape.org)
 *
 * Licensed 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.modeshape.jcr.value.binary;

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;
import org.modeshape.common.annotation.ThreadSafe;
import org.modeshape.common.util.StringUtil;
import org.modeshape.jcr.JcrI18n;
import org.modeshape.jcr.value.BinaryKey;
import org.modeshape.jcr.value.BinaryValue;
import com.zaxxer.hikari.HikariDataSource;

/**
 * A {@link BinaryStore} implementation that uses a database for persisting binary values.
 * <p>
 * This binary store implementation establishes a connection to the specified database and then attempts to determine which type
 * of database is being used. ModeShape is aware of the following database types:
 * <ul>
 * <li><code>mysql</code></li>
 * <li><code>postgres</code></li>
 * <li><code>derby</code></li>
 * <li><code>hsql</code></li>
 * <li><code>h2</code></li>
 * <li><code>sqlite</code></li>
 * <li><code>db2</code></li>
 * <li><code>db2_390</code></li>
 * <li><code>informix</code></li>
 * <li><code>interbase</code></li>
 * <li><code>firebird</code></li>
 * <li><code>sqlserver</code></li>
 * <li><code>access</code></li>
 * <li><code>oracle</code></li>
 * <li><code>sybase</code></li>
 * </ul>
 * This binary store implementation then uses DDL and DML statements to create the table(s) if not already existing and to perform
 * the various operations required of a binary store. ModeShape can use database-specific statements, although a default set of
 * SQL-99 statements are used as a fallback.
 * </p>
 * <p>
 * These statements are read from a property file named "<code>binary_store_{type}_database.properties</code>", where where "
 * <code>{type}</code>" is one of the above-mentioned database type strings. These properties files are expected to be found on
 * the classpath directly under "org/modeshape/jcr/database". If the corresponding file is not found on the classpath, then the "
 * <code>binary_store_default_database.properties</code>" file provided by ModeShape is used.
 * </p>
 * <p>
 * ModeShape provides out-of-the-box database-specific files for several of the DBMSes that are popular within the open source
 * community. The properties files for the other database types are not provided (though the ModeShape community will gladly
 * incorporate them if you wish to make them available to us); in such cases, simply copy one of the provided properties files
 * (e.g., "<code>binary_store_default_database.properties</code>" is often a good start) and customize it for your particular
 * DBMS, naming it according to the pattern described above and including it on the classpath.
 * </p>
 * <p>
 * Note that this mechanism can also be used to override the statements that ModeShape does provide out-of-the-box. In such cases,
 * be sure to place the file on the classpath before the ModeShape JARs so that your file will be discovered first.
 * </p>
 * <p>
 * The JDBC driver used needs to be at least JDBC 1.4 (JDK 6) compliant, because
 * {@link PreparedStatement#setBinaryStream(int parameterIndex, java.io.InputStream x)} is being used.
 * </p>
 */
@ThreadSafe
public class DatabaseBinaryStore extends AbstractBinaryStore {
    private static final boolean ALIVE = true;
    private static final boolean UNUSED = false;

    /**
     * JDBC params
     */
    private final String driverClass;
    private final String connectionURL;
    private final String username;
    private final String password;
    private final String datasourceJNDILocation;
    private DataSource dataSource;

    /**
     * A temporary fs-based store which stores binaries before they are persisted in the DB
     */
    private final FileSystemBinaryStore cache;

    /**
     * JDBC utility for working with the database.
     */
    private Database database;

    /**
     * Create new store.
     * 
     * @param driverClass JDBC driver class name
     * @param connectionURL database location
     * @param username database user name
     * @param password database password
     */
    public DatabaseBinaryStore( String driverClass,
                                String connectionURL,
                                String username,
                                String password ) {
        this.driverClass = driverClass;
        this.connectionURL = connectionURL;
        this.username = username;
        this.password = password;
        this.datasourceJNDILocation = null;
        this.cache = TransientBinaryStore.get();
    }

    /**
     * Create new store that uses the JDBC DataSource in the given JNDI location.
     * 
     * @param datasourceJNDILocation the JNDI name of the JDBC Data Source that should be used, or null
     */
    public DatabaseBinaryStore( String datasourceJNDILocation ) {
        this.driverClass = null;
        this.connectionURL = null;
        this.username = null;
        this.password = null;
        this.datasourceJNDILocation = datasourceJNDILocation;
        this.cache = TransientBinaryStore.get();
    }

    @Override
    public void start() {
        if (!StringUtil.isBlank(datasourceJNDILocation)) {
            lookupDataSource();
        } else {
            initManagedDS();
        }
        
        try (Connection connection = newConnection()) {
            this.database = new Database(connection);
        } catch (SQLException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public BinaryValue storeValue( InputStream stream, final boolean markAsUnused ) throws BinaryStoreException {
        // store into temporary file system store and get SHA-1
        final BinaryValue temp = cache.storeValue(stream, markAsUnused);
        // prepare new binary key based on SHA-1
        final BinaryKey key = new BinaryKey(temp.getKey().toString());
        try {
            return dbCall(connection -> {
                connection.setAutoCommit(false);
              
                if (database.contentExists(key, ALIVE, connection)) {
                    return new StoredBinaryValue(DatabaseBinaryStore.this, key, temp.getSize());
                }

                // check unused content
                if (database.contentExists(key, UNUSED, connection)) {
                    if (!markAsUnused) {
                        database.restoreContent(connection, Collections.singletonList(key));
                    }
                } else {
                    try (InputStream is = temp.getStream()) {
                        // store the content
                        database.insertContent(key, is, temp.getSize(), connection);
                        if (markAsUnused) {
                            database.markUnused(Collections.singletonList(key), connection);
                        }
                    }
                }
                return new StoredBinaryValue(DatabaseBinaryStore.this, key, temp.getSize());
            });
        } catch (BinaryStoreException e) {
            if (e.getCause() instanceof SQLException) {
                // under certain conditions - e.g. in a cluster - someone else may have already inserted the binary
                // so try reading again
                 return dbCall(connection -> {
                    if (database.contentExists(key, !markAsUnused, connection)) {
                        return new StoredBinaryValue(DatabaseBinaryStore.this, key, temp.getSize());        
                    }
                    // nothing there, so rethrow the original exception
                    throw e;
                });
            }
            throw e;
        } finally {
            // remove content from temp store
            cache.markAsUnused(temp.getKey());
        }
    }
    
    /**
     * @inheritDoc
     * 
     * In addition to the generic contract from {@link BinaryStore}, this implementation will use a database connection to read
     * the contents of a binary stream from the database. If such a stream cannot be found or an unexpected exception occurs,
     * the connection is always closed.
     * <p/>
     * However, if the content is found in the database, the {@link Connection} <b>is not closed</b> until the {@code InputStream}
     * is closed because otherwise actual streaming from the database could not be possible.
     */
    @Override
    public InputStream getInputStream( BinaryKey key ) throws BinaryStoreException {
        Connection connection = newConnection();
        try {
            InputStream inputStream = database.readContent(key, connection);
            if (inputStream == null) {
                // if we didn't find anything, the connection should've been closed already
                throw new BinaryStoreException(JcrI18n.unableToFindBinaryValue.text(key, database.getTableName()));
            }
            // the connection & statement will be left open until the stream is closed !
            return inputStream;
        } catch (SQLException e) {
            throw new BinaryStoreException(e);
        }
    }

    @Override
    public void markAsUsed(final Iterable<BinaryKey> keys ) throws BinaryStoreException {
        dbCall(connection -> {
            connection.setAutoCommit(false);
            database.restoreContent(connection, keys);
            return null;
        }) ;
    }

    @Override
    public void markAsUnused( final Iterable<BinaryKey> keys ) throws BinaryStoreException {
        dbCall(connection -> {
            connection.setAutoCommit(false);
            database.markUnused(keys, connection);
            return null;
        });
    }

    @Override
    public void removeValuesUnusedLongerThan( final long minimumAge,
                                              final TimeUnit unit ) throws BinaryStoreException {
        dbCall(connection -> {
            long deadline = System.currentTimeMillis() - unit.toMillis(minimumAge);
            database.removeExpiredContent(deadline, connection);
            return null;
        });
    }

    @Override
    protected String getStoredMimeType( final BinaryValue source ) throws BinaryStoreException {
        return dbCall(connection -> {
            connection.setAutoCommit(false);
            BinaryKey key = source.getKey();
            if (!database.contentExists(key, true, connection) && !database.contentExists(key, false, connection)) {
                throw new BinaryStoreException(JcrI18n.unableToFindBinaryValue.text(key, database.getTableName()));
            }
            return database.getMimeType(key, connection);
        });
    }

    @Override
    protected void storeMimeType( final BinaryValue source,
                                  final String mimeType ) throws BinaryStoreException {
        dbCall(connection -> {
            database.setMimeType(source.getKey(), mimeType, connection);
            return null;
        });
    }

    @Override
    public String getExtractedText( final BinaryValue source ) throws BinaryStoreException {
        return dbCall(connection -> {  
            connection.setAutoCommit(false);
            BinaryKey key = source.getKey();
            if (!database.contentExists(key, true, connection) && !database.contentExists(key, false, connection)) {
                throw new BinaryStoreException(JcrI18n.unableToFindBinaryValue.text(key, database.getTableName()));
            }
            return database.getExtractedText(key, connection);
        });
    }

    @Override
    public void storeExtractedText( final BinaryValue source,
                                    final String extractedText ) throws BinaryStoreException {
        dbCall(connection -> {
            database.setExtractedText(source.getKey(), extractedText, connection);
            return null;
        });
    }

    @Override
    public Iterable<BinaryKey> getAllBinaryKeys() throws BinaryStoreException {
        return dbCall(connection -> database.getBinaryKeys(connection));
    }

    @Override
    public void shutdown() {
       if (dataSource instanceof HikariDataSource) {
            // we're managing this, so force a close to release all connections...
            ((HikariDataSource) this.dataSource).close();
        }
    }

    private Connection newConnection() {
        try {
            return dataSource.getConnection();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    private void lookupDataSource() {
        try {
            InitialContext context = new InitialContext();
            dataSource = (DataSource)context.lookup(datasourceJNDILocation);
        } catch (NamingException e) {
            throw new RuntimeException(e);
        }
    }

    private void initManagedDS() {
        logger.debug("Attempting to connect to '{0}' with '{1}' for username '{2}' and password '{3}'", connectionURL,
                     driverClass, username, password);
        HikariDataSource hikariDS = new HikariDataSource();
        hikariDS.setJdbcUrl(connectionURL);
        hikariDS.setDriverClassName(driverClass);
        hikariDS.setUsername(username);
        hikariDS.setPassword(password);

        this.dataSource = hikariDS;
    }

    @FunctionalInterface
    private interface DBCallable<T> {
         T execute( Connection connection ) throws Exception;
    }

    private <T> T dbCall( DBCallable<T> callable ) throws BinaryStoreException {
        try (Connection connection = newConnection()) {
            boolean autoCommit = true;
            try {
                connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
                T result = callable.execute(connection);
                autoCommit = connection.getAutoCommit();
                if (!autoCommit) {
                    connection.commit();
                }
                return result;
            } catch (Throwable t) {
                if (!autoCommit) {
                    connection.rollback();
                }
                throw t;
            }
        } catch (BinaryStoreException bse) {
            throw bse;
        } catch (Exception e) {
            throw new BinaryStoreException(e);
        } 
    }
}
