/*****************************************************************
 *   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 org.apache.cayenne.access.types;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Blob;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;

import org.apache.cayenne.CayenneException;
import org.apache.cayenne.map.DbAttribute;
import org.apache.cayenne.util.MemoryBlob;
import org.apache.cayenne.validation.BeanValidationFailure;
import org.apache.cayenne.validation.ValidationResult;

/**
 * Handles <code>byte[]</code>, mapping it as either of JDBC types - BLOB or
 * (VAR)BINARY. Can be configured to trim trailing zero bytes.
 * 
 * @author Andrus Adamchik
 */
public class ByteArrayType extends AbstractType {

    private static final int BUF_SIZE = 8 * 1024;

    protected boolean trimmingBytes;
    protected boolean usingBlobs;

    /**
     * Strips null bytes from the byte array, returning a potentially smaller array that
     * contains no trailing zero bytes.
     */
    public static byte[] trimBytes(byte[] bytes) {
        int bytesToTrim = 0;
        for (int i = bytes.length - 1; i >= 0; i--) {
            if (bytes[i] != 0) {
                bytesToTrim = bytes.length - 1 - i;
                break;
            }
        }

        if (bytesToTrim == 0) {
            return bytes;
        }

        byte[] dest = new byte[bytes.length - bytesToTrim];
        System.arraycopy(bytes, 0, dest, 0, dest.length);
        return dest;
    }

    public ByteArrayType(boolean trimmingBytes, boolean usingBlobs) {
        this.usingBlobs = usingBlobs;
        this.trimmingBytes = trimmingBytes;
    }

    @Override
    public String getClassName() {
        return "byte[]";
    }

    /**
     * Validates byte[] property.
     * 
     * @since 1.1
     * @deprecated since 3.0 as validation should not be done at the DataNode level.
     */
    @Override
    public boolean validateProperty(
            Object source,
            String property,
            Object value,
            DbAttribute dbAttribute,
            ValidationResult validationResult) {

        if (!(value instanceof byte[])) {
            return true;
        }

        if (dbAttribute.getMaxLength() <= 0) {
            return true;
        }

        byte[] bytes = (byte[]) value;
        if (bytes.length > dbAttribute.getMaxLength()) {
            String message = "\""
                    + property
                    + "\" exceeds maximum allowed length ("
                    + dbAttribute.getMaxLength()
                    + " bytes): "
                    + bytes.length;
            validationResult.addFailure(new BeanValidationFailure(
                    source,
                    property,
                    message));
            return false;
        }

        return true;
    }

    @Override
    public Object materializeObject(ResultSet rs, int index, int type) throws Exception {

        byte[] bytes = null;

        if (type == Types.BLOB) {
            bytes = (isUsingBlobs()) ? readBlob(rs.getBlob(index)) : readBinaryStream(
                    rs,
                    index);
        }
        else {
            bytes = rs.getBytes(index);

            // trim BINARY type
            if (bytes != null && type == Types.BINARY && isTrimmingBytes()) {
                bytes = trimBytes(bytes);
            }
        }

        return bytes;
    }

    @Override
    public Object materializeObject(CallableStatement cs, int index, int type)
            throws Exception {

        byte[] bytes = null;

        if (type == Types.BLOB) {
            if (!isUsingBlobs()) {
                throw new CayenneException(
                        "Binary streams are not supported in stored procedure parameters.");
            }
            bytes = readBlob(cs.getBlob(index));
        }
        else {

            bytes = cs.getBytes(index);

            // trim BINARY type
            if (bytes != null && type == Types.BINARY && isTrimmingBytes()) {
                bytes = trimBytes(bytes);
            }
        }

        return bytes;
    }

    @Override
    public void setJdbcObject(
            PreparedStatement st,
            Object val,
            int pos,
            int type,
            int precision) throws Exception {

        // if this is a BLOB column, set the value as "bytes"
        // instead. This should work with most drivers
        if (type == Types.BLOB) {
            if (isUsingBlobs()) {
                st.setBlob(pos, writeBlob((byte[]) val));
            }
            else {
                st.setBytes(pos, (byte[]) val);
            }
        }
        else {
            super.setJdbcObject(st, val, pos, type, precision);
        }
    }

    protected Blob writeBlob(byte[] bytes) {
        return bytes != null ? new MemoryBlob(bytes) : null;
    }

    protected byte[] readBlob(Blob blob) throws IOException, SQLException {
        if (blob == null) {
            return null;
        }

        // sanity check on size
        if (blob.length() > Integer.MAX_VALUE) {
            throw new IllegalArgumentException(
                    "BLOB is too big to be read as byte[] in memory: " + blob.length());
        }

        int size = (int) blob.length();
        if (size == 0) {
            return new byte[0];
        }

        int bufSize = (size < BUF_SIZE) ? size : BUF_SIZE;
        InputStream in = blob.getBinaryStream();
        return (in != null) ? readValueStream(
                new BufferedInputStream(in, bufSize),
                size,
                bufSize) : null;
    }

    protected byte[] readBinaryStream(ResultSet rs, int index) throws IOException,
            SQLException {
        InputStream in = rs.getBinaryStream(index);
        return (in != null) ? readValueStream(in, -1, BUF_SIZE) : null;
    }

    protected byte[] readValueStream(InputStream in, int streamSize, int bufSize)
            throws IOException {

        byte[] buf = new byte[bufSize];
        int read;
        ByteArrayOutputStream out = (streamSize > 0) ? new ByteArrayOutputStream(
                streamSize) : new ByteArrayOutputStream();

        try {
            while ((read = in.read(buf, 0, bufSize)) >= 0) {
                out.write(buf, 0, read);
            }
            return out.toByteArray();
        }
        finally {
            in.close();
        }
    }

    /**
     * Returns <code>true</code> if byte columns are handled as BLOBs internally.
     */
    public boolean isUsingBlobs() {
        return usingBlobs;
    }

    public void setUsingBlobs(boolean usingBlobs) {
        this.usingBlobs = usingBlobs;
    }

    public boolean isTrimmingBytes() {
        return trimmingBytes;
    }

    public void setTrimmingBytes(boolean trimingBytes) {
        this.trimmingBytes = trimingBytes;
    }
}
