/*
 * Copyright 2004-2023 H2 Group. Multiple-Licensed under the MPL 2.0,
 * and the EPL 1.0 (https://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.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;

import org.h2.api.ErrorCode;
import org.h2.api.Trigger;
import org.h2.engine.Constants;
import org.h2.engine.DbObject;
import org.h2.engine.SessionLocal;
import org.h2.jdbc.JdbcConnection;
import org.h2.jdbc.JdbcResultSet;
import org.h2.message.DbException;
import org.h2.message.Trace;
import org.h2.result.Row;
import org.h2.result.SimpleResult;
import org.h2.table.Column;
import org.h2.table.Table;
import org.h2.tools.TriggerAdapter;
import org.h2.util.JdbcUtils;
import org.h2.util.SourceCompiler;
import org.h2.util.StringUtils;
import org.h2.value.Value;
import org.h2.value.ValueToObjectConverter;

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

    /**
     * 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 boolean isInsteadOf() {
        return insteadOf;
    }

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

    private synchronized void load() {
        if (triggerCallback != null) {
            return;
        }
        try {
            SessionLocal 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(SessionLocal 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.getLastIdentity();
        try {
            if (triggerCallback instanceof TriggerAdapter) {
                ((TriggerAdapter) triggerCallback).fire(c2, (ResultSet) null, (ResultSet) null);
            } else {
                triggerCallback.fire(c2, null, null);
            }
        } catch (Throwable e) {
            throw getErrorExecutingTrigger(e);
        } finally {
            session.setLastIdentity(identity);
            if (type != Trigger.SELECT) {
                session.setCommitOrRollbackDisabled(old);
            }
        }
    }

    private static Object[] convertToObjectList(Row row, JdbcConnection conn) {
        if (row == null) {
            return null;
        }
        int len = row.getColumnCount();
        Object[] list = new Object[len];
        for (int i = 0; i < len; i++) {
            list[i] = ValueToObjectConverter.valueToDefaultObject(row.getValue(i), conn, false);
        }
        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(SessionLocal session, Table table, Row oldRow, Row newRow,
            boolean beforeAction, boolean rollback) {
        if (!rowBased || before != beforeAction) {
            return false;
        }
        if (rollback && !onRollback) {
            return false;
        }
        load();
        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;
        }
        JdbcConnection c2 = session.createConnection(false);
        boolean old = session.getAutoCommit();
        boolean oldDisabled = session.setCommitOrRollbackDisabled(true);
        Value identity = session.getLastIdentity();
        try {
            session.setAutoCommit(false);
            if (triggerCallback instanceof TriggerAdapter) {
                JdbcResultSet oldResultSet = oldRow != null ? createResultSet(c2, table, oldRow, false) : null;
                JdbcResultSet newResultSet = newRow != null ? createResultSet(c2, table, newRow, before) : null;
                try {
                    ((TriggerAdapter) triggerCallback).fire(c2, oldResultSet, newResultSet);
                } catch (Throwable e) {
                    throw getErrorExecutingTrigger(e);
                }
                if (newResultSet != null) {
                    Value[] updatedList = newResultSet.getUpdateRow();
                    if (updatedList != null) {
                        boolean modified = false;
                        for (int i = 0, l = updatedList.length; i < l; i++) {
                            Value v = updatedList[i];
                            if (v != null) {
                                modified = true;
                                newRow.setValue(i, v);
                            }
                        }
                        if (modified) {
                            table.convertUpdateRow(session, newRow, true);
                        }
                    }
                }
            } else {
                Object[] oldList = convertToObjectList(oldRow, c2);
                Object[] newList = convertToObjectList(newRow, c2);
                Object[] newListBackup = before && newList != null ? Arrays.copyOf(newList, newList.length) : null;
                try {
                    triggerCallback.fire(c2, oldList, newList);
                } catch (Throwable e) {
                    throw getErrorExecutingTrigger(e);
                }
                if (newListBackup != null) {
                    boolean modified = false;
                    for (int i = 0; i < newList.length; i++) {
                        Object o = newList[i];
                        if (o != newListBackup[i]) {
                            modified = true;
                            newRow.setValue(i, ValueToObjectConverter.objectToValue(session, o, Value.UNKNOWN));
                        }
                    }
                    if (modified) {
                        table.convertUpdateRow(session, newRow, true);
                    }
                }
            }
        } catch (Exception e) {
            if (onRollback) {
                // ignore
            } else {
                throw DbException.convert(e);
            }
        } finally {
            session.setLastIdentity(identity);
            session.setCommitOrRollbackDisabled(oldDisabled);
            session.setAutoCommit(old);
        }
        return insteadOf;
    }

    private static JdbcResultSet createResultSet(JdbcConnection conn, Table table, Row row, boolean updatable)
            throws SQLException {
        SimpleResult result = new SimpleResult(table.getSchema().getName(), table.getName());
        for (Column c : table.getColumns()) {
            result.addColumn(c.getName(), c.getType());
        }
        /*
         * Old implementation works with and without next() invocation, so add
         * the row twice for compatibility.
         */
        result.addRow(row.getValueList());
        result.addRow(row.getValueList());
        JdbcResultSet resultSet = new JdbcResultSet(conn, null, null, result, -1, false, false, updatable);
        resultSet.next();
        return resultSet;
    }

    private DbException getErrorExecutingTrigger(Throwable e) {
        if (e instanceof DbException) {
            return (DbException) e;
        }
        if (e instanceof SQLException) {
            return DbException.convert(e);
        }
        return DbException.get(ErrorCode.ERROR_EXECUTING_TRIGGER_3, e, getName(),
                triggerClassName != null ? triggerClassName : "..source..", e.toString());
    }

    /**
     * Returns the trigger type.
     *
     * @return the trigger type
     */
    public int getTypeMask() {
        return typeMask;
    }

    /**
     * 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 boolean isRowBased() {
        return 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;
    }

    public boolean isOnRollback() {
        return onRollback;
    }

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

    /**
     * Append the trigger types to the given string builder.
     *
     * @param builder the builder
     * @return the passed string builder
     */
    public StringBuilder getTypeNameList(StringBuilder builder) {
        boolean f = false;
        if ((typeMask & Trigger.INSERT) != 0) {
            f = true;
            builder.append("INSERT");
        }
        if ((typeMask & Trigger.UPDATE) != 0) {
            if (f) {
                builder.append(", ");
            }
            f = true;
            builder.append("UPDATE");
        }
        if ((typeMask & Trigger.DELETE) != 0) {
            if (f) {
                builder.append(", ");
            }
            f = true;
            builder.append("DELETE");
        }
        if ((typeMask & Trigger.SELECT) != 0) {
            if (f) {
                builder.append(", ");
            }
            f = true;
            builder.append("SELECT");
        }
        if (onRollback) {
            if (f) {
                builder.append(", ");
            }
            builder.append("ROLLBACK");
        }
        return builder;
    }

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

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

    @Override
    public void removeChildrenAndResources(SessionLocal 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();
    }

    /**
     * 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.
     * @throws SQLException on failure
     */
    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;
    }

}
