/**
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


package com.mucommander.commons.file.archive.tar.provider;

import com.mucommander.commons.io.BufferPool;

import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;

/**
 * The TarInputStream reads a UNIX tar archive as an InputStream.
 * methods are provided to position at each successive entry in
 * the archive, and the read each entry as a normal input stream
 * using read().
 *
 * <p>-----------------------------------</p>
 * <p>This class is based off the <code>org.apache.tools.tar</code> package of the <i>Apache Ant</i> project. The Ant
 * code has been modified under the terms of the Apache License which you can find in the bundled muCommander license
 * file. It was forked at version 1.7.1 of Ant.</p>
 * 
 * @author Apache Ant, Maxence Bernard
 */
public class TarInputStream extends InputStream {
    private static final int NAME_BUFFER_SIZE = 256;
    private static final int BYTE_MASK = 0xFF;

    protected boolean debug;
    protected boolean hasHitEOF;
    protected long entrySize;
    protected long entryOffset;
    protected byte[] recordBuf;
    protected byte[] nameBuf;
    protected int recordBufPos;
    protected int recordBufLeft;
    protected TarBuffer buffer;
    protected TarEntry currEntry;
    protected boolean closed;

    /**
     * This contents of this array is not used at all in this class,
     * it is only here to avoid repreated object creation during calls
     * to the no-arg read method.
     */
    protected byte[] oneBuf;

    /**
     * Creates a new <code>TarInputStream</code> over the specified input stream using the default block size and
     * record size and starting at the first entry.
     *
     * @param is the input stream providing the actual TAR data
     * @throws IOException if an error ocurred while initializing the stream
     */
    public TarInputStream(InputStream is) throws IOException  {
        this(is, TarBuffer.DEFAULT_BLKSIZE, TarBuffer.DEFAULT_RCDSIZE, 0);
    }


    /**
     * Creates a new <code>TarInputStream</code> over the specified input stream, starting at the specified
     * entry offset.
     *
     * @param is the input stream providing the actual TAR data
     * @param entryOffset offset from the start of the archive to an entry. Must be a multiple of recordSize, or
     * <code>0</code> to start at the first entry.
     * @throws IOException if an error ocurred while initializing the stream
     */
    public TarInputStream(InputStream is, long entryOffset) throws IOException {
        this(is, TarBuffer.DEFAULT_BLKSIZE, TarBuffer.DEFAULT_RCDSIZE, entryOffset);
    }


    /**
     * Creates a new <code>TarInputStream</code> over the specified input stream, using the specified
     * block size, record size and start offset.
     *
     * @param is the input stream to use
     * @param blockSize the block size to use
     * @param recordSize the record size to use
     * @param entryOffset offset from the start of the archive to an entry. Must be a multiple of recordSize, or
     * <code>0</code> to start at the first entry.
     * @throws IOException if an error ocurred while initializing the stream
     */
    public TarInputStream(InputStream is, int blockSize, int recordSize, long entryOffset) throws IOException {
        this.buffer = new TarBuffer(is, blockSize, recordSize);
        this.recordBuf = BufferPool.getByteArray(buffer.getRecordSize());
        this.nameBuf = BufferPool.getByteArray(NAME_BUFFER_SIZE);
        this.oneBuf = BufferPool.getByteArray(1);
        this.debug = false;
        this.hasHitEOF = false;

        if(entryOffset>0) {
            if((entryOffset%recordSize)!=0)
                throw new IllegalArgumentException("entryOffset ("+entryOffset+") is not a multiple of recordSize ("+recordSize+")");

            skipBytes(entryOffset);
        }

    }

    /**
     * Sets the debugging flag.
     *
     * @param debug True to turn on debugging.
     */
    public void setDebug(boolean debug) {
        this.debug = debug;
        buffer.setDebug(debug);
    }

    /**
     * Closes this stream. Calls the TarBuffer's close() method.
     * @throws IOException on error
     */
    @Override
    public void close() throws IOException {
        if (!closed) {
            try {
                buffer.close();
            }
            finally {
                BufferPool.releaseByteArray(recordBuf);
                BufferPool.releaseByteArray(nameBuf);
                BufferPool.releaseByteArray(oneBuf);

                closed = true;
            }
        }
    }

    /**
     * Get the record size being used by this stream's TarBuffer.
     *
     * @return The TarBuffer record size.
     */
    public int getRecordSize() {
        return buffer.getRecordSize();
    }

    /**
     * Get the available data that can be read from the current
     * entry in the archive. This does not indicate how much data
     * is left in the entire archive, only in the current entry.
     * This value is determined from the entry's size header field
     * and the amount of data already read from the current entry.
     * Integer.MAX_VALUE is returen in case more than Integer.MAX_VALUE
     * bytes are left in the current entry in the archive.
     *
     * @return The number of available bytes for the current entry.
     * @throws IOException for signature
     */
    @Override
    public int available() throws IOException {
        if (entrySize - entryOffset > Integer.MAX_VALUE) {
            return Integer.MAX_VALUE;
        }
        return (int) (entrySize - entryOffset);
    }

    /**
     * Since we do not support marking just yet, we return false.
     *
     * @return False.
     */
    @Override
    public boolean markSupported() {
        return false;
    }

    /**
     * Since we do not support marking just yet, we do nothing.
     *
     * @param markLimit The limit to mark.
     */
    @Override
    public void mark(int markLimit) {
    }

    /**
     * Since we do not support marking just yet, we do nothing.
     */
    @Override
    public void reset() {
    }


    /**
     * Reads a whole new record from the {@link TarBuffer} into the {@link #recordBuf record buffer} and resets
     * {@link #recordBufPos} and {@link #recordBufLeft} fields accordingly.
     *
     * @return <code>true</code> if the record has been read, <code>false</code> if EOF has been reached
     * @throws IOException on error
     */
    public boolean readRecord() throws IOException {
        boolean ret = buffer.readRecord(recordBuf);

        recordBufPos = 0;
        recordBufLeft = ret?recordBuf.length:0;

        return ret;
    }


    /**
     * Returns the current entry where this <code>TarInputStream</code> is currently positionned.
     *
     * @return the current entry where this <code>TarInputStream</code> is currently positionned
     */
    public TarEntry getCurrentEntry() {
        return currEntry;
    }

    /**
     * Get the next entry in this tar archive. This will skip
     * over any remaining data in the current entry, if there
     * is one, and place the input stream at the header of the
     * next entry, and read the header and instantiate a new
     * TarEntry from the header bytes and return that entry.
     * If there are no more entries in the archive, null will
     * be returned to indicate that the end of the archive has
     * been reached.
     *
     * @return The next TarEntry in the archive, or null.
     * @throws IOException on error
     */
    public TarEntry getNextEntry() throws IOException {
        if (hasHitEOF) {
            return null;
        }

        if (currEntry != null) {
            long numToSkip = entrySize - entryOffset;

            if (debug) {
                System.err.println("TarInputStream: SKIP currENTRY '"
                        + currEntry.getName() + "' SZ "
                        + entrySize + " OFF "
                        + entryOffset + "  skipping "
                        + numToSkip + " bytes");
            }

            if (numToSkip > 0) {
                skipBytes(numToSkip);
            }
        }

        // Read the header record
        if (!readRecord()) {
            if (debug) {
                System.err.println("READ NULL RECORD");
            }
            hasHitEOF = true;
        } else if (buffer.isEOFRecord(recordBuf)) {
            if (debug) {
                System.err.println("READ EOF RECORD");
            }
            hasHitEOF = true;
        }

        if (hasHitEOF) {
            currEntry = null;
        } else {
            currEntry = new TarEntry(recordBuf);

            // Offset of the current entry from the start of the archive,
            // allows to reposition the stream at the start of the entry
            currEntry.setOffset(buffer.getCurrentBlockNum()*buffer.getBlockSize()
                               + buffer.getCurrentRecordNum()*buffer.getRecordSize());

            if (debug) {
                System.err.println("TarInputStream: SET CURRENTRY '"
                        + currEntry.getName()
                        + "' size = "
                        + currEntry.getSize());
            }

            // Update the current entry offset and size
            entryOffset = 0;
            entrySize = currEntry.getSize();

            // Consume the rest of the record
            recordBufPos = 0;
            recordBufLeft = 0;
        }

        if (currEntry != null && currEntry.isGNULongNameEntry()) {
            // read in the name
            StringBuffer longName = new StringBuffer();
            int length;
            while ((length = read(nameBuf)) >= 0) {
                longName.append(new String(nameBuf, 0, length));
            }
            getNextEntry();
            if (currEntry == null) {
                // Bugzilla: 40334
                // Malformed tar file - long entry name not followed by entry
                return null;
            }
            // remove trailing null terminator
            if (longName.length() > 0
                && longName.charAt(longName.length() - 1) == 0) {
                longName.deleteCharAt(longName.length() - 1);
            }
            currEntry.setName(longName.toString());
        }

        return currEntry;
    }

    /**
     * Reads a byte from the current tar archive entry.
     *
     * This method simply calls read( byte[], int, int ).
     *
     * @return The byte read, or -1 at EOF.
     * @throws IOException on error
     */
    @Override
    public int read() throws IOException {
        int num = read(oneBuf, 0, 1);
        return num == -1 ? -1 : ((int) oneBuf[0]) & BYTE_MASK;
    }



    /**
     * Reads bytes from the current tar archive entry.
     *
     * This method is aware of the boundaries of the current
     * entry in the archive and will deal with them as if they
     * were this stream's start and EOF.
     *
     * @param buf The buffer into which to place bytes read.
     * @param offset The offset at which to place bytes read.
     * @param numToRead The number of bytes to read.
     * @return The number of bytes read, or -1 at EOF.
     * @throws IOException on error
     */
    @Override
    public int read(byte[] buf, int offset, int numToRead) throws IOException {
        int totalRead = 0;

        // Have we already reached the end of file/entry ?
        if (entryOffset >= entrySize) {
            return -1;
        }

        // Can't read more than the entry's size
        if ((numToRead + entryOffset) > entrySize) {
            numToRead = (int) (entrySize - entryOffset);
        }

        // Read data one record (at most) at a time. The record buffer is first emptied before reading a new record.
        while (numToRead > 0) {
            // If there is no more data left to read from the current record buffer,
            // read a new record  
            if(recordBufLeft<=0) {
                if (!readRecord()) {
                    // Unexpected EOF!
                    throw new EOFException("unexpected EOF with " + numToRead + " bytes unread");
                }
            }

            int sz = (numToRead > recordBufLeft)
                    ? recordBufLeft
                    : numToRead;

            System.arraycopy(recordBuf, recordBufPos, buf, offset, sz);

            recordBufPos += sz;
            recordBufLeft -= sz;

            totalRead += sz;
            numToRead -= sz;
            offset += sz;
            entryOffset += sz;
        }

        return totalRead;
    }

    /**
     * Skip bytes in the input buffer. This skips bytes in the
     * current entry's data, not the entire archive, and will
     * stop at the end of the current entry's data if the number
     * to skip extends beyond that point.
     *
     * @param numToSkip the number of bytes to skip.
     * @return the number actually skipped
     * @throws IOException on error
     */
    @Override
    public long skip(long numToSkip) throws IOException {
        // Have we already reached the end of file/entry ?
        if (entryOffset >= entrySize) {
            return -1;
        }

        // Can't read more than the entry's size
        if ((numToSkip + entryOffset) > entrySize) {
            numToSkip = (int) (entrySize - entryOffset);
        }

        return skipBytes(numToSkip);
    }

    /**
     * Skips the specified number of bytes, without checking for the current entry's boundaries.
     *
     * @param numToSkip the number of bytes to skip.
     * @return the number actually skipped
     * @throws IOException on error
     */
    private long skipBytes(long numToSkip) throws IOException {
        int totalSkipped = 0;

        int recordSize = buffer.getRecordSize();
        int blockSize = buffer.getBlockSize();

        while (numToSkip > 0) {
            // If the record buffer has some data left, empty it
            if(recordBufLeft>0) {
                int sz = (numToSkip > recordBufLeft)
                        ? recordBufLeft
                        : (int)numToSkip;

                recordBufPos += sz;
                recordBufLeft -= sz;

                totalSkipped += sz;
                numToSkip -= sz;
                entryOffset += sz;
            }
            // Skip a whole block if there are enough bytes left to skip, and if we are at the end of the current block
            else if(numToSkip>=blockSize && buffer.getCurrentRecordNum()==buffer.getRecordsPerBlock()-1) {
                if (!buffer.skipBlock()) {
                    // Unexpected EOF!
                    throw new EOFException("unexpected EOF with " + numToSkip + " bytes unskipped");
                }

                totalSkipped += blockSize;
                numToSkip -= blockSize;
                entryOffset += blockSize;
            }
            // Skip a whole record if there are enough bytes left to skip
            else if(numToSkip>=recordSize) {
                if (!buffer.skipRecord()) {
                    // Unexpected EOF!
                    throw new EOFException("unexpected EOF with " + numToSkip + " bytes unskipped");
                }

                totalSkipped += recordSize;
                numToSkip -= recordSize;
                entryOffset += recordSize;
            }
            // There is less than a record to skip -> read the record and skip
            else {
                if (!readRecord()) {
                    // Unexpected EOF!
                    throw new EOFException("unexpected EOF with " + numToSkip + " bytes unskipped");
                }

                // if(recordBufLeft>0) will be matched on the next loop
            }
        }

        return totalSkipped;
    }
}
