/*
 *  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.commons.compress.archivers.arj;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.zip.CRC32;

import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.ArchiveException;
import org.apache.commons.compress.archivers.ArchiveInputStream;
import org.apache.commons.compress.utils.BoundedInputStream;
import org.apache.commons.compress.utils.CRC32VerifyingInputStream;
import org.apache.commons.compress.utils.IOUtils;

/**
 * Implements the "arj" archive format as an InputStream.
 * <p>
 * <a href="https://github.com/FarGroup/FarManager/blob/master/plugins/multiarc/arc.doc/arj.txt">Reference 1</a>
 * <br>
 * <a href="http://www.fileformat.info/format/arj/corion.htm">Reference 2</a>
 * @NotThreadSafe
 * @since 1.6
 */
public class ArjArchiveInputStream extends ArchiveInputStream {
    private static final int ARJ_MAGIC_1 = 0x60;
    private static final int ARJ_MAGIC_2 = 0xEA;
    private final DataInputStream in;
    private final String charsetName;
    private final MainHeader mainHeader;
    private LocalFileHeader currentLocalFileHeader = null;
    private InputStream currentInputStream = null;

    /**
     * Constructs the ArjInputStream, taking ownership of the inputStream that is passed in.
     * @param inputStream the underlying stream, whose ownership is taken
     * @param charsetName the charset used for file names and comments
     *   in the archive. May be {@code null} to use the platform default.
     * @throws ArchiveException if an exception occurs while reading
     */
    public ArjArchiveInputStream(final InputStream inputStream,
            final String charsetName) throws ArchiveException {
        in = new DataInputStream(inputStream);
        this.charsetName = charsetName;
        try {
            mainHeader = readMainHeader();
            if ((mainHeader.arjFlags & MainHeader.Flags.GARBLED) != 0) {
                throw new ArchiveException("Encrypted ARJ files are unsupported");
            }
            if ((mainHeader.arjFlags & MainHeader.Flags.VOLUME) != 0) {
                throw new ArchiveException("Multi-volume ARJ files are unsupported");
            }
        } catch (final IOException ioException) {
            throw new ArchiveException(ioException.getMessage(), ioException);
        }
    }

    /**
     * Constructs the ArjInputStream, taking ownership of the inputStream that is passed in,
     * and using the CP437 character encoding.
     * @param inputStream the underlying stream, whose ownership is taken
     * @throws ArchiveException if an exception occurs while reading
     */
    public ArjArchiveInputStream(final InputStream inputStream)
            throws ArchiveException {
        this(inputStream, "CP437");
    }

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

    private int read8(final DataInputStream dataIn) throws IOException {
        final int value = dataIn.readUnsignedByte();
        count(1);
        return value;
    }

    private int read16(final DataInputStream dataIn) throws IOException {
        final int value = dataIn.readUnsignedShort();
        count(2);
        return Integer.reverseBytes(value) >>> 16;
    }

    private int read32(final DataInputStream dataIn) throws IOException {
        final int value = dataIn.readInt();
        count(4);
        return Integer.reverseBytes(value);
    }

    private String readString(final DataInputStream dataIn) throws IOException {
        try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
            int nextByte;
            while ((nextByte = dataIn.readUnsignedByte()) != 0) {
                buffer.write(nextByte);
            }
            if (charsetName != null) {
                return new String(buffer.toByteArray(), charsetName);
            }
            // intentionally using the default encoding as that's the contract for a null charsetName
            return new String(buffer.toByteArray());
        }
    }

    private void readFully(final DataInputStream dataIn, final byte[] b)
        throws IOException {
        dataIn.readFully(b);
        count(b.length);
    }

    private byte[] readHeader() throws IOException {
        boolean found = false;
        byte[] basicHeaderBytes = null;
        do {
            int first = 0;
            int second = read8(in);
            do {
                first = second;
                second = read8(in);
            } while (first != ARJ_MAGIC_1 && second != ARJ_MAGIC_2);
            final int basicHeaderSize = read16(in);
            if (basicHeaderSize == 0) {
                // end of archive
                return null;
            }
            if (basicHeaderSize <= 2600) {
                basicHeaderBytes = new byte[basicHeaderSize];
                readFully(in, basicHeaderBytes);
                final long basicHeaderCrc32 = read32(in) & 0xFFFFFFFFL;
                final CRC32 crc32 = new CRC32();
                crc32.update(basicHeaderBytes);
                if (basicHeaderCrc32 == crc32.getValue()) {
                    found = true;
                }
            }
        } while (!found);
        return basicHeaderBytes;
    }

    private MainHeader readMainHeader() throws IOException {
        final byte[] basicHeaderBytes = readHeader();
        if (basicHeaderBytes == null) {
            throw new IOException("Archive ends without any headers");
        }
        final DataInputStream basicHeader = new DataInputStream(
                new ByteArrayInputStream(basicHeaderBytes));

        final int firstHeaderSize = basicHeader.readUnsignedByte();
        final byte[] firstHeaderBytes = new byte[firstHeaderSize - 1];
        basicHeader.readFully(firstHeaderBytes);
        final DataInputStream firstHeader = new DataInputStream(
                new ByteArrayInputStream(firstHeaderBytes));

        final MainHeader hdr = new MainHeader();
        hdr.archiverVersionNumber = firstHeader.readUnsignedByte();
        hdr.minVersionToExtract = firstHeader.readUnsignedByte();
        hdr.hostOS = firstHeader.readUnsignedByte();
        hdr.arjFlags = firstHeader.readUnsignedByte();
        hdr.securityVersion = firstHeader.readUnsignedByte();
        hdr.fileType = firstHeader.readUnsignedByte();
        hdr.reserved = firstHeader.readUnsignedByte();
        hdr.dateTimeCreated = read32(firstHeader);
        hdr.dateTimeModified = read32(firstHeader);
        hdr.archiveSize = 0xffffFFFFL & read32(firstHeader);
        hdr.securityEnvelopeFilePosition = read32(firstHeader);
        hdr.fileSpecPosition = read16(firstHeader);
        hdr.securityEnvelopeLength = read16(firstHeader);
        pushedBackBytes(20); // count has already counted them via readFully
        hdr.encryptionVersion = firstHeader.readUnsignedByte();
        hdr.lastChapter = firstHeader.readUnsignedByte();

        if (firstHeaderSize >= 33) {
            hdr.arjProtectionFactor = firstHeader.readUnsignedByte();
            hdr.arjFlags2 = firstHeader.readUnsignedByte();
            firstHeader.readUnsignedByte();
            firstHeader.readUnsignedByte();
        }

        hdr.name = readString(basicHeader);
        hdr.comment = readString(basicHeader);

        final  int extendedHeaderSize = read16(in);
        if (extendedHeaderSize > 0) {
            hdr.extendedHeaderBytes = new byte[extendedHeaderSize];
            readFully(in, hdr.extendedHeaderBytes);
            final long extendedHeaderCrc32 = 0xffffFFFFL & read32(in);
            final CRC32 crc32 = new CRC32();
            crc32.update(hdr.extendedHeaderBytes);
            if (extendedHeaderCrc32 != crc32.getValue()) {
                throw new IOException("Extended header CRC32 verification failure");
            }
        }

        return hdr;
    }

    private LocalFileHeader readLocalFileHeader() throws IOException {
        final byte[] basicHeaderBytes = readHeader();
        if (basicHeaderBytes == null) {
            return null;
        }
        try (final DataInputStream basicHeader = new DataInputStream(new ByteArrayInputStream(basicHeaderBytes))) {

            final int firstHeaderSize = basicHeader.readUnsignedByte();
            final byte[] firstHeaderBytes = new byte[firstHeaderSize - 1];
            basicHeader.readFully(firstHeaderBytes);
            try (final DataInputStream firstHeader = new DataInputStream(new ByteArrayInputStream(firstHeaderBytes))) {

                final LocalFileHeader localFileHeader = new LocalFileHeader();
                localFileHeader.archiverVersionNumber = firstHeader.readUnsignedByte();
                localFileHeader.minVersionToExtract = firstHeader.readUnsignedByte();
                localFileHeader.hostOS = firstHeader.readUnsignedByte();
                localFileHeader.arjFlags = firstHeader.readUnsignedByte();
                localFileHeader.method = firstHeader.readUnsignedByte();
                localFileHeader.fileType = firstHeader.readUnsignedByte();
                localFileHeader.reserved = firstHeader.readUnsignedByte();
                localFileHeader.dateTimeModified = read32(firstHeader);
                localFileHeader.compressedSize = 0xffffFFFFL & read32(firstHeader);
                localFileHeader.originalSize = 0xffffFFFFL & read32(firstHeader);
                localFileHeader.originalCrc32 = 0xffffFFFFL & read32(firstHeader);
                localFileHeader.fileSpecPosition = read16(firstHeader);
                localFileHeader.fileAccessMode = read16(firstHeader);
                pushedBackBytes(20);
                localFileHeader.firstChapter = firstHeader.readUnsignedByte();
                localFileHeader.lastChapter = firstHeader.readUnsignedByte();

                readExtraData(firstHeaderSize, firstHeader, localFileHeader);

                localFileHeader.name = readString(basicHeader);
                localFileHeader.comment = readString(basicHeader);

                final ArrayList<byte[]> extendedHeaders = new ArrayList<>();
                int extendedHeaderSize;
                while ((extendedHeaderSize = read16(in)) > 0) {
                    final byte[] extendedHeaderBytes = new byte[extendedHeaderSize];
                    readFully(in, extendedHeaderBytes);
                    final long extendedHeaderCrc32 = 0xffffFFFFL & read32(in);
                    final CRC32 crc32 = new CRC32();
                    crc32.update(extendedHeaderBytes);
                    if (extendedHeaderCrc32 != crc32.getValue()) {
                        throw new IOException("Extended header CRC32 verification failure");
                    }
                    extendedHeaders.add(extendedHeaderBytes);
                }
                localFileHeader.extendedHeaders = extendedHeaders.toArray(new byte[0][]);

                return localFileHeader;
            }
        }
    }

    private void readExtraData(final int firstHeaderSize, final DataInputStream firstHeader,
                               final LocalFileHeader localFileHeader) throws IOException {
        if (firstHeaderSize >= 33) {
            localFileHeader.extendedFilePosition = read32(firstHeader);
            if (firstHeaderSize >= 45) {
                localFileHeader.dateTimeAccessed = read32(firstHeader);
                localFileHeader.dateTimeCreated = read32(firstHeader);
                localFileHeader.originalSizeEvenForVolumes = read32(firstHeader);
                pushedBackBytes(12);
            }
            pushedBackBytes(4);
        }
    }

    /**
     * Checks if the signature matches what is expected for an arj file.
     *
     * @param signature
     *            the bytes to check
     * @param length
     *            the number of bytes to check
     * @return true, if this stream is an arj archive stream, false otherwise
     */
    public static boolean matches(final byte[] signature, final int length) {
        return length >= 2 &&
                (0xff & signature[0]) == ARJ_MAGIC_1 &&
                (0xff & signature[1]) == ARJ_MAGIC_2;
    }

    /**
     * Gets the archive's recorded name.
     * @return the archive's name
     */
    public String getArchiveName() {
        return mainHeader.name;
    }

    /**
     * Gets the archive's comment.
     * @return the archive's comment
     */
    public String getArchiveComment() {
        return mainHeader.comment;
    }

    @Override
    public ArjArchiveEntry getNextEntry() throws IOException {
        if (currentInputStream != null) {
            // return value ignored as IOUtils.skip ensures the stream is drained completely
            IOUtils.skip(currentInputStream, Long.MAX_VALUE);
            currentInputStream.close();
            currentLocalFileHeader = null;
            currentInputStream = null;
        }

        currentLocalFileHeader = readLocalFileHeader();
        if (currentLocalFileHeader != null) {
            currentInputStream = new BoundedInputStream(in, currentLocalFileHeader.compressedSize);
            if (currentLocalFileHeader.method == LocalFileHeader.Methods.STORED) {
                currentInputStream = new CRC32VerifyingInputStream(currentInputStream,
                        currentLocalFileHeader.originalSize, currentLocalFileHeader.originalCrc32);
            }
            return new ArjArchiveEntry(currentLocalFileHeader);
        }
        currentInputStream = null;
        return null;
    }

    @Override
    public boolean canReadEntryData(final ArchiveEntry ae) {
        return ae instanceof ArjArchiveEntry
            && ((ArjArchiveEntry) ae).getMethod() == LocalFileHeader.Methods.STORED;
    }

    @Override
    public int read(final byte[] b, final int off, final int len) throws IOException {
        if (len == 0) {
            return 0;
        }
        if (currentLocalFileHeader == null) {
            throw new IllegalStateException("No current arj entry");
        }
        if (currentLocalFileHeader.method != LocalFileHeader.Methods.STORED) {
            throw new IOException("Unsupported compression method " + currentLocalFileHeader.method);
        }
        return currentInputStream.read(b, off, len);
    }
}
