package net.sf.jabref.shared;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import net.sf.jabref.model.entry.BibEntry;
import net.sf.jabref.model.entry.event.EntryEventSource;
import net.sf.jabref.shared.exception.OfflineLockException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Processes all incoming or outgoing bib data to external SQL Database and manages its structure.
 */
public abstract class DBMSProcessor {

    protected static final Log LOGGER = LogFactory.getLog(DBMSProcessor.class);


    protected final Connection connection;

    protected DBMSConnectionProperties connectionProperties;

    public static final String PROCESSOR_ID = UUID.randomUUID().toString();


    protected DBMSProcessor(DBMSConnection dbmsConnection) {
        this.connection = dbmsConnection.getConnection();
        this.connectionProperties = dbmsConnection.getProperties();
    }

    /**
     * Scans the database for required tables.
     *
     * @return <code>true</code> if the structure matches the requirements, <code>false</code> if not.
     * @throws SQLException
     */
    public boolean checkBaseIntegrity() throws SQLException {
        return checkTableAvailibility("ENTRY", "FIELD", "METADATA");
    }

    /**
     * Determines whether the database is using an pre-3.6 structure.
     *
     * @return <code>true</code> if the structure is old, else <code>false</code>.
     */
    public boolean checkForPre3Dot6Intergrity() throws SQLException {
        return checkTableAvailibility(
                "ENTRIES",
                "ENTRY_GROUP",
                "ENTRY_TYPES",
                "GROUPS",
                "GROUP_TYPES",
                "JABREF_DATABASE",
                "STRINGS"); // old tables
    }

    /**
     * Checks whether all given table names (<b>case insensitive</b>) exist in database.
     *
     * @param tableNames Table names to be checked
     * @return <code>true</code> if <b>all</b> given tables are present, else <code>false</code>.
     */
    private boolean checkTableAvailibility(String... tableNames) throws SQLException {
        List<String> requiredTables = new ArrayList<>();
        for (String name : tableNames) {
            requiredTables.add(name.toUpperCase(Locale.ENGLISH));
        }

        DatabaseMetaData databaseMetaData = connection.getMetaData();
        // ...getTables(null, ...): no restrictions
        try (ResultSet databaseMetaDataResultSet = databaseMetaData.getTables(null, null, null, null)) {
            while (databaseMetaDataResultSet.next()) {
                String tableName = databaseMetaDataResultSet.getString("TABLE_NAME").toUpperCase();
                requiredTables.remove(tableName); // Remove matching tables to check requiredTables for emptiness
            }
            return requiredTables.isEmpty();
        }
    }

    /**
     * Creates and sets up the needed tables and columns according to the database type and
     * performs a check whether the needed tables are present.
     *
     * @throws SQLException
     */
    public void setupSharedDatabase() throws SQLException {
        setUp();

        if (!checkBaseIntegrity()) {
            // can only happen with users direct intervention on shared database
            LOGGER.error("Corrupt_shared_database_structure.");
        }
    }

    /**
     * Creates and sets up the needed tables and columns according to the database type.
     *
     * @throws SQLException
     */
    protected abstract void setUp() throws SQLException;

    /**
     * Escapes parts of SQL expressions like table or field name to match the conventions
     * of the database system using the current dbmsType.
     *
     * This method is package private, because of DBMSProcessorTest
     *
     * @param expression Table or field name
     * @return Correctly escaped expression
     */
    abstract String escape(String expression);


    /**
     * Inserts the given bibEntry into shared database.
     *
     * @param bibEntry {@link BibEntry} to be inserted
     */
    public void insertEntry(BibEntry bibEntry) {
        if (!checkForBibEntryExistence(bibEntry)) {
            insertIntoEntryTable(bibEntry);
            insertIntoFieldTable(bibEntry);
        }

    }

    /**
     * Inserts the given bibEntry into ENTRY table.
     *
     * @param bibEntry {@link BibEntry} to be inserted
     */
    protected void insertIntoEntryTable(BibEntry bibEntry) {
        // Inserting into ENTRY table
        StringBuilder insertIntoEntryQuery = new StringBuilder()
                .append("INSERT INTO ")
                .append(escape("ENTRY"))
                .append("(")
                .append(escape("TYPE"))
                .append(") VALUES(?)");

        // This is the only method to get generated keys which is accepted by MySQL, PostgreSQL and Oracle.
        try (PreparedStatement preparedEntryStatement = connection.prepareStatement(insertIntoEntryQuery.toString(),
                new String[] {"SHARED_ID"})) {

            preparedEntryStatement.setString(1, bibEntry.getType());
            preparedEntryStatement.executeUpdate();

            try (ResultSet generatedKeys = preparedEntryStatement.getGeneratedKeys()) {
                if (generatedKeys.next()) {
                    bibEntry.getSharedBibEntryData().setSharedID(generatedKeys.getInt(1)); // set generated ID locally
                }
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error: ", e);
        }
    }

    /**
     * Checks whether the given bibEntry already exists on shared database.
     * @param bibEntry {@link BibEntry} to be checked
     * @return <code>true</code> if existent, else <code>false</code>
     */
    private boolean checkForBibEntryExistence(BibEntry bibEntry) {
        try {
            // Check if already exists
            int sharedID = bibEntry.getSharedBibEntryData().getSharedID();
            if (sharedID != -1) {
                StringBuilder selectQuery = new StringBuilder()
                        .append("SELECT * FROM ")
                        .append(escape("ENTRY"))
                        .append(" WHERE ")
                        .append(escape("SHARED_ID"))
                        .append(" = ?");

                try (PreparedStatement preparedSelectStatement = connection.prepareStatement(selectQuery.toString())) {
                    preparedSelectStatement.setInt(1, sharedID);
                    try (ResultSet resultSet = preparedSelectStatement.executeQuery()) {
                        if (resultSet.next()) {
                            return true;
                        }
                    }
                }
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error: ", e);
        }
        return false;
    }

    /**
     * Inserts the given bibEntry into FIELD table.
     *
     * @param bibEntry {@link BibEntry} to be inserted
     */
    private void insertIntoFieldTable(BibEntry bibEntry) {
        try {
            // Inserting into FIELD table
            for (String fieldName : bibEntry.getFieldNames()) {
                StringBuilder insertFieldQuery = new StringBuilder()
                    .append("INSERT INTO ")
                    .append(escape("FIELD"))
                    .append("(")
                    .append(escape("ENTRY_SHARED_ID"))
                    .append(", ")
                    .append(escape("NAME"))
                    .append(", ")
                    .append(escape("VALUE"))
                    .append(") VALUES(?, ?, ?)");

                try (PreparedStatement preparedFieldStatement = connection.prepareStatement(insertFieldQuery.toString())) {
                    // columnIndex starts with 1
                    preparedFieldStatement.setInt(1, bibEntry.getSharedBibEntryData().getSharedID());
                    preparedFieldStatement.setString(2, fieldName);
                    preparedFieldStatement.setString(3, bibEntry.getField(fieldName).get());
                    preparedFieldStatement.executeUpdate();
                }
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error: ", e);
        }
    }

    /**
     * Updates the whole {@link BibEntry} on shared database.
     *
     * @param localBibEntry {@link BibEntry} affected by changes
     * @throws SQLException
     */
    public void updateEntry(BibEntry localBibEntry) throws OfflineLockException, SQLException {
        connection.setAutoCommit(false); // disable auto commit due to transaction

        try {
            Optional<BibEntry> sharedEntryOptional = getSharedEntry(localBibEntry.getSharedBibEntryData().getSharedID());

            if (!sharedEntryOptional.isPresent()) {
                return;
            }

            BibEntry sharedBibEntry = sharedEntryOptional.get();

            // remove shared fields which do not exist locally
            removeSharedFieldsByDifference(localBibEntry, sharedBibEntry);

            // update only if local version is higher or the entries are equal
            if ((localBibEntry.getSharedBibEntryData().getVersion() >= sharedBibEntry.getSharedBibEntryData()
                    .getVersion()) || localBibEntry.equals(sharedBibEntry)) {

                insertOrUpdateFields(localBibEntry);

                // updating entry type
                StringBuilder updateEntryTypeQuery = new StringBuilder()
                    .append("UPDATE ")
                    .append(escape("ENTRY"))
                    .append(" SET ")
                    .append(escape("TYPE"))
                    .append(" = ?, ")
                    .append(escape("VERSION"))
                    .append(" = ")
                    .append(escape("VERSION"))
                    .append(" + 1 WHERE ")
                    .append(escape("SHARED_ID"))
                    .append(" = ?");

                try (PreparedStatement preparedUpdateEntryTypeStatement = connection.prepareStatement(updateEntryTypeQuery.toString())) {
                    preparedUpdateEntryTypeStatement.setString(1, localBibEntry.getType());
                    preparedUpdateEntryTypeStatement.setInt(2, localBibEntry.getSharedBibEntryData().getSharedID());
                    preparedUpdateEntryTypeStatement.executeUpdate();
                }

                connection.commit(); // apply all changes in current transaction

            } else {
                throw new OfflineLockException(localBibEntry, sharedBibEntry);
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error: ", e);
            connection.rollback(); // undo changes made in current transaction
        } finally {
            connection.setAutoCommit(true); // enable auto commit mode again
        }
    }

    /**
     * Helping method. Removes shared fields which do not exist locally
     */
    private void removeSharedFieldsByDifference(BibEntry localBibEntry, BibEntry sharedBibEntry) throws SQLException {
        Set<String> nullFields = new HashSet<>(sharedBibEntry.getFieldNames());
        nullFields.removeAll(localBibEntry.getFieldNames());
        for (String nullField : nullFields) {
            StringBuilder deleteFieldQuery = new StringBuilder()
                .append("DELETE FROM ")
                .append(escape("FIELD"))
                .append(" WHERE ")
                .append(escape("NAME"))
                .append(" = ? AND ")
                .append(escape("ENTRY_SHARED_ID"))
                .append(" = ?");

            try (PreparedStatement preparedDeleteFieldStatement = connection
                    .prepareStatement(deleteFieldQuery.toString())) {
                preparedDeleteFieldStatement.setString(1, nullField);
                preparedDeleteFieldStatement.setInt(2, localBibEntry.getSharedBibEntryData().getSharedID());
                preparedDeleteFieldStatement.executeUpdate();
            }
        }
    }

    /**
     * Helping method. Inserts a key-value pair into FIELD table for every field if not existing. Otherwise only an update is performed.
     */
    private void insertOrUpdateFields(BibEntry localBibEntry) throws SQLException {
        for (String fieldName : localBibEntry.getFieldNames()) {
            // avoiding to use deprecated BibEntry.getField() method. null values are accepted by PreparedStatement!
            Optional<String> valueOptional = localBibEntry.getField(fieldName);
            String value = null;
            if (valueOptional.isPresent()) {
                value = valueOptional.get();
            }

            StringBuilder selectFieldQuery = new StringBuilder()
                    .append("SELECT * FROM ")
                    .append(escape("FIELD"))
                    .append(" WHERE ")
                    .append(escape("NAME"))
                    .append(" = ? AND ")
                    .append(escape("ENTRY_SHARED_ID"))
                    .append(" = ?");

            try (PreparedStatement preparedSelectFieldStatement = connection
                    .prepareStatement(selectFieldQuery.toString())) {
                preparedSelectFieldStatement.setString(1, fieldName);
                preparedSelectFieldStatement.setInt(2, localBibEntry.getSharedBibEntryData().getSharedID());

                try (ResultSet selectFieldResultSet = preparedSelectFieldStatement.executeQuery()) {
                    if (selectFieldResultSet.next()) { // check if field already exists
                        StringBuilder updateFieldQuery = new StringBuilder()
                                .append("UPDATE ")
                                .append(escape("FIELD"))
                                .append(" SET ")
                                .append(escape("VALUE"))
                                .append(" = ? WHERE ")
                                .append(escape("NAME"))
                                .append(" = ? AND ")
                                .append(escape("ENTRY_SHARED_ID"))
                                .append(" = ?");

                        try (PreparedStatement preparedUpdateFieldStatement = connection
                                .prepareStatement(updateFieldQuery.toString())) {
                            preparedUpdateFieldStatement.setString(1, value);
                            preparedUpdateFieldStatement.setString(2, fieldName);
                            preparedUpdateFieldStatement.setInt(3, localBibEntry.getSharedBibEntryData().getSharedID());
                            preparedUpdateFieldStatement.executeUpdate();
                        }
                    } else {
                        StringBuilder insertFieldQuery = new StringBuilder()
                                .append("INSERT INTO ")
                                .append(escape("FIELD"))
                                .append("(")
                                .append(escape("ENTRY_SHARED_ID"))
                                .append(", ")
                                .append(escape("NAME"))
                                .append(", ")
                                .append(escape("VALUE"))
                                .append(") VALUES(?, ?, ?)");

                        try (PreparedStatement preparedFieldStatement = connection
                                .prepareStatement(insertFieldQuery.toString())) {
                            preparedFieldStatement.setInt(1, localBibEntry.getSharedBibEntryData().getSharedID());
                            preparedFieldStatement.setString(2, fieldName);
                            preparedFieldStatement.setString(3, value);
                            preparedFieldStatement.executeUpdate();
                        }
                    }
                }
            }
        }
    }

    /**
     * Removes the shared bibEntry.
     *
     * @param bibEntry {@link BibEntry} to be deleted
     */
    public void removeEntry(BibEntry bibEntry) {
        StringBuilder query = new StringBuilder()
                .append("DELETE FROM ")
                .append(escape("ENTRY"))
                .append(" WHERE ")
                .append(escape("SHARED_ID"))
                .append(" = ?");

        try (PreparedStatement preparedStatement = connection.prepareStatement(query.toString())) {
            preparedStatement.setInt(1, bibEntry.getSharedBibEntryData().getSharedID());
            preparedStatement.executeUpdate();
        } catch (SQLException e) {
            LOGGER.error("SQL Error: ", e);
        }

    }

    /**
     * @param sharedID Entry ID
     * @return instance of {@link BibEntry}
     */
    public Optional<BibEntry> getSharedEntry(int sharedID) {
        List<BibEntry> sharedEntries = getSharedEntryList(sharedID);
        if (!sharedEntries.isEmpty()) {
            return Optional.of(sharedEntries.get(0));
        }
        return Optional.empty();
    }

    public List<BibEntry> getSharedEntries() {
        return getSharedEntryList(0);
    }

    /**
     * @param sharedID Entry ID. If 0, all entries are going to be fetched.
     * @return List of {@link BibEntry} instances
     */
    private List<BibEntry> getSharedEntryList(int sharedID) {
        List<BibEntry> sharedEntries = new ArrayList<>();

        StringBuilder selectEntryQuery = new StringBuilder();
        selectEntryQuery.append("SELECT * FROM ");
        selectEntryQuery.append(escape("ENTRY"));

        if (sharedID != 0) {
            selectEntryQuery.append(" WHERE ");
            selectEntryQuery.append(escape("SHARED_ID"));
            selectEntryQuery.append(" = ");
            selectEntryQuery.append(sharedID);
        }

        selectEntryQuery.append(" ORDER BY ");
        selectEntryQuery.append(escape("SHARED_ID"));

        try (ResultSet selectEntryResultSet = connection.createStatement().executeQuery(selectEntryQuery.toString())) {
            while (selectEntryResultSet.next()) {
                BibEntry bibEntry = new BibEntry();
                // setting the base attributes once
                bibEntry.getSharedBibEntryData().setSharedID(selectEntryResultSet.getInt("SHARED_ID"));
                bibEntry.setType(selectEntryResultSet.getString("TYPE"));
                bibEntry.getSharedBibEntryData().setVersion(selectEntryResultSet.getInt("VERSION"));

                StringBuilder selectFieldQuery = new StringBuilder()
                    .append("SELECT * FROM ")
                    .append(escape("FIELD"))
                    .append(" WHERE ")
                    .append(escape("ENTRY_SHARED_ID"))
                    .append(" = ?");

                try (PreparedStatement preparedSelectFieldStatement = connection.prepareStatement(selectFieldQuery.toString())) {
                    preparedSelectFieldStatement.setInt(1, selectEntryResultSet.getInt("SHARED_ID"));
                    try (ResultSet selectFieldResultSet = preparedSelectFieldStatement.executeQuery()) {
                        while (selectFieldResultSet.next()) {
                            bibEntry.setField(selectFieldResultSet.getString("NAME"),
                                    Optional.ofNullable(selectFieldResultSet.getString("VALUE")), EntryEventSource.SHARED);
                        }
                    }
                }
                sharedEntries.add(bibEntry);
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error", e);
        }

        return sharedEntries;
    }

    /**
     * Retrieves a mapping between the columns SHARED_ID and VERSION.
     */
    public Map<Integer, Integer> getSharedIDVersionMapping() {
        Map<Integer, Integer> sharedIDVersionMapping = new HashMap<>();
        StringBuilder selectEntryQuery = new StringBuilder()
            .append("SELECT * FROM ")
            .append(escape("ENTRY"))
            .append(" ORDER BY ")
            .append(escape("SHARED_ID"));

        try (ResultSet selectEntryResultSet = connection.createStatement().executeQuery(selectEntryQuery.toString())) {
            while (selectEntryResultSet.next()) {
                sharedIDVersionMapping.put(selectEntryResultSet.getInt("SHARED_ID"), selectEntryResultSet.getInt("VERSION"));
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error", e);
        }

        return sharedIDVersionMapping;
    }

    /**
     * Fetches and returns all shared meta data.
     */
    public Map<String, String> getSharedMetaData() {
        Map<String, String> data = new HashMap<>();

        try (ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM " + escape("METADATA"))) {
            while(resultSet.next()) {
                data.put(resultSet.getString("KEY"), resultSet.getString("VALUE"));
            }
        } catch (SQLException e) {
            LOGGER.error("SQL Error", e);
        }

        return data;
    }

    /**
     * Clears and sets all shared meta data.
     *
     * @param data JabRef meta data as map
     */
    public void setSharedMetaData(Map<String, String> data) throws SQLException {
        connection.createStatement().executeUpdate("TRUNCATE TABLE " + escape("METADATA")); // delete data all data from table

        for (Map.Entry<String, String> metaEntry : data.entrySet()) {

            StringBuilder query = new StringBuilder()
                .append("INSERT INTO ")
                .append(escape("METADATA"))
                .append("(")
                .append(escape("KEY"))
                .append(", ")
                .append(escape("VALUE"))
                .append(") VALUES(?, ?)");

            try (PreparedStatement preparedStatement = connection.prepareStatement(query.toString())) {
                preparedStatement.setString(1, metaEntry.getKey());
                preparedStatement.setString(2, metaEntry.getValue());
                preparedStatement.executeUpdate();
            } catch (SQLException e) {
                LOGGER.error("SQL Error: ", e);
            }
        }
    }

    /**
     *  Returns a new instance of the abstract type {@link DBMSProcessor}
     */
    public static DBMSProcessor getProcessorInstance(DBMSConnection connection) {
        DBMSType type = connection.getProperties().getType();
        if (type == DBMSType.MYSQL) {
            return new MySQLProcessor(connection);
        } else if (type == DBMSType.POSTGRESQL) {
            return new PostgreSQLProcessor(connection);
        } else if (type == DBMSType.ORACLE) {
            return new OracleProcessor(connection);
        }
        return null; // can never happen except new types were added without updating this method.
    }

    public DBMSConnectionProperties getDBMSConnectionProperties() {
        return this.connectionProperties;
    }

    /**
     * Listens for notifications from DBMS.
     * Needs to be implemented if LiveUpdate is supported by the DBMS
     *
     * @param dbmsSynchronizer {@link DBMSSynchronizer} which handles the notification.
     */
    public void startNotificationListener(@SuppressWarnings("unused") DBMSSynchronizer dbmsSynchronizer) {
        // nothing to do
    }

    /**
     * Terminates the notification listener.
     * Needs to be implemented if LiveUpdate is supported by the DBMS
     */
    public void stopNotificationListener() {
        // nothing to do
    }

    /**
     * Notifies all clients ({@link DBMSSynchronizer}) which are connected to the same DBMS.
     * Needs to be implemented if LiveUpdate is supported by the DBMS
     */
    public void notifyClients() {
        // nothing to do
    }
}
