<?php

declare(strict_types=1);

namespace ZipStream;

use Closure;
use DateTimeInterface;
use DeflateContext;
use RuntimeException;
use ZipStream\Exception\FileSizeIncorrectException;
use ZipStream\Exception\OverflowException;
use ZipStream\Exception\ResourceActionException;
use ZipStream\Exception\SimulationFileUnknownException;
use ZipStream\Exception\StreamNotReadableException;
use ZipStream\Exception\StreamNotSeekableException;

/**
 * @internal
 */
final class File
{
    private const CHUNKED_READ_BLOCK_SIZE = 0x1000000;

    private Version $version;

    private int $compressedSize = 0;

    private int $uncompressedSize = 0;

    private int $crc = 0;

    private int $generalPurposeBitFlag = 0;

    private readonly string $fileName;

    /**
     * @var resource|null
     */
    private $stream;

    /**
     * @param Closure $dataCallback
     * @psalm-param Closure(): resource $dataCallback
     */
    public function __construct(
        string $fileName,
        private readonly Closure $dataCallback,
        private readonly OperationMode $operationMode,
        private readonly int $startOffset,
        private readonly CompressionMethod $compressionMethod,
        private readonly string $comment,
        private readonly DateTimeInterface $lastModificationDateTime,
        private readonly int $deflateLevel,
        private readonly ?int $maxSize,
        private readonly ?int $exactSize,
        private readonly bool $enableZip64,
        private readonly bool $enableZeroHeader,
        private readonly Closure $send,
        private readonly Closure $recordSentBytes,
    ) {
        $this->fileName = self::filterFilename($fileName);
        $this->checkEncoding();

        if ($this->enableZeroHeader) {
            $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
        }

        $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
    }

    public function cloneSimulationExecution(): self
    {
        return new self(
            $this->fileName,
            $this->dataCallback,
            OperationMode::NORMAL,
            $this->startOffset,
            $this->compressionMethod,
            $this->comment,
            $this->lastModificationDateTime,
            $this->deflateLevel,
            $this->maxSize,
            $this->exactSize,
            $this->enableZip64,
            $this->enableZeroHeader,
            $this->send,
            $this->recordSentBytes,
        );
    }

    public function process(): string
    {
        $forecastSize = $this->forecastSize();

        if ($this->enableZeroHeader) {
            // No calculation required
        } elseif ($this->isSimulation() && $forecastSize !== null) {
            $this->uncompressedSize = $forecastSize;
            $this->compressedSize = $forecastSize;
        } else {
            $this->readStream(send: false);
            if (rewind($this->unpackStream()) === false) {
                throw new ResourceActionException('rewind', $this->unpackStream());
            }
        }

        $this->addFileHeader();

        $detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);

        if (
            $this->isSimulation()
            && $detectedSize !== null
        ) {
            $this->uncompressedSize = $detectedSize;
            $this->compressedSize = $detectedSize;
            ($this->recordSentBytes)($detectedSize);
        } else {
            $this->readStream(send: true);
        }

        $this->addFileFooter();
        return $this->getCdrFile();
    }

    /**
     * @return resource
     */
    private function unpackStream()
    {
        if ($this->stream) {
            return $this->stream;
        }

        if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
            throw new SimulationFileUnknownException();
        }

        $this->stream = ($this->dataCallback)();

        if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
            throw new StreamNotSeekableException();
        }
        if (!(
            str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
        )) {
            throw new StreamNotReadableException();
        }

        return $this->stream;
    }

    private function forecastSize(): ?int
    {
        if ($this->compressionMethod !== CompressionMethod::STORE) {
            return null;
        }
        if ($this->exactSize !== null) {
            return $this->exactSize;
        }
        $fstat = fstat($this->unpackStream());
        if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
            return null;
        }

        if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
            return $this->maxSize;
        }

        return $fstat['size'];
    }

    /**
     * Create and send zip header for this file.
     */
    private function addFileHeader(): void
    {
        $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;

        $footer = $this->buildZip64ExtraBlock($forceEnableZip64);

        $zip64Enabled = $footer !== '';

        if ($zip64Enabled) {
            $this->version = Version::ZIP64;
        }

        if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
            // Put the tricky entry to
            // force Linux unzip to lookup EFS flag.
            $footer .= Zs\ExtendedInformationExtraField::generate();
        }

        $data = LocalFileHeader::generate(
            versionNeededToExtract: $this->version->value,
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
            compressionMethod: $this->compressionMethod,
            lastModificationDateTime: $this->lastModificationDateTime,
            crc32UncompressedData: $this->crc,
            compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
                ? 0xFFFFFFFF
                : $this->compressedSize,
            uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
                ? 0xFFFFFFFF
                : $this->uncompressedSize,
            fileName: $this->fileName,
            extraField: $footer,
        );


        ($this->send)($data);
    }

    /**
     * Strip characters that are not legal in Windows filenames
     * to prevent compatibility issues
     */
    private static function filterFilename(
        /**
         * Unprocessed filename
         */
        string $fileName
    ): string {
        // strip leading slashes from file name
        // (fixes bug in windows archive viewer)
        $fileName = ltrim($fileName, '/');

        return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
    }

    private function checkEncoding(): void
    {
        // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
        // the filename and comment fields for this file
        // MUST be encoded using UTF-8. (see APPENDIX D)
        if (mb_check_encoding($this->fileName, 'UTF-8')
                && mb_check_encoding($this->comment, 'UTF-8')) {
            $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
        }
    }

    private function buildZip64ExtraBlock(bool $force = false): string
    {
        $outputZip64ExtraBlock = false;

        $originalSize = null;
        if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
            $outputZip64ExtraBlock = true;
            $originalSize = $this->uncompressedSize;
        }

        $compressedSize = null;
        if ($force || $this->compressedSize > 0xFFFFFFFF) {
            $outputZip64ExtraBlock = true;
            $compressedSize = $this->compressedSize;
        }

        // If this file will start over 4GB limit in ZIP file,
        // CDR record will have to use Zip64 extension to describe offset
        // to keep consistency we use the same value here
        $relativeHeaderOffset = null;
        if ($this->startOffset > 0xFFFFFFFF) {
            $outputZip64ExtraBlock = true;
            $relativeHeaderOffset = $this->startOffset;
        }

        if (!$outputZip64ExtraBlock) {
            return '';
        }

        if (!$this->enableZip64) {
            throw new OverflowException();
        }

        return Zip64\ExtendedInformationExtraField::generate(
            originalSize: $originalSize,
            compressedSize: $compressedSize,
            relativeHeaderOffset: $relativeHeaderOffset,
            diskStartNumber: null,
        );
    }

    private function addFileFooter(): void
    {
        if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
            throw new OverflowException();
        }

        if (!$this->enableZeroHeader) {
            return;
        }

        if ($this->version === Version::ZIP64) {
            $footer = Zip64\DataDescriptor::generate(
                crc32UncompressedData: $this->crc,
                compressedSize: $this->compressedSize,
                uncompressedSize: $this->uncompressedSize,
            );
        } else {
            $footer = DataDescriptor::generate(
                crc32UncompressedData: $this->crc,
                compressedSize: $this->compressedSize,
                uncompressedSize: $this->uncompressedSize,
            );
        }

        ($this->send)($footer);
    }

    private function readStream(bool $send): void
    {
        $this->compressedSize = 0;
        $this->uncompressedSize = 0;
        $hash = hash_init('crc32b');

        $deflate = $this->compressionInit();

        while (
            !feof($this->unpackStream())
            && ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
            && ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
        ) {
            $readLength = min(
                ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
                ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
                self::CHUNKED_READ_BLOCK_SIZE
            );

            $data = fread($this->unpackStream(), $readLength);

            if ($data === false) {
                throw new ResourceActionException('fread', $this->unpackStream());
            }

            hash_update($hash, $data);

            $this->uncompressedSize += strlen($data);

            if ($deflate) {
                $data =  deflate_add(
                    $deflate,
                    $data,
                    feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
                );

                if ($data === false) {
                    throw new RuntimeException('deflate_add failed');
                }
            }

            $this->compressedSize += strlen($data);

            if ($send) {
                ($this->send)($data);
            }
        }

        if ($this->exactSize !== null && $this->uncompressedSize !== $this->exactSize) {
            throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
        }

        $this->crc = hexdec(hash_final($hash));
    }

    private function compressionInit(): ?DeflateContext
    {
        switch ($this->compressionMethod) {
            case CompressionMethod::STORE:
                // Noting to do
                return null;
            case CompressionMethod::DEFLATE:
                $deflateContext = deflate_init(
                    ZLIB_ENCODING_RAW,
                    ['level' => $this->deflateLevel]
                );

                if (!$deflateContext) {
                    // @codeCoverageIgnoreStart
                    throw new RuntimeException("Can't initialize deflate context.");
                    // @codeCoverageIgnoreEnd
                }

                // False positive, resource is no longer returned from this function
                return $deflateContext;
            default:
                // @codeCoverageIgnoreStart
                throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
                // @codeCoverageIgnoreEnd
        }
    }

    private function getCdrFile(): string
    {
        $footer = $this->buildZip64ExtraBlock();

        return CentralDirectoryFileHeader::generate(
            versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
            versionNeededToExtract: $this->version->value,
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
            compressionMethod: $this->compressionMethod,
            lastModificationDateTime: $this->lastModificationDateTime,
            crc32: $this->crc,
            compressedSize: $this->compressedSize > 0xFFFFFFFF
                ? 0xFFFFFFFF
                : $this->compressedSize,
            uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
                ? 0xFFFFFFFF
                : $this->uncompressedSize,
            fileName: $this->fileName,
            extraField: $footer,
            fileComment: $this->comment,
            diskNumberStart: 0,
            internalFileAttributes: 0,
            externalFileAttributes: 32,
            relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
                ? 0xFFFFFFFF
                : $this->startOffset,
        );
    }

    private function isSimulation(): bool
    {
        return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
    }
}
