/*
 * Copyright 2004-2019 H2 Group. Multiple-Licensed under the MPL 2.0,
 * and the EPL 1.0 (http://h2database.com/html/license.html).
 * Initial Developer: H2 Group
 */
package org.h2.schema;

import java.lang.reflect.Method;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Arrays;

import org.h2.api.ErrorCode;
import org.h2.api.Trigger;
import org.h2.command.Parser;
import org.h2.engine.Constants;
import org.h2.engine.DbObject;
import org.h2.engine.Session;
import org.h2.message.DbException;
import org.h2.message.Trace;
import org.h2.result.Row;
import org.h2.table.Table;
import org.h2.util.JdbcUtils;
import org.h2.util.SourceCompiler;
import org.h2.util.StatementBuilder;
import org.h2.util.StringUtils;
import org.h2.value.DataType;
import org.h2.value.Value;

/**
 *A trigger is created using the statement
 * CREATE TRIGGER
 */
public class TriggerObject extends SchemaObjectBase {

    /**
     * The default queue size.
     */
    public static final int DEFAULT_QUEUE_SIZE = 1024;

    private boolean insteadOf;
    private boolean before;
    private int typeMask;
    private boolean rowBased;
    private boolean onRollback;
    // TODO trigger: support queue and noWait = false as well
    private int queueSize = DEFAULT_QUEUE_SIZE;
    private boolean noWait;
    private Table table;
    private String triggerClassName;
    private String triggerSource;
    private Trigger triggerCallback;

    public TriggerObject(Schema schema, int id, String name, Table table) {
        super(schema, id, name, Trace.TRIGGER);
        this.table = table;
        setTemporary(table.isTemporary());
    }

    public void setBefore(boolean before) {
        this.before = before;
    }

    public void setInsteadOf(boolean insteadOf) {
        this.insteadOf = insteadOf;
    }

    private synchronized void load() {
        if (triggerCallback != null) {
            return;
        }
        try {
            Session sysSession = database.getSystemSession();
            Connection c2 = sysSession.createConnection(false);
            Object obj;
            if (triggerClassName != null) {
                obj = JdbcUtils.loadUserClass(triggerClassName).getDeclaredConstructor().newInstance();
            } else {
                obj = loadFromSource();
            }
            triggerCallback = (Trigger) obj;
            triggerCallback.init(c2, getSchema().getName(), getName(),
                    table.getName(), before, typeMask);
        } catch (Throwable e) {
            // try again later
            triggerCallback = null;
            throw DbException.get(ErrorCode.ERROR_CREATING_TRIGGER_OBJECT_3, e, getName(),
                triggerClassName != null ? triggerClassName : "..source..", e.toString());
        }
    }

    private Trigger loadFromSource() {
        SourceCompiler compiler = database.getCompiler();
        synchronized (compiler) {
            String fullClassName = Constants.USER_PACKAGE + ".trigger." + getName();
            compiler.setSource(fullClassName, triggerSource);
            try {
                if (SourceCompiler.isJavaxScriptSource(triggerSource)) {
                    return (Trigger) compiler.getCompiledScript(fullClassName).eval();
                } else {
                    final Method m = compiler.getMethod(fullClassName);
                    if (m.getParameterTypes().length > 0) {
                        throw new IllegalStateException("No parameters are allowed for a trigger");
                    }
                    return (Trigger) m.invoke(null);
                }
            } catch (DbException e) {
                throw e;
            } catch (Exception e) {
                throw DbException.get(ErrorCode.SYNTAX_ERROR_1, e, triggerSource);
            }
        }
    }

    /**
     * Set the trigger class name and load the class if possible.
     *
     * @param triggerClassName the name of the trigger class
     * @param force whether exceptions (due to missing class or access rights)
     *            should be ignored
     */
    public void setTriggerClassName(String triggerClassName, boolean force) {
        this.setTriggerAction(triggerClassName, null, force);
    }

    /**
     * Set the trigger source code and compile it if possible.
     *
     * @param source the source code of a method returning a {@link Trigger}
     * @param force whether exceptions (due to syntax error)
     *            should be ignored
     */
    public void setTriggerSource(String source, boolean force) {
        this.setTriggerAction(null, source, force);
    }

    private void setTriggerAction(String triggerClassName, String source, boolean force) {
        this.triggerClassName = triggerClassName;
        this.triggerSource = source;
        try {
            load();
        } catch (DbException e) {
            if (!force) {
                throw e;
            }
        }
    }

    /**
     * Call the trigger class if required. This method does nothing if the
     * trigger is not defined for the given action. This method is called before
     * or after any rows have been processed, once for each statement.
     *
     * @param session the session
     * @param type the trigger type
     * @param beforeAction if this method is called before applying the changes
     */
    public void fire(Session session, int type, boolean beforeAction) {
        if (rowBased || before != beforeAction || (typeMask & type) == 0) {
            return;
        }
        load();
        Connection c2 = session.createConnection(false);
        boolean old = false;
        if (type != Trigger.SELECT) {
            old = session.setCommitOrRollbackDisabled(true);
        }
        Value identity = session.getLastScopeIdentity();
        try {
            triggerCallback.fire(c2, null, null);
        } catch (Throwable e) {
            throw DbException.get(ErrorCode.ERROR_EXECUTING_TRIGGER_3, e, getName(),
                    triggerClassName != null ? triggerClassName : "..source..", e.toString());
        } finally {
            if (session.getLastTriggerIdentity() != null) {
                session.setLastScopeIdentity(session.getLastTriggerIdentity());
                session.setLastTriggerIdentity(null);
            } else {
                session.setLastScopeIdentity(identity);
            }
            if (type != Trigger.SELECT) {
                session.setCommitOrRollbackDisabled(old);
            }
        }
    }

    private static Object[] convertToObjectList(Row row) {
        if (row == null) {
            return null;
        }
        int len = row.getColumnCount();
        Object[] list = new Object[len];
        for (int i = 0; i < len; i++) {
            list[i] = row.getValue(i).getObject();
        }
        return list;
    }

    /**
     * Call the fire method of the user-defined trigger class if required. This
     * method does nothing if the trigger is not defined for the given action.
     * This method is called before or after a row is processed, possibly many
     * times for each statement.
     *
     * @param session the session
     * @param table the table
     * @param oldRow the old row
     * @param newRow the new row
     * @param beforeAction true if this method is called before the operation is
     *            applied
     * @param rollback when the operation occurred within a rollback
     * @return true if no further action is required (for 'instead of' triggers)
     */
    public boolean fireRow(Session session, Table table, Row oldRow, Row newRow,
            boolean beforeAction, boolean rollback) {
        if (!rowBased || before != beforeAction) {
            return false;
        }
        if (rollback && !onRollback) {
            return false;
        }
        load();
        Object[] oldList;
        Object[] newList;
        boolean fire = false;
        if ((typeMask & Trigger.INSERT) != 0) {
            if (oldRow == null && newRow != null) {
                fire = true;
            }
        }
        if ((typeMask & Trigger.UPDATE) != 0) {
            if (oldRow != null && newRow != null) {
                fire = true;
            }
        }
        if ((typeMask & Trigger.DELETE) != 0) {
            if (oldRow != null && newRow == null) {
                fire = true;
            }
        }
        if (!fire) {
            return false;
        }
        oldList = convertToObjectList(oldRow);
        newList = convertToObjectList(newRow);
        Object[] newListBackup;
        if (before && newList != null) {
            newListBackup = Arrays.copyOf(newList, newList.length);
        } else {
            newListBackup = null;
        }
        Connection c2 = session.createConnection(false);
        boolean old = session.getAutoCommit();
        boolean oldDisabled = session.setCommitOrRollbackDisabled(true);
        Value identity = session.getLastScopeIdentity();
        try {
            session.setAutoCommit(false);
            triggerCallback.fire(c2, oldList, newList);
            if (newListBackup != null) {
                for (int i = 0; i < newList.length; i++) {
                    Object o = newList[i];
                    if (o != newListBackup[i]) {
                        Value v = DataType.convertToValue(session, o, Value.UNKNOWN);
                        session.getGeneratedKeys().add(table.getColumn(i));
                        newRow.setValue(i, v);
                    }
                }
            }
        } catch (Exception e) {
            if (onRollback) {
                // ignore
            } else {
                throw DbException.convert(e);
            }
        } finally {
            if (session.getLastTriggerIdentity() != null) {
                session.setLastScopeIdentity(session.getLastTriggerIdentity());
                session.setLastTriggerIdentity(null);
            } else {
                session.setLastScopeIdentity(identity);
            }
            session.setCommitOrRollbackDisabled(oldDisabled);
            session.setAutoCommit(old);
        }
        return insteadOf;
    }

    /**
     * Set the trigger type.
     *
     * @param typeMask the type
     */
    public void setTypeMask(int typeMask) {
        this.typeMask = typeMask;
    }

    public void setRowBased(boolean rowBased) {
        this.rowBased = rowBased;
    }

    public void setQueueSize(int size) {
        this.queueSize = size;
    }

    public int getQueueSize() {
        return queueSize;
    }

    public void setNoWait(boolean noWait) {
        this.noWait = noWait;
    }

    public boolean isNoWait() {
        return noWait;
    }

    public void setOnRollback(boolean onRollback) {
        this.onRollback = onRollback;
    }

    @Override
    public String getDropSQL() {
        return null;
    }

    @Override
    public String getCreateSQLForCopy(Table targetTable, String quotedName) {
        StringBuilder buff = new StringBuilder("CREATE FORCE TRIGGER ");
        buff.append(quotedName);
        if (insteadOf) {
            buff.append(" INSTEAD OF ");
        } else if (before) {
            buff.append(" BEFORE ");
        } else {
            buff.append(" AFTER ");
        }
        buff.append(getTypeNameList());
        buff.append(" ON ").append(targetTable.getSQL());
        if (rowBased) {
            buff.append(" FOR EACH ROW");
        }
        if (noWait) {
            buff.append(" NOWAIT");
        } else {
            buff.append(" QUEUE ").append(queueSize);
        }
        if (triggerClassName != null) {
            buff.append(" CALL ");
            Parser.quoteIdentifier(buff, triggerClassName);
        } else {
            buff.append(" AS ");
            StringUtils.quoteStringSQL(buff, triggerSource);
        }
        return buff.toString();
    }

    public String getTypeNameList() {
        StatementBuilder buff = new StatementBuilder();
        if ((typeMask & Trigger.INSERT) != 0) {
            buff.appendExceptFirst(", ");
            buff.append("INSERT");
        }
        if ((typeMask & Trigger.UPDATE) != 0) {
            buff.appendExceptFirst(", ");
            buff.append("UPDATE");
        }
        if ((typeMask & Trigger.DELETE) != 0) {
            buff.appendExceptFirst(", ");
            buff.append("DELETE");
        }
        if ((typeMask & Trigger.SELECT) != 0) {
            buff.appendExceptFirst(", ");
            buff.append("SELECT");
        }
        if (onRollback) {
            buff.appendExceptFirst(", ");
            buff.append("ROLLBACK");
        }
        return buff.toString();
    }

    @Override
    public String getCreateSQL() {
        return getCreateSQLForCopy(table, getSQL());
    }

    @Override
    public int getType() {
        return DbObject.TRIGGER;
    }

    @Override
    public void removeChildrenAndResources(Session session) {
        table.removeTrigger(this);
        database.removeMeta(session, getId());
        if (triggerCallback != null) {
            try {
                triggerCallback.remove();
            } catch (SQLException e) {
                throw DbException.convert(e);
            }
        }
        table = null;
        triggerClassName = null;
        triggerSource = null;
        triggerCallback = null;
        invalidate();
    }

    @Override
    public void checkRename() {
        // nothing to do
    }

    /**
     * Get the table of this trigger.
     *
     * @return the table
     */
    public Table getTable() {
        return table;
    }

    /**
     * Check if this is a before trigger.
     *
     * @return true if it is
     */
    public boolean isBefore() {
        return before;
    }

    /**
     * Get the trigger class name.
     *
     * @return the class name
     */
    public String getTriggerClassName() {
        return triggerClassName;
    }

    public String getTriggerSource() {
        return triggerSource;
    }

    /**
     * Close the trigger.
     */
    public void close() throws SQLException {
        if (triggerCallback != null) {
            triggerCallback.close();
        }
    }

    /**
     * Check whether this is a select trigger.
     *
     * @return true if it is
     */
    public boolean isSelectTrigger() {
        return (typeMask & Trigger.SELECT) != 0;
    }

}
