<?php

declare (strict_types=1);
namespace WOE\ZipStream;

use HashContext;
use WOE\Psr\Http\Message\StreamInterface;
use WOE\ZipStream\Exception\FileNotFoundException;
use WOE\ZipStream\Exception\FileNotReadableException;
use WOE\ZipStream\Exception\OverflowException;
use WOE\ZipStream\Option\File as FileOptions;
use WOE\ZipStream\Option\Method;
use WOE\ZipStream\Option\Version;
class File
{
    public const HASH_ALGORITHM = 'crc32b';
    public const BIT_ZERO_HEADER = 0x8;
    public const BIT_EFS_UTF8 = 0x800;
    public const COMPUTE = 1;
    public const SEND = 2;
    private const CHUNKED_READ_BLOCK_SIZE = 1048576;
    /**
     * @var string
     */
    public $name;
    /**
     * @var FileOptions
     */
    public $opt;
    /**
     * @var Bigint
     */
    public $len;
    /**
     * @var Bigint
     */
    public $zlen;
    /** @var  int */
    public $crc;
    /**
     * @var Bigint
     */
    public $hlen;
    /**
     * @var Bigint
     */
    public $ofs;
    /**
     * @var int
     */
    public $bits;
    /**
     * @var Version
     */
    public $version;
    /**
     * @var \ZipStream
     */
    public $zip;
    /**
     * @var resource
     */
    private $deflate;
    /**
     * @var HashContext
     */
    private $hash;
    /**
     * @var Method
     */
    private $method;
    /**
     * @var Bigint
     */
    private $totalLength;
    public function __construct(ZipStream $zip, string $name, ?FileOptions $opt = null)
    {
        $this->zip = $zip;
        $this->name = $name;
        $this->opt = $opt ?: new FileOptions();
        $this->method = $this->opt->getMethod();
        $this->version = Version::STORE();
        $this->ofs = new Bigint();
    }
    public function processPath(string $path): void
    {
        if (!is_readable($path)) {
            if (!file_exists($path)) {
                throw new FileNotFoundException($path);
            }
            throw new FileNotReadableException($path);
        }
        if ($this->zip->isLargeFile($path) === false) {
            $data = file_get_contents($path);
            $this->processData($data);
        } else {
            $this->method = $this->zip->opt->getLargeFileMethod();
            $stream = new DeflateStream(fopen($path, 'rb'));
            $this->processStream($stream);
            $stream->close();
        }
    }
    public function processData(string $data): void
    {
        $this->len = new Bigint(strlen($data));
        $this->crc = crc32($data);
        // compress data if needed
        if ($this->method->equals(Method::DEFLATE())) {
            $data = gzdeflate($data);
        }
        $this->zlen = new Bigint(strlen($data));
        $this->addFileHeader();
        $this->zip->send($data);
        $this->addFileFooter();
    }
    /**
     * Create and send zip header for this file.
     *
     * @return void
     * @throws \WOE\ZipStream\Exception\EncodingException
     */
    public function addFileHeader(): void
    {
        $name = static::filterFilename($this->name);
        // calculate name length
        $nameLength = strlen($name);
        // create dos timestamp
        $time = static::dosTime($this->opt->getTime()->getTimestamp());
        $comment = $this->opt->getComment();
        if (!mb_check_encoding($name, 'ASCII') || !mb_check_encoding($comment, 'ASCII')) {
            // 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($name, 'UTF-8') && mb_check_encoding($comment, 'UTF-8')) {
                $this->bits |= self::BIT_EFS_UTF8;
            }
        }
        if ($this->method->equals(Method::DEFLATE())) {
            $this->version = Version::DEFLATE();
        }
        $force = (bool) ($this->bits & self::BIT_ZERO_HEADER) && $this->zip->opt->isEnableZip64();
        $footer = $this->buildZip64ExtraBlock($force);
        // 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
        if ($this->zip->ofs->isOver32()) {
            $this->version = Version::ZIP64();
        }
        $fields = [
            ['V', ZipStream::FILE_HEADER_SIGNATURE],
            ['v', $this->version->getValue()],
            // Version needed to Extract
            ['v', $this->bits],
            // General purpose bit flags - data descriptor flag set
            ['v', $this->method->getValue()],
            // Compression method
            ['V', $time],
            // Timestamp (DOS Format)
            ['V', $this->crc],
            // CRC32 of data (0 -> moved to data descriptor footer)
            ['V', $this->zlen->getLowFF($force)],
            // Length of compressed data (forced to 0xFFFFFFFF for zero header)
            ['V', $this->len->getLowFF($force)],
            // Length of original data (forced to 0xFFFFFFFF for zero header)
            ['v', $nameLength],
            // Length of filename
            ['v', strlen($footer)],
        ];
        // pack fields and calculate "total" length
        $header = ZipStream::packFields($fields);
        // print header and filename
        $data = $header . $name . $footer;
        $this->zip->send($data);
        // save header length
        $this->hlen = Bigint::init(strlen($data));
    }
    /**
     * Strip characters that are not legal in Windows filenames
     * to prevent compatibility issues
     *
     * @param string $filename Unprocessed filename
     * @return string
     */
    public static function filterFilename(string $filename): string
    {
        // strip leading slashes from file name
        // (fixes bug in windows archive viewer)
        $filename = preg_replace('/^\/+/', '', $filename);
        return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename);
    }
    /**
     * Create and send data descriptor footer for this file.
     *
     * @return void
     */
    public function addFileFooter(): void
    {
        if ($this->bits & self::BIT_ZERO_HEADER) {
            // compressed and uncompressed size
            $sizeFormat = 'V';
            if ($this->zip->opt->isEnableZip64()) {
                $sizeFormat = 'P';
            }
            $fields = [
                ['V', ZipStream::DATA_DESCRIPTOR_SIGNATURE],
                ['V', $this->crc],
                // CRC32
                [$sizeFormat, $this->zlen],
                // Length of compressed data
                [$sizeFormat, $this->len],
            ];
            $footer = ZipStream::packFields($fields);
            $this->zip->send($footer);
        } else {
            $footer = '';
        }
        $this->totalLength = $this->hlen->add($this->zlen)->add(Bigint::init(strlen($footer)));
        $this->zip->addToCdr($this);
    }
    public function processStream(StreamInterface $stream): void
    {
        $this->zlen = new Bigint();
        $this->len = new Bigint();
        if ($this->zip->opt->isZeroHeader()) {
            $this->processStreamWithZeroHeader($stream);
        } else {
            $this->processStreamWithComputedHeader($stream);
        }
    }
    /**
     * Send CDR record for specified file.
     *
     * @return string
     */
    public function getCdrFile(): string
    {
        $name = static::filterFilename($this->name);
        // get attributes
        $comment = $this->opt->getComment();
        // get dos timestamp
        $time = static::dosTime($this->opt->getTime()->getTimestamp());
        $footer = $this->buildZip64ExtraBlock();
        $fields = [
            ['V', ZipStream::CDR_FILE_SIGNATURE],
            // Central file header signature
            ['v', ZipStream::ZIP_VERSION_MADE_BY],
            // Made by version
            ['v', $this->version->getValue()],
            // Extract by version
            ['v', $this->bits],
            // General purpose bit flags - data descriptor flag set
            ['v', $this->method->getValue()],
            // Compression method
            ['V', $time],
            // Timestamp (DOS Format)
            ['V', $this->crc],
            // CRC32
            ['V', $this->zlen->getLowFF()],
            // Compressed Data Length
            ['V', $this->len->getLowFF()],
            // Original Data Length
            ['v', strlen($name)],
            // Length of filename
            ['v', strlen($footer)],
            // Extra data len (see above)
            ['v', strlen($comment)],
            // Length of comment
            ['v', 0],
            // Disk number
            ['v', 0],
            // Internal File Attributes
            ['V', 32],
            // External File Attributes
            ['V', $this->ofs->getLowFF()],
        ];
        // pack fields, then append name and comment
        $header = ZipStream::packFields($fields);
        return $header . $name . $footer . $comment;
    }
    /**
     * @return Bigint
     */
    public function getTotalLength(): Bigint
    {
        return $this->totalLength;
    }
    /**
     * Convert a UNIX timestamp to a DOS timestamp.
     *
     * @param int $when
     * @return int DOS Timestamp
     */
    final protected static function dosTime(int $when): int
    {
        // get date array for timestamp
        $d = getdate($when);
        // set lower-bound on dates
        if ($d['year'] < 1980) {
            $d = ['year' => 1980, 'mon' => 1, 'mday' => 1, 'hours' => 0, 'minutes' => 0, 'seconds' => 0];
        }
        // remove extra years from 1980
        $d['year'] -= 1980;
        // return date string
        return $d['year'] << 25 | $d['mon'] << 21 | $d['mday'] << 16 | $d['hours'] << 11 | $d['minutes'] << 5 | $d['seconds'] >> 1;
    }
    protected function buildZip64ExtraBlock(bool $force = false): string
    {
        $fields = [];
        if ($this->len->isOver32($force)) {
            $fields[] = ['P', $this->len];
            // Length of original data
        }
        if ($this->len->isOver32($force)) {
            $fields[] = ['P', $this->zlen];
            // Length of compressed data
        }
        if ($this->ofs->isOver32()) {
            $fields[] = ['P', $this->ofs];
            // Offset of local header record
        }
        if (!empty($fields)) {
            if (!$this->zip->opt->isEnableZip64()) {
                throw new OverflowException();
            }
            array_unshift(
                $fields,
                ['v', 0x1],
                // 64 bit extension
                ['v', count($fields) * 8]
            );
            $this->version = Version::ZIP64();
        }
        if ($this->bits & self::BIT_EFS_UTF8) {
            // Put the tricky entry to
            // force Linux unzip to lookup EFS flag.
            $fields[] = ['v', 0x5653];
            // Choose 'ZS' for proprietary usage
            $fields[] = ['v', 0x0];
            // zero length
        }
        return ZipStream::packFields($fields);
    }
    protected function processStreamWithZeroHeader(StreamInterface $stream): void
    {
        $this->bits |= self::BIT_ZERO_HEADER;
        $this->addFileHeader();
        $this->readStream($stream, self::COMPUTE | self::SEND);
        $this->addFileFooter();
    }
    protected function readStream(StreamInterface $stream, ?int $options = null): void
    {
        $this->deflateInit();
        $total = 0;
        $size = $this->opt->getSize();
        while (!$stream->eof() && ($size === 0 || $total < $size)) {
            $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
            $total += strlen($data);
            if ($size > 0 && $total > $size) {
                $data = substr($data, 0, strlen($data) - ($total - $size));
            }
            $this->deflateData($stream, $data, $options);
            if ($options & self::SEND) {
                $this->zip->send($data);
            }
        }
        $this->deflateFinish($options);
    }
    protected function deflateInit(): void
    {
        $hash = hash_init(self::HASH_ALGORITHM);
        $this->hash = $hash;
        if ($this->method->equals(Method::DEFLATE())) {
            $this->deflate = deflate_init(ZLIB_ENCODING_RAW, ['level' => $this->opt->getDeflateLevel()]);
        }
    }
    protected function deflateData(StreamInterface $stream, string &$data, ?int $options = null): void
    {
        if ($options & self::COMPUTE) {
            $this->len = $this->len->add(Bigint::init(strlen($data)));
            hash_update($this->hash, $data);
        }
        if ($this->deflate) {
            $data = deflate_add($this->deflate, $data, $stream->eof() ? ZLIB_FINISH : ZLIB_NO_FLUSH);
        }
        if ($options & self::COMPUTE) {
            $this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
        }
    }
    protected function deflateFinish(?int $options = null): void
    {
        if ($options & self::COMPUTE) {
            $this->crc = hexdec(hash_final($this->hash));
        }
    }
    protected function processStreamWithComputedHeader(StreamInterface $stream): void
    {
        $this->readStream($stream, self::COMPUTE);
        $stream->rewind();
        // incremental compression with deflate_add
        // makes this second read unnecessary
        // but it is only available from PHP 7.0
        if (!$this->deflate && $stream instanceof DeflateStream && $this->method->equals(Method::DEFLATE())) {
            $stream->addDeflateFilter($this->opt);
            $this->zlen = new Bigint();
            while (!$stream->eof()) {
                $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
                $this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
            }
            $stream->rewind();
        }
        $this->addFileHeader();
        $this->readStream($stream, self::SEND);
        $this->addFileFooter();
    }
}