/*
 * 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.jackrabbit.core.xml;

import org.apache.jackrabbit.core.value.InternalValue;
import org.apache.jackrabbit.util.Base64;
import org.apache.jackrabbit.util.TransientFileFactory;
import org.apache.jackrabbit.value.ValueHelper;
import org.apache.jackrabbit.spi.commons.conversion.NamePathResolver;
import org.apache.jackrabbit.spi.commons.value.ValueFormat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.ValueFormatException;
import javax.jcr.ValueFactory;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;

/**
 * <code>BufferedStringValue</code> represents an appendable
 * serialized value that is either buffered in-memory or backed
 * by a temporary file if its size exceeds a certain limit.
 * <p>
 * <b>Important:</b> Note that in order to free resources
 * <code>{@link #dispose()}</code> should be called as soon as
 * <code>BufferedStringValue</code> instance is not used anymore.
 */
class BufferedStringValue implements TextValue {

    private static Logger log = LoggerFactory.getLogger(BufferedStringValue.class);

    /**
     * The maximum size for buffering data in memory.
     */
    private static final int MAX_BUFFER_SIZE = 0x10000;

    /**
     * The in-memory buffer.
     */
    private StringWriter buffer;

    /**
     * The number of characters written so far.
     * If the in-memory buffer is used, this is position within buffer (size of actual data in buffer)
     */
    private long length;

    /**
     * Backing temporary file created when size of data exceeds
     * MAX_BUFFER_SIZE.
     */
    private File tmpFile;

    /**
     * Writer used to write to tmpFile.
     */
    private Writer writer;

    private final NamePathResolver nsContext;
    private final ValueFactory valueFactory;

    /**
     * Whether the value is base64 encoded.
     */
    private boolean base64;

    /**
     * Constructs a new empty <code>BufferedStringValue</code>.
     * @param nsContext
     */
    protected BufferedStringValue(NamePathResolver nsContext, ValueFactory valueFactory) {
        buffer = new StringWriter();
        length = 0;
        tmpFile = null;
        writer = null;
        this.nsContext = nsContext;
        this.valueFactory = valueFactory;
    }

    /**
     * Returns the length of the serialized value.
     *
     * @return the length of the serialized value
     * @throws IOException if an I/O error occurs
     */
    public long length() throws IOException {
        return length;
    }

    private String retrieveString() throws IOException {
        String value = retrieve();
        if (base64) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Base64.decode(value, out);
            value = new String(out.toByteArray(), StandardCharsets.UTF_8);
        }
        return value;
    }

    /**
     * Retrieves the serialized value.
     *
     * @return the serialized value
     * @throws IOException if an I/O error occurs
     */
    public String retrieve() throws IOException {
        if (buffer != null) {
            return buffer.toString();
        } else if (tmpFile != null) {
            // close writer first
            writer.close();
            if (tmpFile.length() > Integer.MAX_VALUE) {
                throw new IOException("size of value is too big, use reader()");
            }
            StringBuilder sb = new StringBuilder((int) length);
            char[] chunk = new char[0x2000];
            Reader reader = openReader();
            try {
                int read;
                while ((read = reader.read(chunk)) > -1) {
                    sb.append(chunk, 0, read);
                }
            } finally {
                reader.close();
            }
            return sb.toString();
        } else {
            throw new IOException("this instance has already been disposed");
        }
    }

    private Reader openReader() throws IOException {
        return new InputStreamReader(
                new BufferedInputStream(new FileInputStream(tmpFile)), StandardCharsets.UTF_8);
    }

    /**
     * Returns a <code>Reader</code> for reading the serialized value.
     *
     * @return a <code>Reader</code> for reading the serialized value.
     * @throws IOException if an I/O error occurs
     */
    public Reader reader() throws IOException {
        if (buffer != null) {
            return new StringReader(retrieve());
        } else if (tmpFile != null) {
            // close writer first
            writer.close();
            return openReader();
        } else {
            throw new IOException("this instance has already been disposed");
        }
    }

    /**
     * Append a portion of an array of characters.
     *
     * @param chars  the characters to be appended
     * @param start  the index of the first character to append
     * @param len the number of characters to append
     * @throws IOException if an I/O error occurs
     */
    public void append(char[] chars, int start, int len)
            throws IOException {
        if (buffer != null) {
            if (this.length + len > MAX_BUFFER_SIZE) {
                // threshold for keeping data in memory exceeded;
                // create temp file and spool buffer contents
                TransientFileFactory fileFactory = TransientFileFactory.getInstance();
                tmpFile = fileFactory.createTransientFile("txt", null, null);
                BufferedOutputStream fout = new BufferedOutputStream(new FileOutputStream(tmpFile));
                writer = new OutputStreamWriter(fout, StandardCharsets.UTF_8);
                writer.write(buffer.toString());
                writer.write(chars, start, len);
                // reset the in-memory buffer
                buffer = null;
            } else {
                buffer.write(chars, start, len);
            }
        } else if (tmpFile != null) {
            writer.write(chars, start, len);
        } else {
            throw new IOException("this instance has already been disposed");
        }
        length += len;
    }

    /**
     * Close this value. Once a value has been closed,
     * further append() invocations will cause an IOException to be thrown.
     *
     * @throws IOException if an I/O error occurs
     */
    public void close() throws IOException {
        if (buffer != null) {
            // nop
        } else if (tmpFile != null) {
            writer.close();
        } else {
            throw new IOException("this instance has already been disposed");
        }
    }

    //--------------------------------------------------------< TextValue >

    public Value getValue(int targetType, NamePathResolver resolver)
            throws ValueFormatException, RepositoryException {
        try {
            if (targetType == PropertyType.NAME
                    || targetType == PropertyType.PATH) {
                // NAME and PATH require special treatment because
                // they depend on the current namespace context
                // of the xml document

                // convert serialized value to InternalValue using
                // current namespace context of xml document
                InternalValue ival =
                    InternalValue.create(ValueHelper.convert(
                            retrieve(), targetType, valueFactory), nsContext);
                // convert InternalValue to Value using this
                // session's namespace mappings
                return ValueFormat.getJCRValue(ival, resolver, valueFactory);
            } else if (targetType == PropertyType.BINARY) {
                if (length() < 0x10000) {
                    // < 65kb: deserialize BINARY type using String
                    return ValueHelper.deserialize(retrieve(), targetType, false, valueFactory);
                } else {
                    // >= 65kb: deserialize BINARY type using Reader
                    Reader reader = reader();
                    try {
                        return ValueHelper.deserialize(reader, targetType, false, valueFactory);
                    } finally {
                        reader.close();
                    }
                }
            } else {
                // all other types
                return ValueHelper.deserialize(retrieveString(), targetType, false, valueFactory);
            }
        } catch (IOException e) {
            String msg = "failed to retrieve serialized value";
            log.debug(msg, e);
            throw new RepositoryException(msg, e);
        }
    }

    public InternalValue getInternalValue(int type)
            throws ValueFormatException, RepositoryException {
        try {
            if (type == PropertyType.BINARY) {
                // base64 encoded BINARY type;
                // decode using Reader
                if (length() < 0x10000) {
                    // < 65kb: deserialize BINARY type in memory
                    ByteArrayOutputStream baos = new ByteArrayOutputStream();
                    Base64.decode(retrieve(), baos);
                    // no need to close ByteArrayOutputStream
                    //baos.close();
                    return InternalValue.create(baos.toByteArray());
                } else {
                    // >= 65kb: deserialize BINARY type
                    // using Reader and temporary file
                    Base64ReaderInputStream in = new Base64ReaderInputStream(reader());
                    return InternalValue.createTemporary(in);
                }
            } else {
                // convert serialized value to InternalValue using
                // current namespace context of xml document
                return InternalValue.create(ValueHelper.convert(
                        retrieveString(), type, valueFactory), nsContext);
            }
        } catch (IOException e) {
            throw new RepositoryException("Error accessing property value", e);
        }
    }

    /**
     * This class converts the text read Converts a base64 reader to an input stream.
     */
    private static class Base64ReaderInputStream extends InputStream {

        private static final int BUFFER_SIZE = 1024;
        private final char[] chars;
        private final ByteArrayOutputStream out;
        private final Reader reader;
        private int pos;
        private int remaining;
        private byte[] buffer;

        public Base64ReaderInputStream(Reader reader) {
            chars = new char[BUFFER_SIZE];
            this.reader = reader;
            out = new ByteArrayOutputStream(BUFFER_SIZE);
        }

        private void fillBuffer() throws IOException {
            int len = reader.read(chars, 0, BUFFER_SIZE);
            if (len < 0) {
                remaining = -1;
                return;
            }
            Base64.decode(chars, 0, len, out);
            buffer = out.toByteArray();
            pos = 0;
            remaining = buffer.length;
            out.reset();
        }

        public int read() throws IOException {
            if (remaining == 0) {
                fillBuffer();
            }
            if (remaining < 0) {
                return -1;
            }
            remaining--;
            return buffer[pos++] & 0xff;
        }
    }

    /**
     * {@inheritDoc}
     */
    public void dispose() {
        if (buffer != null) {
            buffer = null;
        } else if (tmpFile != null) {
            try {
                writer.close();
                tmpFile.delete();
                tmpFile = null;
                writer = null;
            } catch (IOException e) {
                log.warn("Problem disposing property value", e);
            }
        } else {
            log.warn("this instance has already been disposed");
        }
    }

    /**
     * Whether this value is base64 encoded
     *
     * @param base64 the flag
     */
    public void setBase64(boolean base64) {
        this.base64 = base64;
    }

}
