package liquibase.change;

import liquibase.configuration.GlobalConfiguration;
import liquibase.configuration.LiquibaseConfiguration;
import liquibase.database.Database;
import liquibase.database.core.MSSQLDatabase;
import liquibase.exception.*;
import liquibase.logging.LogFactory;
import liquibase.statement.SqlStatement;
import liquibase.statement.core.RawSqlStatement;
import liquibase.util.StringUtils;

import java.io.*;
import java.util.*;

/**
 * A common parent for all raw SQL related changes regardless of where the sql was sourced from.
 * 
 * Implements the necessary logic to choose how the SQL string should be parsed to generate the statements.
 *
 */
public abstract class AbstractSQLChange extends AbstractChange implements DbmsTargetedChange {

    private boolean stripComments;
    private boolean splitStatements;
    private String endDelimiter;
    private String sql;
    private String dbms;

    protected String encoding = null;


    protected AbstractSQLChange() {
        setStripComments(null);
        setSplitStatements(null);
    }

    public InputStream openSqlStream() throws IOException {
        return null;
    }

    @Override
    @DatabaseChangeProperty(since = "3.0", exampleValue = "h2, oracle")
    public String getDbms() {
        return dbms;
    }

    @Override
    public void setDbms(final String dbms) {
        this.dbms = dbms;
    }

    /**
     * {@inheritDoc}
     * @param database
     * @return
     */
    @Override
    public boolean supports(Database database) {
        return true;
    }

    @Override
    public Warnings warn(Database database) {
        return new Warnings();
    }

    @Override
    public ValidationErrors validate(Database database) {
        ValidationErrors validationErrors = new ValidationErrors();
        if (StringUtils.trimToNull(sql) == null) {
            validationErrors.addError("'sql' is required");
        }
        return validationErrors;

    }

    /**
     * Return if comments should be stripped from the SQL before passing it to the database.
     * <p></p>
     * This will always return a non-null value and should be a boolean rather than a Boolean, but that breaks the Bean Standard.
     */
    @DatabaseChangeProperty(description = "Set to true to remove any comments in the SQL before executing, otherwise false. Defaults to false if not set")
    public Boolean isStripComments() {
        return stripComments;
    }


    /**
     * Return true if comments should be stripped from the SQL before passing it to the database.
     * Passing null sets stripComments to the default value (false).
     */
    public void setStripComments(Boolean stripComments) {
        if (stripComments == null) {
            this.stripComments = false;
        } else {
            this.stripComments = stripComments;
        }
    }

    /**
     * Return if the SQL should be split into multiple statements before passing it to the database.
     * By default, statements are split around ";" and "go" delimiters.
     * <p></p>
     * This will always return a non-null value and should be a boolean rather than a Boolean, but that breaks the Bean Standard.
     */
    @DatabaseChangeProperty(description = "Set to false to not have liquibase split statements on ;'s and GO's. Defaults to true if not set")
    public Boolean isSplitStatements() {
        return splitStatements;
    }

    /**
     * Set whether SQL should be split into multiple statements.
     * Passing null sets stripComments to the default value (true).
     */
    public void setSplitStatements(Boolean splitStatements) {
        if (splitStatements == null) {
            this.splitStatements = true;
        } else {
            this.splitStatements = splitStatements;
        }
    }

    /**
     * Return the raw SQL managed by this Change
     */
    @DatabaseChangeProperty(serializationType = SerializationType.DIRECT_VALUE)
    public String getSql() {
        return sql;
    }

    /**
     * Set the raw SQL managed by this Change. The passed sql is trimmed and set to null if an empty string is passed.
     */
    public void setSql(String sql) {
       this.sql = StringUtils.trimToNull(sql);
    }

    /**
     * Set the end delimiter used to split statements. Will return null if the default delimiter should be used.
     *
     * @see #splitStatements
     */
    @DatabaseChangeProperty(description = "Delimiter to apply to the end of the statement. Defaults to ';', may be set to ''.", exampleValue = "\\nGO")
    public String getEndDelimiter() {
        return endDelimiter;
    }

    /**
     * Set the end delimiter for splitting SQL statements. Set to null to use the default delimiter.
     * @param endDelimiter
     */
    public void setEndDelimiter(String endDelimiter) {
        this.endDelimiter = endDelimiter;
    }

    /**
     * Calculates the checksum based on the contained SQL.
     *
     * @see liquibase.change.AbstractChange#generateCheckSum()
     */
    @Override
    public CheckSum generateCheckSum() {
        InputStream stream = null;
        try {
            stream = openSqlStream();

            String sql = this.sql;
            if (stream == null && sql == null) {
                sql = "";
            }

            if (sql != null) {
                stream = new ByteArrayInputStream(sql.getBytes(LiquibaseConfiguration.getInstance().getConfiguration(GlobalConfiguration.class).getOutputEncoding()));
            }

            return CheckSum.compute(new NormalizingStream(this.getEndDelimiter(), this.isSplitStatements(), this.isStripComments(), stream), false);
        } catch (IOException e) {
            throw new UnexpectedLiquibaseException(e);
        } finally {
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException e) {
                    LogFactory.getLogger().debug("Error closing stream", e);
                }
            }
        }
    }


    /**
     * Generates one or more SqlStatements depending on how the SQL should be parsed.
     * If split statements is set to true then the SQL is split and each command is made into a separate SqlStatement.
     * <p></p>
     * If stripping comments is true then any comments are removed before the splitting is executed.
     * The set SQL is passed through the {@link java.sql.Connection#nativeSQL} method if a connection is available.
     */
    @Override
    public SqlStatement[] generateStatements(Database database) {

        List<SqlStatement> returnStatements = new ArrayList<SqlStatement>();

        String sql = StringUtils.trimToNull(getSql());
        if (sql == null) {
            return new SqlStatement[0];
        }

        String processedSQL = normalizeLineEndings(sql);
        for (String statement : StringUtils.processMutliLineSQL(processedSQL, isStripComments(), isSplitStatements(), getEndDelimiter())) {
            if (database instanceof MSSQLDatabase) {
                 statement = statement.replaceAll("\\n", "\r\n");
             }

            String escapedStatement = statement;
            try {
                if (database.getConnection() != null) {
                    escapedStatement = database.getConnection().nativeSQL(statement);
                }
            } catch (DatabaseException e) {
				escapedStatement = statement;
			}

            returnStatements.add(new RawSqlStatement(escapedStatement, getEndDelimiter()));
        }

        return returnStatements.toArray(new SqlStatement[returnStatements.size()]);
    }

    @Override
    public boolean generateStatementsVolatile(Database database) {
        return false;
    }

    @Override
    public boolean generateRollbackStatementsVolatile(Database database) {
        return false;
    }

    @Override
    public ChangeStatus checkStatus(Database database) {
        return new ChangeStatus().unknown("Cannot check raw sql status");
    }

    protected String normalizeLineEndings(String string) {
        return string.replace("\r", "");
    }

//    @Override
//    public Set<String> getSerializableFields() {
//        Set<String> fieldsToSerialize = new HashSet<String>(super.getSerializableFields());
//        fieldsToSerialize.add("splitStatements");
//        fieldsToSerialize.add("stripComments");
//        return Collections.unmodifiableSet(fieldsToSerialize);
//    }
//
//    @Override
//    public Object getSerializableFieldValue(String field) {
//        if (field.equals("splitStatements")) {
//            return isSplitStatements();
//        }
//    }

    public static class NormalizingStream extends InputStream {
        private ByteArrayInputStream headerStream;
        private PushbackInputStream stream;

        private byte[] quickBuffer = new byte[100];
        private List<Byte> resizingBuffer = new ArrayList<Byte>();


        private int lastChar = 'X';
        private boolean seenNonSpace = false;

        public NormalizingStream(String endDelimiter, Boolean splitStatements, Boolean stripComments, InputStream stream) {
            this.stream = new PushbackInputStream(stream, 2048);
            try {
                this.headerStream = new ByteArrayInputStream((endDelimiter+":"+splitStatements+":"+stripComments+":").getBytes(LiquibaseConfiguration.getInstance().getConfiguration(GlobalConfiguration.class).getOutputEncoding()));
            } catch (UnsupportedEncodingException e) {
                throw new UnexpectedLiquibaseException(e);
            }
        }

        @Override
        public int read() throws IOException {
            if (headerStream != null) {
                int returnChar = headerStream.read();
                if (returnChar != -1) {
                    return returnChar;
                }
                headerStream = null;
            }

            int returnChar = stream.read();
            if (isWhiteSpace(returnChar)) {
                returnChar = ' ';
            }

            while (returnChar == ' ' && (!seenNonSpace || lastChar == ' ')) {
                returnChar = stream.read();

                if (isWhiteSpace(returnChar)) {
                    returnChar = ' ';
                }
            }

            seenNonSpace = true;

            lastChar = returnChar;

            if (lastChar == ' ' && isOnlyWhitespaceRemaining()) {
                return -1;
            }

            return returnChar;
        }

        @Override
        public int available() throws IOException {
            return stream.available();
        }

        @Override
        public boolean markSupported() {
            return stream.markSupported();
        }

        @Override
        public void mark(int readlimit) {
            stream.mark(readlimit);
        }

        @Override
        public void reset() throws IOException {
            stream.reset();
        }

        private boolean isOnlyWhitespaceRemaining() throws IOException {
            try {
                int quickBufferUsed = 0;
                while (true) {
                    byte read = (byte) stream.read();
                    if (quickBufferUsed >= quickBuffer.length) {
                        resizingBuffer.add(read);
                    } else {
                        quickBuffer[quickBufferUsed++] = read;
                    }

                    if (read == -1) {
                        return true;
                    }
                    if (!isWhiteSpace(read)) {
                        if (resizingBuffer.size() > 0) {

                            byte[] buf = new byte[resizingBuffer.size()];
                            for (int i=0; i< resizingBuffer.size(); i++) {
                                buf[i] = resizingBuffer.get(i);
                            }

                            stream.unread(buf);
                        }

                        stream.unread(quickBuffer, 0, quickBufferUsed);
                        return false;
                    }
                }
            } finally {
                resizingBuffer.clear();
            }
        }

        private boolean isWhiteSpace(int read) {
            return read == ' ' || read == '\n' || read == '\r' || read == '\t';
        }

        @Override
        public void close() throws IOException {
            stream.close();
        }
    }
}
