/*
 * 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.journal;

import org.apache.commons.io.IOUtils;
import org.apache.jackrabbit.spi.commons.conversion.NamePathResolver;
import org.apache.jackrabbit.spi.commons.namespace.NamespaceResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;

/**
 * A file record log is a file containing {@link Record}s. Every file record
 * log contains a header with the following physical layout:
 *
 * <blockquote>
 *   <table border="2" cellpadding="4">
 *     <tr align="center" valign="bottom" bgcolor="silver">
 *       <td><tt>Byte 1</tt></td>
 *       <td><tt>Byte 2</tt></td>
 *       <td><tt>Byte 3</tt></td>
 *       <td><tt>Byte 4</tt></td>
 *     </tr>
 *     <tr>
 *       <td align="center"><tt>'J'</tt></td>
 *       <td align="center"><tt>'L'</tt></td>
 *       <td align="center"><tt>'O'</tt></td>
 *       <td align="center"><tt>'G'</tt></td>
 *     </tr>
 *     <tr>
 *       <td align="center" colspan="2"><tt>MAJOR</tt></td>
 *       <td align="center" colspan="2"><tt>MINOR</tt></td>
 *     </tr>
 *     <tr>
 *       <td align="center" colspan="4"><tt>START REVISION</tt></td>
 *     </tr>
 *  </table>
 * </blockquote>
 *
 * After this header, zero or more <code>ReadRecord</code>s follow.
 */
public class FileRecordLog {

    /**
     * Logger.
     */
    private static Logger log = LoggerFactory.getLogger(FileRecordLog.class);

    /**
     * Record log signature.
     */
    private static final byte[] SIGNATURE = { 'J', 'L', 'O', 'G' };

    /**
     * Known major version.
     */
    private static final short MAJOR_VERSION = 2;

    /**
     * Known minor version.
     */
    private static final short MINOR_VERSION = 0;

    /**
     * Header size. This is the size of {@link #SIGNATURE}, {@link #MAJOR_VERSION},
     * {@link #MINOR_VERSION} and first revision (8 bytes).
     */
    private static final int HEADER_SIZE = 4 + 2 + 2 + 8;

    /**
     * Underlying file.
     */
    private File logFile;

    /**
     * Flag indicating whether this is a new log.
     */
    private boolean isNew;

    /**
     * Input stream used when seeking a specific record.
     */
    private DataInputStream in;

    /**
     * Last revision that is not in this log.
     */
    private long previousRevision;

    /**
     * Relative position inside this log.
     */
    private long position;

    /**
     * Last revision that is available in this log.
     */
    private long lastRevision;

    /**
     * Major version found in record log.
     */
    private short major;

    /**
     * Minor version found in record log.
     */
    private short minor;

    /**
     * Create a new instance of this class. Opens a record log in read-only mode.
     *
     * @param logFile file containing record log
     * @throws java.io.IOException if an I/O error occurs
     */
    public FileRecordLog(File logFile) throws IOException {
        this.logFile = logFile;

        if (logFile.exists()) {
            DataInputStream in = new DataInputStream(
                    new BufferedInputStream(new FileInputStream(logFile), 128));

            try {
                readHeader(in);
                previousRevision = in.readLong();
                lastRevision = previousRevision + logFile.length() - HEADER_SIZE;
            } finally {
                close(in);
            }
        } else {
            isNew = true;
        }
    }

    /**
     * Initialize this record log by writing a header containing the
     * previous revision.
     */
    public void init(long previousRevision) throws IOException {
        if (isNew) {
            DataOutputStream out = new DataOutputStream(
                    new BufferedOutputStream(new FileOutputStream(logFile), 128));

            try {
                writeHeader(out);
                out.writeLong(previousRevision);
            } finally {
                close(out);
            }

            this.previousRevision = previousRevision;
            this.lastRevision = previousRevision;
            isNew = false;
        }
    }

    /**
     * Return a flag indicating whether this record log contains a certain revision.
     *
     * @param revision revision to look for
     * @return <code>true</code> if this record log contain a certain revision;
     *         <code>false</code> otherwise
     */
    public boolean contains(long revision) {
        return (revision >= previousRevision && revision < lastRevision);
    }

    /**
     * Return a flag indicating whether this record log is new.
     *
     * @return <code>true</code> if this record log is new;
     *         <code>false</code> otherwise
     */
    public boolean isNew() {
        return isNew;
    }

    /**
     * Return a flag indicating whether this record log exceeds a given size.
     */
    public boolean exceeds(long size) {
        return (lastRevision - previousRevision) > size;
    }

    /**
     * Seek an entry. This is an operation that allows the underlying input stream
     * to be sequentially scanned and must therefore not be called twice.
     *
     * @param revision revision to seek
     * @throws java.io.IOException if an I/O error occurs
     */
    public void seek(long revision) throws IOException {
        if (in != null) {
            String msg = "Stream already open: seek() only allowed once.";
            throw new IllegalStateException(msg);
        }
        in = new DataInputStream(new BufferedInputStream(
                new FileInputStream(logFile)));
        skip(revision - previousRevision + HEADER_SIZE);
        position = revision - previousRevision;
    }

    /**
     * Skip exactly <code>n</code> bytes. Throws if less bytes are skipped.
     *
     * @param n bytes to skip
     * @throws java.io.IOException if an I/O error occurs, or less that <code>n</code> bytes
     *                     were skipped.
     */
    private void skip(long n) throws IOException {
        long skiplen = n;
        while (skiplen > 0) {
            long skipped = in.skip(skiplen);
            if (skipped <= 0) {
                break;
            }
            skiplen -= skipped;
        }
        if (skiplen != 0) {
            String msg = "Unable to skip remaining bytes.";
            throw new IOException(msg);
        }
    }

    /**
     * Read the file record at the current seek position.
     *
     * @param resolver namespace resolver
     * @return file record
     * @throws java.io.IOException if an I/O error occurs
     */
    public ReadRecord read(NamespaceResolver resolver, NamePathResolver npResolver) throws IOException {
        String journalId = in.readUTF();
        String producerId = in.readUTF();
        int length = in.readInt();

        position +=
            2 + utfLength(journalId) +  2 + utfLength(producerId) + 4 + length;

        long revision = previousRevision + position;
        return new ReadRecord(journalId, producerId, revision, in, length, resolver, npResolver);
    }

    /**
     * Append a record to this log. Returns the revision following this record.
     *
     * @param journalId journal identifier
     * @param producerId producer identifier
     * @param in record to add
     * @param length record length
     * @throws java.io.IOException if an I/O error occurs
     */
    public long append(String journalId, String producerId, InputStream in, int length)
            throws IOException {

        OutputStream out = new FileOutputStream(logFile, true);

        try {
            DataBuffer buffer = new DataBuffer();
            buffer.writeUTF(journalId);
            buffer.writeUTF(producerId);
            buffer.writeInt(length);
            buffer.copy(out);

            IOUtils.copy(in, out);
            out.flush();

            lastRevision +=
                2 + utfLength(journalId) + 2 + utfLength(producerId)
                + 4 + length;
            return lastRevision;
        } finally {
            close(out);
        }
    }

    /**
     * Return the previous revision. This is the last revision preceding the
     * first revision in this log.
     *
     * @return previous revision
     */
    public long getPreviousRevision() {
        return previousRevision;
    }

    /**
     * Return the last revision. This is the last revision in this log.
     *
     * @return last revision
     */
    public long getLastRevision() {
        return lastRevision;
    }

    /**
     * Close this log.
     */
    public void close() {
        try {
            if (in != null) {
                in.close();
            }
        } catch (IOException e) {
            String msg = "Error while closing record log: " + e.getMessage();
            log.warn(msg);
        }
    }

    /**
     * Read signature and major/minor version of file and verify.
     *
     * @param in input stream
     * @throws java.io.IOException if an I/O error occurs or the file does
     *                     not have a valid header.
     */
    private void readHeader(DataInputStream in) throws IOException {
        byte[] signature = new byte[SIGNATURE.length];
        in.readFully(signature);

        for (int i = 0; i < SIGNATURE.length; i++) {
            if (signature[i] != SIGNATURE[i]) {
                String msg = "Record log '" + logFile.getPath()
                    + "' has wrong signature: " + toHexString(signature);
                throw new IOException(msg);
            }
        }

        major = in.readShort();
        if (major != MAJOR_VERSION) {
            String msg = "Record log '" + logFile.getPath()
                + "' has incompatible major version: " + major;
            throw new IOException(msg);
        }
        minor  = in.readShort();
    }

    /**
     * Write signature and major/minor.
     *
     * @param out input stream
     * @throws java.io.IOException if an I/O error occurs.
     */
    private void writeHeader(DataOutputStream out) throws IOException {
        out.write(SIGNATURE);
        out.writeShort(MAJOR_VERSION);
        out.writeShort(MINOR_VERSION);
    }

    /**
     * Close an input stream, logging a warning if an error occurs.
     */
    private static void close(InputStream in) {
        try {
            in.close();
        } catch (IOException e) {
            String msg = "I/O error while closing input stream.";
            log.warn(msg, e);
        }
    }

    /**
     * Close an output stream, logging a warning if an error occurs.
     */
    private static void close(OutputStream out) {
        try {
            out.close();
        } catch (IOException e) {
            String msg = "I/O error while closing input stream.";
            log.warn(msg, e);
        }
    }

    /**
     * Convert a byte array to its hexadecimal string representation.
     */
    private static String toHexString(byte[] b) {
        StringBuilder buf = new StringBuilder();
        for (int i = 0; i < b.length; i++) {
            String s = Integer.toHexString(b[i] & 0xff).toUpperCase();
            if (s.length() == 1) {
                buf.append('0');
            }
            buf.append(s);
        }
        return buf.toString();
    }

    /**
     * Return the length of a string when converted to its Java modified
     * UTF-8 encoding, as used by <code>DataInput.readUTF</code> and
     * <code>DataOutput.writeUTF</code>.
     */
    private static int utfLength(String s) {
        char[] ac = s.toCharArray();
        int utflen = 0;

        for (int i = 0; i < ac.length; i++) {
            char c = ac[i];
            if ((c >= 0x0001) && (c <= 0x007F)) {
                utflen++;
            } else if (c > 0x07FF) {
                utflen += 3;
            } else {
                utflen += 2;
            }
        }
        return utflen;
    }

    /**
     * A simple helper class that writes to a buffer. The current buffer can
     * be {@link #copy copied} to an output stream.
     */
    private static final class DataBuffer extends DataOutputStream {

        public DataBuffer() {
            super(new ByteArrayOutputStream());
        }

        /**
         * Copies the bytes the are currently held in the buffer to the given
         * output stream.
         *
         * @param out the output stream where the buffered data is written.
         * @throws IOException if an error occurs while writing data to
         *          <code>out</code>.
         */
        public void copy(OutputStream out) throws IOException {
            byte[] buffer = ((ByteArrayOutputStream) super.out).toByteArray();
            out.write(buffer);
        }
    }
}
