/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2022-2024. All rights reserved.
 */
package zip4cj.io.inputstream

public class ZipInputStream <: InputStream & Resource {
    private var inputStream: PushbackInputStream
    private var decompressedInputStream: ?DecompressedInputStream = None
    private var headerReader: HeaderReader = HeaderReader()
    private var password: ?Array<Rune>
    private var passwordCallback: ?PasswordCallback
    private var localFileHeader: ?LocalFileHeader = None
    private var crc32: CRC32 = CRC32()
    private var endOfEntryBuffer: Array<Byte> = InternalZipConstants.NULL_BYTE_ARRAY
    private var canSkipExtendedLocalFileHeader: Bool = false
    private var zip4cjConfig: Zip4cjConfig
    private var streamClosed: Bool = false
    private var entryEOFReached: Bool = false

    public init(
        inputStream: InputStream,
        password!: ?Array<Rune> = None, 
        passwordCallback!: ?PasswordCallback = None ,
        config!: Zip4cjConfig = Zip4cjConfig(InternalZipConstants.BUFF_SIZE, InternalZipConstants.USE_UTF8_FOR_PASSWORD_ENCODING_DECODING)
    ) {
        if (config.getBufferSize() < InternalZipConstants.MIN_BUFF_SIZE) {
            throw IllegalArgumentException(
                "Buffer size cannot be less than  ${InternalZipConstants.MIN_BUFF_SIZE} bytes")
        }

        this.inputStream = PushbackInputStream(inputStream, Int64(config.getBufferSize()))
        this.password = password
        this.passwordCallback = passwordCallback
        this.zip4cjConfig = config
    }

    public func getNextEntry(fileHeader: ?FileHeader, readUntilEndOfCurrentEntryIfOpen: Bool): ?LocalFileHeader {
        if (!localFileHeader.isNone() && readUntilEndOfCurrentEntryIfOpen) {
            readUntilEndOfEntry()
        }

        localFileHeader = headerReader.readLocalFileHeader(inputStream)

        if (localFileHeader.isNone()) {
            return None
        }
        let localFileHeaderV = localFileHeader.getOrThrow()
        if (localFileHeaderV.isEncrypted() && password.isNone() && passwordCallback.isSome()) {
            setPassword(passwordCallback.getOrThrow().getPassword())
        }

        verifyLocalFileHeader(localFileHeaderV)

        if (let Some(fileHeaderV) <- fileHeader) {
            localFileHeaderV.setCrc(fileHeaderV.getCrc())
            localFileHeaderV.setCompressedSize(fileHeaderV.getCompressedSize())
            localFileHeaderV.setUncompressedSize(fileHeaderV.getUncompressedSize())
            // file header's directory flag is more reliable than local file header's directory flag as file header has
            // additional external file attributes which has a directory flag defined. In local file header, the only way
            // to determine if an entry is directory is to check if the file name has a trailing forward slash "/"
            localFileHeaderV.setDirectory(fileHeaderV.isDirectory())
            canSkipExtendedLocalFileHeader = true
        } else {
            canSkipExtendedLocalFileHeader = false
        }

        this.decompressedInputStream = initializeEntryInputStream(localFileHeaderV)
        this.entryEOFReached = false
        return localFileHeader
    }

    public func read(b: Array<Byte>): Int64 {
        if (b.size == 0) {
            return -1
        }
        if (streamClosed) {
            throw ZipIOException("Stream closed")
        }
        if (localFileHeader.isNone()) {
            // localfileheader can be null when end of compressed data is reached.  If null check is missing, read method will
            // throw a NPE when end of compressed data is reached and read is called again.
            return -1
        }

        try {
            var readLen = decompressedInputStream.getOrThrow().read(b)
            if (readLen > 0) {
                crc32.update(b[0..readLen])
            } else if (readLen <= 0) {
                endOfCompressedDataReached()
            }
            return readLen
        } catch (e: IOException | ZipIOException) {
            if (isEncryptionMethodZipStandard(localFileHeader.getOrThrow())) {
                throw ZipException(e.message)
            }
            throw e
        }
    }

    private func endOfCompressedDataReached () {
        var numberOfBytesPushedBack = decompressedInputStream.getOrThrow().pushBackInputStreamIfNecessary(inputStream)
        //First signal the end of data for this entry so that ciphers can read any header data if applicable
        decompressedInputStream?.endOfEntryReached(inputStream, numberOfBytesPushedBack)
        readExtendedLocalFileHeaderIfPresent()
        verifyCrc()
        resetFields()
        this.entryEOFReached = true
    }

    public func isClosed(): Bool {
        streamClosed
    }

    public func close(): Unit {
        if (streamClosed) {
            return
        }
        decompressedInputStream?.close()
        this.streamClosed = true
    }

    public func available(): Int64 {
        assertStreamOpen()
        if (entryEOFReached) {
            return 0
        }
        return 1
    }

    public func setPassword(password: ?Array<Rune>): Unit {
        this.password = password
    }

    private func initializeEntryInputStream(localFileHeader: LocalFileHeader): DecompressedInputStream {
        var zipEntryInputStream: ZipEntryInputStream = ZipEntryInputStream(
            inputStream,
            getCompressedSize(localFileHeader)
        )
        var cipherInputStream: ICipherInputStream = initializeCipherInputStream(
            zipEntryInputStream,
            localFileHeader
        )
        return initializeDecompressorForThisEntry(cipherInputStream, localFileHeader)
    }

    private func initializeCipherInputStream(
        zipEntryInputStream: ZipEntryInputStream,
        localFileHeader: LocalFileHeader
    ): ICipherInputStream {
        if (!localFileHeader.isEncrypted()) {
            return NoCipherInputStream(zipEntryInputStream, localFileHeader, password,
                Int64(zip4cjConfig.getBufferSize()))
        }

        if (localFileHeader.getEncryptionMethod() == EncryptionMethod.AES) {
            return AesCipherInputStream(zipEntryInputStream, localFileHeader, password, zip4cjConfig.getBufferSize(),
              zip4cjConfig.isUseUtf8CharsetForPasswords())
        } else if (localFileHeader.getEncryptionMethod() == EncryptionMethod.ZIP_STANDARD) {
            return ZipStandardCipherInputStream(zipEntryInputStream, localFileHeader, password,
                Int64(zip4cjConfig.getBufferSize()), zip4cjConfig.isUseUtf8CharsetForPasswords())
        } else {
            let message: String = "Entry ${localFileHeader.getFileName()} Strong Encryption not supported"
            throw ZipException(message, ZipExceptionType.UNSUPPORTED_ENCRYPTION)
        }
    }

    private func initializeDecompressorForThisEntry(
        cipherInputStream: ICipherInputStream,
        localFileHeader: LocalFileHeader
    ): DecompressedInputStream {
        let compressionMethod = Zip4cjUtil.getCompressionMethod(localFileHeader)

        if (compressionMethod == CompressionMethod.DEFLATE) {
            return InflaterInputStream(cipherInputStream, Int64(zip4cjConfig.getBufferSize()))
        }

        return StoreInputStream(cipherInputStream)
    }

    private func readExtendedLocalFileHeaderIfPresent(): Unit {
        let localFileHeaders = localFileHeader.getOrThrow()
        if (!localFileHeaders.isDataDescriptorExists() || canSkipExtendedLocalFileHeader) {
            return
        }
        let dataDescriptor = headerReader.readDataDescriptor(inputStream,
            checkIfZip64ExtraDataRecordPresentInLFH(localFileHeaders.getExtraDataRecords()))
        localFileHeaders.setCompressedSize(dataDescriptor.getCompressedSize())
        localFileHeaders.setUncompressedSize(dataDescriptor.getUncompressedSize())
        localFileHeaders.setCrc(dataDescriptor.getCrc())
    }

    private func verifyLocalFileHeader(localFileHeader: LocalFileHeader): Unit {
        if (!isEntryDirectory(localFileHeader.getFileName().getOrThrow()) &&
            localFileHeader.getCompressionMethod().getOrThrow() == CompressionMethod.STORE &&
            localFileHeader.getUncompressedSize() < 0) {
            throw ZipIOException(
                "Invalid local file header for: ${localFileHeader.getFileName().getOrThrow()}. Uncompressed size has to be set for entry of compression type store which is not a directory"
            )
        }
    }

    private func checkIfZip64ExtraDataRecordPresentInLFH(extraDataRecord: ?ArrayList<ExtraDataRecord>): Bool {
        if (let Some(extraDataRecords) <- extraDataRecord) {
            if (extraDataRecords.isEmpty()) {
                return false
            }
            for (i in 0..extraDataRecords.size) {
                if (extraDataRecords[i].getHeader() == HeaderSignature.ZIP64_EXTRA_FIELD_SIGNATURE.getValue()) {
                    return true
                }
            }
        }
        return false
    }

    private func verifyCrc(): Unit {
        if (localFileHeader.getOrThrow().getEncryptionMethod() == EncryptionMethod.AES &&
            localFileHeader.getOrThrow().getAesExtraDataRecord().getOrThrow().getAesVersion().getVersionNumber() ==
            AesVersion.TWO.getVersionNumber()) {
            // Verification will be done in this case by AesCipherInputStream
            return
        }

        if (localFileHeader.getOrThrow().getCrc() != Int64(crc32.getValue())) {
            var exceptionType = ZipExceptionType.CHECKSUM_MISMATCH

            if (isEncryptionMethodZipStandard(localFileHeader.getOrThrow())) {
                exceptionType = ZipExceptionType.WRONG_PASSWORD
            }

            throw ZipException(
                "Reached end of entry, but crc verification failed for " +
                    localFileHeader.getOrThrow().getFileName().getOrThrow(),
                exceptionType
            )
        }
    }

    private func resetFields(): Unit {
        localFileHeader = None
        crc32.reset()
    }

    private func isEntryDirectory(entryName: String): Bool {
        return entryName.endsWith("/") || entryName.endsWith("\\")
    }

    private func getCompressedSize(localFileHeader: LocalFileHeader): Int64 {
        if (Zip4cjUtil.getCompressionMethod(localFileHeader) == (CompressionMethod.STORE)) {
            return localFileHeader.getUncompressedSize()
        }

        if (localFileHeader.isDataDescriptorExists() && !canSkipExtendedLocalFileHeader) {
            return -1
        }

        return localFileHeader.getCompressedSize() - getEncryptionHeaderSize(localFileHeader)
    }

    private func getEncryptionHeaderSize(localFileHeader: LocalFileHeader) {
        if (!localFileHeader.isEncrypted()) {
            return 0
        }

        if (localFileHeader.getEncryptionMethod() == (EncryptionMethod.AES)) {
            return getAesEncryptionHeaderSize(localFileHeader.getAesExtraDataRecord())
        } else if (localFileHeader.getEncryptionMethod() == (EncryptionMethod.ZIP_STANDARD)) {
            return InternalZipConstants.STD_DEC_HDR_SIZE
        } else {
            return 0
        }
    }

    private func readUntilEndOfEntry(): Unit {
        if (endOfEntryBuffer.isEmpty()) {
            endOfEntryBuffer = Array<Byte>(512, repeat: 0)
        }

        //noinspection StatementWithEmptyBody
        while (read(endOfEntryBuffer) != -1) {
            this.entryEOFReached = true
        }
    }

    private func getAesEncryptionHeaderSize(aesExtraDataRecord: ?AESExtraDataRecord): Int64 {
        if (aesExtraDataRecord.isNone()) {
            throw ZipException("AesExtraDataRecord not found or invalid for Aes encrypted entry")
        }

        return Int64(InternalZipConstants.AES_AUTH_LENGTH + InternalZipConstants.AES_PASSWORD_VERIFIER_LENGTH +
                aesExtraDataRecord.getOrThrow().getAesKeyStrength().getSaltLength())
    }

    private func isEncryptionMethodZipStandard(localFileHeader: LocalFileHeader): Bool {
        return localFileHeader.isEncrypted() && EncryptionMethod.ZIP_STANDARD == localFileHeader.getEncryptionMethod()
    }

    private func assertStreamOpen(): Unit {
        if (streamClosed) {
            throw ZipIOException("Stream closed")
        }
    }
}