<?php

declare(strict_types=1);

namespace PhpWA\Protocol\Compression;

use InvalidArgumentException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Runtime\Deadline;
use PhpWA\Runtime\SafeRuntime;

/** Inflates a zlib stream with hard input, output and wall-clock bounds. */
final class BoundedZlibInflater
{
    /** @var int */ private $maximumCompressedBytes;
    /** @var int */ private $maximumInflatedBytes;

    public function __construct(int $maximumCompressedBytes = 67108864, int $maximumInflatedBytes = 134217728)
    {
        if ($maximumCompressedBytes < 1 || $maximumCompressedBytes > 268435456
            || $maximumInflatedBytes < 1 || $maximumInflatedBytes > 536870912) {
            throw new InvalidArgumentException('Zlib inflation bounds are invalid.');
        }
        $this->maximumCompressedBytes = $maximumCompressedBytes;
        $this->maximumInflatedBytes = $maximumInflatedBytes;
    }

    public function inflateString(string $compressed, Deadline $deadline): string
    {
        if ($compressed === '' || strlen($compressed) > $this->maximumCompressedBytes) {
            throw new ProtocolException('Compressed history-sync payload is empty or too large.');
        }
        $offset = 0;
        return $this->inflateChunks(static function () use ($compressed, &$offset): ?string {
            if ($offset >= strlen($compressed)) { return null; }
            $chunk = substr($compressed, $offset, 8192);
            $offset += strlen($chunk);
            return $chunk;
        }, $deadline);
    }

    public function inflateFile(string $path, Deadline $deadline): string
    {
        clearstatcache(true, $path);
        $size = SafeRuntime::call(static function () use ($path) { return filesize($path); }, false);
        if (!is_int($size) || $size < 1 || $size > $this->maximumCompressedBytes || is_link($path) || !is_file($path)) {
            throw new ProtocolException('Compressed history-sync file is missing, unsafe or too large.');
        }
        $handle = SafeRuntime::call(static function () use ($path) { return fopen($path, 'rb'); }, false);
        if (!is_resource($handle)) { throw new ProtocolException('Unable to open the compressed history-sync file.'); }
        try {
            return $this->inflateChunks(static function () use ($handle): ?string {
                if (feof($handle)) { return null; }
                $chunk = fread($handle, 8192);
                if (!is_string($chunk) || ($chunk === '' && !feof($handle))) {
                    throw new ProtocolException('Unable to read the compressed history-sync file.');
                }
                return $chunk === '' ? null : $chunk;
            }, $deadline);
        } finally { fclose($handle); }
    }

    /** @param callable():?string $next */
    private function inflateChunks(callable $next, Deadline $deadline): string
    {
        if (!function_exists('inflate_init') || !function_exists('inflate_add')) {
            throw new ProtocolException('The zlib extension is required for history sync.');
        }
        $context = SafeRuntime::call(static function () { return inflate_init(ZLIB_ENCODING_DEFLATE); }, false);
        if ($context === false) { throw new ProtocolException('Unable to initialize the history-sync inflater.'); }
        $output = ''; $inputBytes = 0;
        while (($chunk = $next()) !== null) {
            $deadline->remaining('history sync inflation');
            $inputBytes += strlen($chunk);
            if ($inputBytes > $this->maximumCompressedBytes) { throw new ProtocolException('Compressed history-sync payload exceeds its limit.'); }
            $inflated = SafeRuntime::call(static function () use ($context, $chunk) {
                return inflate_add($context, $chunk, ZLIB_NO_FLUSH);
            }, false);
            if (!is_string($inflated)) { throw new ProtocolException('History-sync zlib payload is malformed.'); }
            if (strlen($output) > $this->maximumInflatedBytes - strlen($inflated)) {
                throw new ProtocolException('Inflated history-sync payload exceeds its limit.');
            }
            $output .= $inflated;
        }
        $tail = SafeRuntime::call(static function () use ($context) {
            return inflate_add($context, '', ZLIB_FINISH);
        }, false);
        if (!is_string($tail)) { throw new ProtocolException('History-sync zlib stream is incomplete.'); }
        if (strlen($output) > $this->maximumInflatedBytes - strlen($tail)) {
            throw new ProtocolException('Inflated history-sync payload exceeds its limit.');
        }
        $output .= $tail;
        if ($output === '') { throw new ProtocolException('Inflated history-sync payload is empty.'); }
        return $output;
    }
}
