<?php

namespace PhpZip\Stream;

use PhpZip\Crypto\TraditionalPkwareEncryptionEngine;
use PhpZip\Crypto\WinZipAesEngine;
use PhpZip\Exception\Crc32Exception;
use PhpZip\Exception\InvalidArgumentException;
use PhpZip\Exception\RuntimeException;
use PhpZip\Exception\ZipCryptoException;
use PhpZip\Exception\ZipException;
use PhpZip\Exception\ZipUnsupportMethod;
use PhpZip\Extra\ExtraFieldsCollection;
use PhpZip\Extra\ExtraFieldsFactory;
use PhpZip\Extra\Fields\ApkAlignmentExtraField;
use PhpZip\Extra\Fields\WinZipAesEntryExtraField;
use PhpZip\Mapper\OffsetPositionMapper;
use PhpZip\Mapper\PositionMapper;
use PhpZip\Model\EndOfCentralDirectory;
use PhpZip\Model\Entry\ZipSourceEntry;
use PhpZip\Model\ZipEntry;
use PhpZip\Model\ZipModel;
use PhpZip\Util\PackUtil;
use PhpZip\Util\StringUtil;
use PhpZip\ZipFileInterface;

/**
 * Read zip file
 *
 * @author Ne-Lexa alexey@nelexa.ru
 * @license MIT
 */
class ZipInputStream implements ZipInputStreamInterface
{
    /**
     * @var resource
     */
    protected $in;
    /**
     * @var PositionMapper
     */
    protected $mapper;
    /**
     * @var int The number of bytes in the preamble of this ZIP file.
     */
    protected $preamble = 0;
    /**
     * @var int The number of bytes in the postamble of this ZIP file.
     */
    protected $postamble = 0;
    /**
     * @var ZipModel
     */
    protected $zipModel;

    /**
     * ZipInputStream constructor.
     * @param resource $in
     * @throws RuntimeException
     */
    public function __construct($in)
    {
        if (!is_resource($in)) {
            throw new RuntimeException('$in must be resource');
        }
        $this->in = $in;
        $this->mapper = new PositionMapper();
    }

    /**
     * @return ZipModel
     */
    public function readZip()
    {
        $this->checkZipFileSignature();
        $endOfCentralDirectory = $this->readEndOfCentralDirectory();
        $entries = $this->mountCentralDirectory($endOfCentralDirectory);
        $this->zipModel = ZipModel::newSourceModel($entries, $endOfCentralDirectory);
        return $this->zipModel;
    }

    /**
     * Check zip file signature
     *
     * @throws ZipException if this not .ZIP file.
     */
    protected function checkZipFileSignature()
    {
        rewind($this->in);
        // Constraint: A ZIP file must start with a Local File Header
        // or a (ZIP64) End Of Central Directory Record if it's empty.
        $signatureBytes = fread($this->in, 4);
        if (strlen($signatureBytes) < 4) {
            throw new ZipException("Invalid zip file.");
        }
        $signature = unpack('V', $signatureBytes)[1];
        if (
            ZipEntry::LOCAL_FILE_HEADER_SIG !== $signature
            && EndOfCentralDirectory::ZIP64_END_OF_CENTRAL_DIRECTORY_RECORD_SIG !== $signature
            && EndOfCentralDirectory::END_OF_CENTRAL_DIRECTORY_RECORD_SIG !== $signature
        ) {
            throw new ZipException("Expected Local File Header or (ZIP64) End Of Central Directory Record! Signature: " . $signature);
        }
    }

    /**
     * @return EndOfCentralDirectory
     * @throws ZipException
     */
    protected function readEndOfCentralDirectory()
    {
        $comment = null;
        // Search for End of central directory record.
        $stats = fstat($this->in);
        $size = $stats['size'];
        $max = $size - EndOfCentralDirectory::END_OF_CENTRAL_DIRECTORY_RECORD_MIN_LEN;
        $min = $max >= 0xffff ? $max - 0xffff : 0;
        for ($endOfCentralDirRecordPos = $max; $endOfCentralDirRecordPos >= $min; $endOfCentralDirRecordPos--) {
            fseek($this->in, $endOfCentralDirRecordPos, SEEK_SET);
            // end of central dir signature    4 bytes  (0x06054b50)
            if (EndOfCentralDirectory::END_OF_CENTRAL_DIRECTORY_RECORD_SIG !== unpack('V', fread($this->in, 4))[1]) {
                continue;
            }

            // number of this disk                        - 2 bytes
            // number of the disk with the start of the
            //        central directory                   - 2 bytes
            // total number of entries in the central
            //        directory on this disk              - 2 bytes
            // total number of entries in the central
            //        directory                           - 2 bytes
            // size of the central directory              - 4 bytes
            // offset of start of central directory with
            //        respect to the starting disk number - 4 bytes
            // ZIP file comment length                    - 2 bytes
            $data = unpack(
                'vdiskNo/vcdDiskNo/vcdEntriesDisk/vcdEntries/VcdSize/VcdPos/vcommentLength',
                fread($this->in, 18)
            );

            if (0 !== $data['diskNo'] || 0 !== $data['cdDiskNo'] || $data['cdEntriesDisk'] !== $data['cdEntries']) {
                throw new ZipException(
                    "ZIP file spanning/splitting is not supported!"
                );
            }
            // .ZIP file comment       (variable size)
            if (0 < $data['commentLength']) {
                $comment = fread($this->in, $data['commentLength']);
            }
            $this->preamble = $endOfCentralDirRecordPos;
            $this->postamble = $size - ftell($this->in);

            // Check for ZIP64 End Of Central Directory Locator.
            $endOfCentralDirLocatorPos = $endOfCentralDirRecordPos - EndOfCentralDirectory::ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_LEN;

            fseek($this->in, $endOfCentralDirLocatorPos, SEEK_SET);
            // zip64 end of central dir locator
            // signature                       4 bytes  (0x07064b50)
            if (
                0 > $endOfCentralDirLocatorPos ||
                ftell($this->in) === $size ||
                EndOfCentralDirectory::ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIG !== unpack('V', fread($this->in, 4))[1]
            ) {
                // Seek and check first CFH, probably requiring an offset mapper.
                $offset = $endOfCentralDirRecordPos - $data['cdSize'];
                fseek($this->in, $offset, SEEK_SET);
                $offset -= $data['cdPos'];
                if (0 !== $offset) {
                    $this->mapper = new OffsetPositionMapper($offset);
                }
                $entryCount = $data['cdEntries'];
                return new EndOfCentralDirectory($entryCount, $comment);
            }

            // number of the disk with the
            // start of the zip64 end of
            // central directory               4 bytes
            $zip64EndOfCentralDirectoryRecordDisk = unpack('V', fread($this->in, 4))[1];
            // relative offset of the zip64
            // end of central directory record 8 bytes
            $zip64EndOfCentralDirectoryRecordPos = PackUtil::unpackLongLE(fread($this->in, 8));
            // total number of disks           4 bytes
            $totalDisks = unpack('V', fread($this->in, 4))[1];
            if (0 !== $zip64EndOfCentralDirectoryRecordDisk || 1 !== $totalDisks) {
                throw new ZipException("ZIP file spanning/splitting is not supported!");
            }
            fseek($this->in, $zip64EndOfCentralDirectoryRecordPos, SEEK_SET);
            // zip64 end of central dir
            // signature                       4 bytes  (0x06064b50)
            $zip64EndOfCentralDirSig = unpack('V', fread($this->in, 4))[1];
            if (EndOfCentralDirectory::ZIP64_END_OF_CENTRAL_DIRECTORY_RECORD_SIG !== $zip64EndOfCentralDirSig) {
                throw new ZipException("Expected ZIP64 End Of Central Directory Record!");
            }
            // size of zip64 end of central
            // directory record                8 bytes
            // version made by                 2 bytes
            // version needed to extract       2 bytes
            fseek($this->in, 12, SEEK_CUR);
            // number of this disk             4 bytes
            $diskNo = unpack('V', fread($this->in, 4))[1];
            // number of the disk with the
            // start of the central directory  4 bytes
            $cdDiskNo = unpack('V', fread($this->in, 4))[1];
            // total number of entries in the
            // central directory on this disk  8 bytes
            $cdEntriesDisk = PackUtil::unpackLongLE(fread($this->in, 8));
            // total number of entries in the
            // central directory               8 bytes
            $cdEntries = PackUtil::unpackLongLE(fread($this->in, 8));
            if (0 !== $diskNo || 0 !== $cdDiskNo || $cdEntriesDisk !== $cdEntries) {
                throw new ZipException("ZIP file spanning/splitting is not supported!");
            }
            if ($cdEntries < 0 || 0x7fffffff < $cdEntries) {
                throw new ZipException("Total Number Of Entries In The Central Directory out of range!");
            }
            // size of the central directory   8 bytes
            fseek($this->in, 8, SEEK_CUR);
            // offset of start of central
            // directory with respect to
            // the starting disk number        8 bytes
            $cdPos = PackUtil::unpackLongLE(fread($this->in, 8));
            // zip64 extensible data sector    (variable size)
            fseek($this->in, $cdPos, SEEK_SET);
            $this->preamble = $zip64EndOfCentralDirectoryRecordPos;
            $entryCount = $cdEntries;
            $zip64 = true;
            return new EndOfCentralDirectory($entryCount, $comment, $zip64);
        }
        // Start recovering file entries from min.
        $this->preamble = $min;
        $this->postamble = $size - $min;
        return new EndOfCentralDirectory(0, $comment);
    }

    /**
     * Reads the central directory from the given seekable byte channel
     * and populates the internal tables with ZipEntry instances.
     *
     * The ZipEntry's will know all data that can be obtained from the
     * central directory alone, but not the data that requires the local
     * file header or additional data to be read.
     *
     * @param EndOfCentralDirectory $endOfCentralDirectory
     * @return ZipEntry[]
     * @throws ZipException
     */
    protected function mountCentralDirectory(EndOfCentralDirectory $endOfCentralDirectory)
    {
        $numEntries = $endOfCentralDirectory->getEntryCount();
        $entries = [];

        for (; $numEntries > 0; $numEntries--) {
            $entry = $this->readEntry();
            // Re-load virtual offset after ZIP64 Extended Information
            // Extra Field may have been parsed, map it to the real
            // offset and conditionally update the preamble size from it.
            $lfhOff = $this->mapper->map($entry->getOffset());
            $lfhOff = PHP_INT_SIZE === 4 ? sprintf('%u', $lfhOff) : $lfhOff;
            if ($lfhOff < $this->preamble) {
                $this->preamble = $lfhOff;
            }
            $entries[$entry->getName()] = $entry;
        }

        if (0 !== $numEntries % 0x10000) {
            throw new ZipException("Expected " . abs($numEntries) .
                ($numEntries > 0 ? " more" : " less") .
                " entries in the Central Directory!");
        }

        if ($this->preamble + $this->postamble >= fstat($this->in)['size']) {
            assert(0 === $numEntries);
            $this->checkZipFileSignature();
        }

        return $entries;
    }

    /**
     * @return ZipEntry
     * @throws InvalidArgumentException
     */
    public function readEntry()
    {
        // central file header signature   4 bytes  (0x02014b50)
        $fileHeaderSig = unpack('V', fread($this->in, 4))[1];
        if (ZipOutputStreamInterface::CENTRAL_FILE_HEADER_SIG !== $fileHeaderSig) {
            throw new InvalidArgumentException("Corrupt zip file. Can not read zip entry.");
        }

        // version made by                 2 bytes
        // version needed to extract       2 bytes
        // general purpose bit flag        2 bytes
        // compression method              2 bytes
        // last mod file time              2 bytes
        // last mod file date              2 bytes
        // crc-32                          4 bytes
        // compressed size                 4 bytes
        // uncompressed size               4 bytes
        // file name length                2 bytes
        // extra field length              2 bytes
        // file comment length             2 bytes
        // disk number start               2 bytes
        // internal file attributes        2 bytes
        // external file attributes        4 bytes
        // relative offset of local header 4 bytes
        $data = unpack(
            'vversionMadeBy/vversionNeededToExtract/vgpbf/' .
            'vrawMethod/VrawTime/VrawCrc/VrawCompressedSize/' .
            'VrawSize/vfileLength/vextraLength/vcommentLength/' .
            'VrawInternalAttributes/VrawExternalAttributes/VlfhOff',
            fread($this->in, 42)
        );

//        $utf8 = 0 !== ($data['gpbf'] & self::GPBF_UTF8);

        // See appendix D of PKWARE's ZIP File Format Specification.
        $name = fread($this->in, $data['fileLength']);

        $entry = new ZipSourceEntry($this);
        $entry->setName($name);
        $entry->setVersionNeededToExtract($data['versionNeededToExtract']);
        $entry->setPlatform($data['versionMadeBy'] >> 8);
        $entry->setMethod($data['rawMethod']);
        $entry->setGeneralPurposeBitFlags($data['gpbf']);
        $entry->setDosTime($data['rawTime']);
        $entry->setCrc($data['rawCrc']);
        $entry->setCompressedSize($data['rawCompressedSize']);
        $entry->setSize($data['rawSize']);
        $entry->setExternalAttributes($data['rawExternalAttributes']);
        $entry->setOffset($data['lfhOff']); // must be unmapped!
        if (0 < $data['extraLength']) {
            $entry->setExtra(fread($this->in, $data['extraLength']));
        }
        if (0 < $data['commentLength']) {
            $entry->setComment(fread($this->in, $data['commentLength']));
        }
        return $entry;
    }

    /**
     * @param ZipEntry $entry
     * @return string
     * @throws ZipException
     */
    public function readEntryContent(ZipEntry $entry)
    {
        if ($entry->isDirectory()) {
            return null;
        }
        if (!($entry instanceof ZipSourceEntry)) {
            throw new InvalidArgumentException('entry must be ' . ZipSourceEntry::class);
        }
        $isEncrypted = $entry->isEncrypted();
        if ($isEncrypted && null === $entry->getPassword()) {
            throw new ZipException("Can not password from entry " . $entry->getName());
        }

        $pos = $entry->getOffset();
        assert(ZipEntry::UNKNOWN !== $pos);
        $pos = PHP_INT_SIZE === 4 ? sprintf('%u', $pos) : $pos;

        $startPos = $pos = $this->mapper->map($pos);
        fseek($this->in, $startPos);

        // local file header signature     4 bytes  (0x04034b50)
        if (ZipEntry::LOCAL_FILE_HEADER_SIG !== unpack('V', fread($this->in, 4))[1]) {
            throw new ZipException($entry->getName() . " (expected Local File Header)");
        }
        fseek($this->in, $pos + ZipEntry::LOCAL_FILE_HEADER_FILE_NAME_LENGTH_POS);
        // file name length                2 bytes
        // extra field length              2 bytes
        $data = unpack('vfileLength/vextraLength', fread($this->in, 4));
        $pos += ZipEntry::LOCAL_FILE_HEADER_MIN_LEN + $data['fileLength'] + $data['extraLength'];

        assert(ZipEntry::UNKNOWN !== $entry->getCrc());

        $method = $entry->getMethod();

        fseek($this->in, $pos);

        // Get raw entry content
        $compressedSize = $entry->getCompressedSize();
        $compressedSize = PHP_INT_SIZE === 4 ? sprintf('%u', $compressedSize) : $compressedSize;
        if ($compressedSize > 0) {
            $content = fread($this->in, $compressedSize);
        } else {
            $content = '';
        }

        $skipCheckCrc = false;
        if ($isEncrypted) {
            if (ZipEntry::METHOD_WINZIP_AES === $method) {
                // Strong Encryption Specification - WinZip AES
                $winZipAesEngine = new WinZipAesEngine($entry);
                $content = $winZipAesEngine->decrypt($content);
                /**
                 * @var WinZipAesEntryExtraField $field
                 */
                $field = $entry->getExtraFieldsCollection()->get(WinZipAesEntryExtraField::getHeaderId());
                $method = $field->getMethod();
                $entry->setEncryptionMethod($field->getEncryptionMethod());
                $skipCheckCrc = true;
            } else {
                // Traditional PKWARE Decryption
                $zipCryptoEngine = new TraditionalPkwareEncryptionEngine($entry);
                $content = $zipCryptoEngine->decrypt($content);
                $entry->setEncryptionMethod(ZipFileInterface::ENCRYPTION_METHOD_TRADITIONAL);
            }

            if (!$skipCheckCrc) {
                // Check CRC32 in the Local File Header or Data Descriptor.
                $localCrc = null;
                if ($entry->getGeneralPurposeBitFlag(ZipEntry::GPBF_DATA_DESCRIPTOR)) {
                    // The CRC32 is in the Data Descriptor after the compressed size.
                    // Note the Data Descriptor's Signature is optional:
                    // All newer apps should write it (and so does TrueVFS),
                    // but older apps might not.
                    fseek($this->in, $pos + $compressedSize);
                    $localCrc = unpack('V', fread($this->in, 4))[1];
                    if (ZipEntry::DATA_DESCRIPTOR_SIG === $localCrc) {
                        $localCrc = unpack('V', fread($this->in, 4))[1];
                    }
                } else {
                    fseek($this->in, $startPos + 14);
                    // The CRC32 in the Local File Header.
                    $localCrc = sprintf('%u', fread($this->in, 4)[1]);
                    $localCrc = PHP_INT_SIZE === 4 ? sprintf('%u', $localCrc) : $localCrc;
                }

                $crc = PHP_INT_SIZE === 4 ? sprintf('%u', $entry->getCrc()) : $entry->getCrc();

                if ($crc != $localCrc) {
                    throw new Crc32Exception($entry->getName(), $crc, $localCrc);
                }
            }
        }

        switch ($method) {
            case ZipFileInterface::METHOD_STORED:
                break;
            case ZipFileInterface::METHOD_DEFLATED:
                $content = gzinflate($content);
                break;
            case ZipFileInterface::METHOD_BZIP2:
                if (!extension_loaded('bz2')) {
                    throw new ZipException('Extension bzip2 not install');
                }
                $content = bzdecompress($content);
                break;
            default:
                throw new ZipUnsupportMethod($entry->getName() .
                    " (compression method " . $method . " is not supported)");
        }
        if (!$skipCheckCrc) {
            $localCrc = crc32($content);
            $localCrc = PHP_INT_SIZE === 4 ? sprintf('%u', $localCrc) : $localCrc;
            $crc = PHP_INT_SIZE === 4 ? sprintf('%u', $entry->getCrc()) : $entry->getCrc();
            if ($crc != $localCrc) {
                if ($isEncrypted) {
                    throw new ZipCryptoException("Wrong password");
                }
                throw new Crc32Exception($entry->getName(), $crc, $localCrc);
            }
        }
        return $content;
    }

    /**
     * @return resource
     */
    public function getStream()
    {
        return $this->in;
    }

    /**
     * Copy the input stream of the LOC entry zip and the data into
     * the output stream and zip the alignment if necessary.
     *
     * @param ZipEntry $entry
     * @param ZipOutputStreamInterface $out
     */
    public function copyEntry(ZipEntry $entry, ZipOutputStreamInterface $out)
    {
        $pos = $entry->getOffset();
        assert(ZipEntry::UNKNOWN !== $pos);
        $pos = PHP_INT_SIZE === 4 ? sprintf('%u', $pos) : $pos;
        $pos = $this->mapper->map($pos);

        $nameLength = strlen($entry->getName());

        fseek($this->in, $pos + ZipEntry::LOCAL_FILE_HEADER_MIN_LEN - 2, SEEK_SET);
        $sourceExtraLength = $destExtraLength = unpack('v', fread($this->in, 2))[1];

        if ($sourceExtraLength > 0) {
            // read Local File Header extra fields
            fseek($this->in, $pos + ZipEntry::LOCAL_FILE_HEADER_MIN_LEN + $nameLength, SEEK_SET);
            $extra = fread($this->in, $sourceExtraLength);
            $extraFieldsCollection = ExtraFieldsFactory::createExtraFieldCollections($extra, $entry);
            if (isset($extraFieldsCollection[ApkAlignmentExtraField::getHeaderId()]) && $this->zipModel->isZipAlign()) {
                unset($extraFieldsCollection[ApkAlignmentExtraField::getHeaderId()]);
                $destExtraLength = strlen(ExtraFieldsFactory::createSerializedData($extraFieldsCollection));
            }
        } else {
            $extraFieldsCollection = new ExtraFieldsCollection();
        }

        $dataAlignmentMultiple = $this->zipModel->getZipAlign();
        $copyInToOutLength = $entry->getCompressedSize();

        fseek($this->in, $pos, SEEK_SET);

        if (
            $this->zipModel->isZipAlign() &&
            !$entry->isEncrypted() &&
            $entry->getMethod() === ZipFileInterface::METHOD_STORED
        ) {
            if (StringUtil::endsWith($entry->getName(), '.so')) {
                $dataAlignmentMultiple = ApkAlignmentExtraField::ANDROID_COMMON_PAGE_ALIGNMENT_BYTES;
            }

            $dataMinStartOffset =
                ftell($out->getStream()) +
                ZipEntry::LOCAL_FILE_HEADER_MIN_LEN +
                $destExtraLength +
                $nameLength +
                ApkAlignmentExtraField::ALIGNMENT_ZIP_EXTRA_MIN_SIZE_BYTES;
            $padding =
                ($dataAlignmentMultiple - ($dataMinStartOffset % $dataAlignmentMultiple))
                % $dataAlignmentMultiple;

            $alignExtra = new ApkAlignmentExtraField();
            $alignExtra->setMultiple($dataAlignmentMultiple);
            $alignExtra->setPadding($padding);
            $extraFieldsCollection->add($alignExtra);

            $extra = ExtraFieldsFactory::createSerializedData($extraFieldsCollection);

            // copy Local File Header without extra field length
            // from input stream to output stream
            stream_copy_to_stream($this->in, $out->getStream(), ZipEntry::LOCAL_FILE_HEADER_MIN_LEN - 2);
            // write new extra field length (2 bytes) to output stream
            fwrite($out->getStream(), pack('v', strlen($extra)));
            // skip 2 bytes to input stream
            fseek($this->in, 2, SEEK_CUR);
            // copy name from input stream to output stream
            stream_copy_to_stream($this->in, $out->getStream(), $nameLength);
            // write extra field to output stream
            fwrite($out->getStream(), $extra);
            // skip source extraLength from input stream
            fseek($this->in, $sourceExtraLength, SEEK_CUR);
        } else {
            $copyInToOutLength += ZipEntry::LOCAL_FILE_HEADER_MIN_LEN + $sourceExtraLength + $nameLength;
            ;
        }
        if ($entry->getGeneralPurposeBitFlag(ZipEntry::GPBF_DATA_DESCRIPTOR)) {
//            crc-32                          4 bytes
//            compressed size                 4 bytes
//            uncompressed size               4 bytes
            $copyInToOutLength += 12;
            if ($entry->isZip64ExtensionsRequired()) {
//              compressed size                 +4 bytes
//              uncompressed size               +4 bytes
                $copyInToOutLength += 8;
            }
        }
        // copy loc, data, data descriptor from input to output stream
        stream_copy_to_stream($this->in, $out->getStream(), $copyInToOutLength);
    }

    /**
     * @param ZipEntry $entry
     * @param ZipOutputStreamInterface $out
     */
    public function copyEntryData(ZipEntry $entry, ZipOutputStreamInterface $out)
    {
        $offset = $entry->getOffset();
        $offset = PHP_INT_SIZE === 4 ? sprintf('%u', $offset) : $offset;
        $offset = $this->mapper->map($offset);
        $nameLength = strlen($entry->getName());

        fseek($this->in, $offset + ZipEntry::LOCAL_FILE_HEADER_MIN_LEN - 2, SEEK_SET);
        $extraLength = unpack('v', fread($this->in, 2))[1];

        fseek($this->in, $offset + ZipEntry::LOCAL_FILE_HEADER_MIN_LEN + $nameLength + $extraLength, SEEK_SET);
        // copy raw data from input stream to output stream
        stream_copy_to_stream($this->in, $out->getStream(), $entry->getCompressedSize());
    }

    public function __destruct()
    {
        $this->close();
    }

    public function close()
    {
        if ($this->in != null) {
            fclose($this->in);
            $this->in = null;
        }
    }
}
