/*
 *  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 groovy.sql;

import groovy.lang.Closure;
import groovy.lang.GroovyRuntimeException;
import groovy.transform.stc.ClosureParams;
import groovy.transform.stc.SimpleType;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.CodeVisitorSupport;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.stmt.Statement;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

/**
 * An enhancement of Groovy's Sql class providing support for accessing
 * and querying databases using POGO fields and operators rather than
 * JDBC-level API calls and RDBMS column names. So, instead of a query like:
 * <pre>
 * {@code
 * def db = // an instance of groovy.sql.Sql
 * def sql = '''select * from Person
 *     where (purchaseCount > ? and birthMonth = ?)
 *     and (lastName < ? or lastName > ?)
 *     and age < ? and age > ? and firstName != ?
 *     order by firstName DESC, age'''
 * def params = [10, "January", "Zulu", "Alpha", 99, 5, "Bert"]
 * def sortedPeopleOfInterest = db.rows(sql, params)
 * }
 * </pre>
 * You can write code like this:
 * <pre>
 * {@code
 * def person = new DataSet(db, 'Person') // or db.dataSet('Person'), or db.dataSet(Person)
 * def janFrequentBuyers = person.findAll { it.purchaseCount > 10 && it.lastName == "January" }
 * def sortedPeopleOfInterest = janFrequentBuyers.
 *     findAll{ it.lastName < 'Zulu' || it.lastName > 'Alpha' }.
 *     findAll{ it.age < 99 }.
 *     findAll{ it.age > 5 }.
 *     sort{ it.firstName }.
 *     reverse().
 *     findAll{ it.firstName != 'Bert' }.
 *     sort{ it.age }
 * }
 * </pre>
 * Currently, the Groovy source code for any accessed POGO must be on the
 * classpath at runtime. Also, at the moment, the expressions (or nested expressions) can only contain
 * references to fields of the POGO or literals (i.e. constant Strings or numbers). This limitation
 * may be removed in a future version of Groovy.
 */
public class DataSet extends Sql {

    private static final int[] EMPTY_INT_ARRAY = new int[0];

    private Closure where;
    private Closure sort;
    private boolean reversed = false;
    private DataSet parent;
    private final String table;
    private SqlWhereVisitor visitor;
    private SqlOrderByVisitor sortVisitor;
    private String sql;
    private List<Object> params;
    private List<Object> batchData;
    private Set<String> batchKeys;
    private final Sql delegate;
    private boolean withinDataSetBatch = false;

    public DataSet(Sql sql, Class type) {
        super(sql);
        delegate = sql;
        String table = type.getName();
        int idx = table.lastIndexOf('.');
        if (idx > 0) {
            table = table.substring(idx + 1);
        }
        this.table = table.toLowerCase(Locale.ROOT);
    }

    public DataSet(Sql sql, String table) {
        super(sql);
        delegate = sql;
        this.table = table;
    }

    private DataSet(DataSet parent, Closure where) {
        super(parent);
        this.delegate = parent.delegate;
        this.table = parent.table;
        this.parent = parent;
        this.where = where;
    }

    private DataSet(DataSet parent, Closure where, Closure sort) {
        super(parent);
        this.delegate = parent.delegate;
        this.table = parent.table;
        this.parent = parent;
        this.where = where;
        this.sort = sort;
    }

    private DataSet(DataSet parent) {
        super(parent);
        this.delegate = parent.delegate;
        this.table = parent.table;
        this.parent = parent;
        this.reversed = true;
    }

    @Override
    protected Connection createConnection() throws SQLException {
        return delegate.createConnection();
    }

    @Override
    protected void closeResources(Connection connection, java.sql.Statement statement, ResultSet results) {
        delegate.closeResources(connection, statement, results);
    }

    @Override
    protected void closeResources(Connection connection, java.sql.Statement statement) {
        delegate.closeResources(connection, statement);
    }

    @Override
    public void cacheConnection(Closure closure) throws SQLException {
        delegate.cacheConnection(closure);
    }

    @Override
    public void withTransaction(Closure closure) throws SQLException {
        delegate.withTransaction(closure);
    }

    @Override
    public void commit() throws SQLException {
        delegate.commit();
    }

    @Override
    public void rollback() throws SQLException {
        delegate.rollback();
    }

    /**
     * Performs the closure (containing batch operations) within a batch.
     * Uses a batch size of zero, i.e. no automatic partitioning of batches.
     *
     * @param closure the closure containing batch and optionally other statements
     * @return an array of update counts containing one element for each
     *         command in the batch.  The elements of the array are ordered according
     *         to the order in which commands were added to the batch.
     * @throws SQLException if a database access error occurs,
     *                      or this method is called on a closed <code>Statement</code>, or the
     *                      driver does not support batch statements. Throws {@link java.sql.BatchUpdateException}
     *                      (a subclass of <code>SQLException</code>) if one of the commands sent to the
     *                      database fails to execute properly or attempts to return a result set.
     */
    @Override
    public int[] withBatch(Closure closure) throws SQLException {
        return withBatch(0, closure);
    }

    /**
     * Performs the closure (containing batch operations) within a batch.
     * For example:
     * <pre>
     * dataSet.withBatch(3) {
     *     add(anint: 1, astring: "Groovy")
     *     add(anint: 2, astring: "rocks")
     *     add(anint: 3, astring: "the")
     *     add(anint: 4, astring: "casbah")
     * }
     * </pre>
     *
     * @param batchSize partition the batch into batchSize pieces, i.e. after batchSize
     *                  <code>addBatch()</code> invocations, call <code>executeBatch()</code> automatically;
     *                  0 means manual calls to executeBatch are required
     * @param closure   the closure containing batch and optionally other statements
     * @return an array of update counts containing one element for each
     *         command in the batch.  The elements of the array are ordered according
     *         to the order in which commands were added to the batch.
     * @throws SQLException if a database access error occurs, or the driver does not support batch statements.
     *                      Throws {@link java.sql.BatchUpdateException} (a subclass of <code>SQLException</code>)
     *                      if one of the commands sent to the database fails to execute properly.
     */
    @Override
    public int[] withBatch(int batchSize, Closure closure) throws SQLException {
        batchData = new ArrayList<Object>();
        withinDataSetBatch = true;
        closure.call(this);
        withinDataSetBatch = false;
        if (batchData.isEmpty()) {
            return EMPTY_INT_ARRAY;
        }
        Closure transformedClosure = new Closure(null) {
            public void doCall(BatchingPreparedStatementWrapper stmt) throws SQLException {
                for (Object next : batchData) {
                    stmt.addBatch(new Object[]{next});
                }
            }
        };
        return super.withBatch(batchSize, buildMapQuery(), transformedClosure);
    }

    /**
     * Adds the provided map of key-value pairs as a new row in the table represented by this DataSet.
     *
     * @param map the key (column-name), value pairs to add as a new row
     * @throws SQLException if a database error occurs
     */
    public void add(Map<String, Object> map) throws SQLException {
        if (withinDataSetBatch) {
            if (batchData.isEmpty()) {
                batchKeys = map.keySet();
            } else {
                if (!map.keySet().equals(batchKeys)) {
                    throw new IllegalArgumentException("Inconsistent keys found for batch add!");
                }
            }
            batchData.add(map);
            return;
        }
        int answer = executeUpdate(buildListQuery(map), new ArrayList<Object>(map.values()));
        if (answer != 1) {
            LOG.warning("Should have updated 1 row not " + answer + " when trying to add: " + map);
        }
    }

    private String buildListQuery(Map<String, Object> map) {
        StringBuilder buffer = new StringBuilder("insert into ");
        buffer.append(table);
        buffer.append(" (");
        StringBuilder paramBuffer = new StringBuilder();
        boolean first = true;
        for (String column : map.keySet()) {
            if (first) {
                first = false;
                paramBuffer.append("?");
            } else {
                buffer.append(", ");
                paramBuffer.append(", ?");
            }
            buffer.append(column);
        }
        buffer.append(") values (");
        buffer.append(paramBuffer.toString());
        buffer.append(")");
        return buffer.toString();
    }

    private String buildMapQuery() {
        StringBuilder buffer = new StringBuilder("insert into ");
        buffer.append(table);
        buffer.append(" (");
        StringBuilder paramBuffer = new StringBuilder();
        boolean first = true;
        for (String column : batchKeys) {
            if (first) {
                first = false;
                paramBuffer.append(":");
            } else {
                buffer.append(", ");
                paramBuffer.append(", :");
            }
            paramBuffer.append(column);
            buffer.append(column);
        }
        buffer.append(") values (");
        buffer.append(paramBuffer.toString());
        buffer.append(")");
        return buffer.toString();
    }

    /**
     * Return a lazy-implemented filtered view of this DataSet.
     *
     * @param where the filtering Closure
     * @return the view DataSet
     */
    public DataSet findAll(Closure where) {
        return new DataSet(this, where);
    }

    /**
     * Return a lazy-implemented re-ordered view of this DataSet.
     *
     * @param sort the ordering Closure
     * @return the view DataSet
     */
    public DataSet sort(Closure sort) {
        return new DataSet(this, null, sort);
    }

    /**
     * Return a lazy-implemented reverse-ordered view of this DataSet.
     *
     * @return the view DataSet
     */
    public DataSet reverse() {
        if (sort == null) {
            throw new GroovyRuntimeException("reverse() only allowed immediately after a sort()");
        }
        return new DataSet(this);
    }

    /**
     * Calls the provided closure for each of the rows of the table represented by this DataSet.
     *
     * @param closure called for each row with a GroovyResultSet
     * @throws SQLException if a database access error occurs
     * @see groovy.sql.Sql#eachRow(String, java.util.List, groovy.lang.Closure)
     */
    public void each(@ClosureParams(value=SimpleType.class, options="groovy.sql.GroovyResultSet") Closure closure) throws SQLException {
        eachRow(getSql(), getParameters(), closure);
    }

    /**
     * Calls the provided closure for a "page" of rows from the table represented by this DataSet.
     * A page is defined as starting at a 1-based offset, and containing a maximum number of rows.
     *
     * @param offset  the 1-based offset for the first row to be processed
     * @param maxRows the maximum number of rows to be processed
     * @param closure called for each row with a GroovyResultSet
     * @throws SQLException if a database access error occurs
     * @see groovy.sql.Sql#eachRow(String, java.util.List, int, int, groovy.lang.Closure)
     */
    public void each(int offset, int maxRows,
                     @ClosureParams(value=SimpleType.class, options="groovy.sql.GroovyResultSet") Closure closure) throws SQLException {
        eachRow(getSql(), getParameters(), offset, maxRows, closure);
    }

    private String getSqlWhere() {
        String whereClaus = "";
        String parentClaus = "";
        if (parent != null) {
            parentClaus = parent.getSqlWhere();
        }
        if (where != null) {
            whereClaus += getSqlWhereVisitor().getWhere();
        }
        if (parentClaus.isEmpty()) return whereClaus;
        if (whereClaus.isEmpty()) return parentClaus;
        return parentClaus + " and " + whereClaus;
    }

    private String getSqlOrderBy() {
        String sortByClaus = "";
        String parentClaus = "";
        if (parent != null) {
            parentClaus = parent.getSqlOrderBy();
        }
        if (reversed) {
            if (!parentClaus.isEmpty()) parentClaus += " DESC";
        }
        if (sort != null) {
            sortByClaus += getSqlOrderByVisitor().getOrderBy();
        }
        if (parentClaus.isEmpty()) return sortByClaus;
        if (sortByClaus.isEmpty()) return parentClaus;
        return parentClaus + ", " + sortByClaus;
    }

    public String getSql() {
        if (sql == null) {
            sql = "select * from " + table;
            String whereClaus = getSqlWhere();
            if (!whereClaus.isEmpty()) {
                sql += " where " + whereClaus;
            }
            String orderByClaus = getSqlOrderBy();
            if (!orderByClaus.isEmpty()) {
                sql += " order by " + orderByClaus;
            }
        }
        return sql;
    }

    public List<Object> getParameters() {
        if (params == null) {
            params = new ArrayList<Object>();
            if (parent != null) {
                params.addAll(parent.getParameters());
            }
            params.addAll(getSqlWhereVisitor().getParameters());
        }
        return params;
    }

    protected SqlWhereVisitor getSqlWhereVisitor() {
        if (visitor == null) {
            visitor = new SqlWhereVisitor();
            visit(where, visitor);
        }
        return visitor;
    }

    protected SqlOrderByVisitor getSqlOrderByVisitor() {
        if (sortVisitor == null) {
            sortVisitor = new SqlOrderByVisitor();
            visit(sort, sortVisitor);
        }
        return sortVisitor;
    }

    private static void visit(Closure closure, CodeVisitorSupport visitor) {
        if (closure != null) {
            ClassNode classNode = closure.getMetaClass().getClassNode();
            if (classNode == null) {
                throw new GroovyRuntimeException(
                        "DataSet unable to evaluate expression. AST not available for closure: " + closure.getMetaClass().getTheClass().getName() +
                                ". Is the source code on the classpath?");
            }
            List methods = classNode.getDeclaredMethods("doCall");
            if (!methods.isEmpty()) {
                MethodNode method = (MethodNode) methods.get(0);
                if (method != null) {
                    Statement statement = method.getCode();
                    if (statement != null) {
                        statement.visit(visitor);
                    }
                }
            }
        }
    }

    /*
    * create a subset of the original dataset
    */
    public DataSet createView(Closure criteria) {
        return new DataSet(this, criteria);
    }

    /**
     * Returns a List of all the rows from the DataSet.
     *
     * @return Returns a list of GroovyRowResult objects from the dataset
     * @throws SQLException if a database error occurs
     */
    public List rows() throws SQLException {
        return rows(getSql(), getParameters());
    }

    /**
     * Returns a "page" of the rows from the table a DataSet represents. A page
     * is defined as starting at a 1-based offset, and containing a maximum number
     * of rows.
     *
     * @param offset the 1-based offset for the first row to be processed
     * @param maxRows the maximum number of rows to be processed
     * @return a list of GroovyRowResult objects from the dataset
     * @throws SQLException if a database error occurs
     */
    public List rows(int offset, int maxRows) throws SQLException {
        return rows(getSql(), getParameters(), offset, maxRows);
    }

    /**
     * Returns the first row from a DataSet's underlying table
     *
     * @return Returns the first GroovyRowResult object from the dataset
     * @throws SQLException if a database error occurs
     */
    public Object firstRow() throws SQLException {
        List rows = rows();
        if (rows.isEmpty()) return null;
        return (rows.get(0));
    }

    @Override
    public void close() {
        delegate.close();
        super.close();
    }
}
