<?php

/**
 * @package   Duplicator
 * @copyright (c) 2022, Snap Creek LLC
 */

namespace Duplicator\Utils\HTTP;

use VendorDuplicator\Amk\JsonSerialize\AbstractJsonSerializable;
use Duplicator\Libs\Snap\SnapLog;
use Duplicator\Libs\Snap\SnapUtil;
use Duplicator\Utils\Logging\DupLog;
use Exception;
use VendorDuplicator\WpOrg\Requests\Requests;
use VendorDuplicator\WpOrg\Requests\Response;
use VendorDuplicator\WpOrg\Requests\Response\Headers;

class DynamicChunkRequests extends AbstractJsonSerializable
{
    /** @var int */
    const CHUNK_SIZE_MIN = 10240; // 10k in bytes
    /** @var int */
    const CHUNK_SIZE_MAX = 104857600; // 100MB in bytes
    /** @var float */
    const DEFAULT_CHUNK_TIME = 5.0; // seconds, can be a float number

    /** @var string real download url */
    protected $downloadUrl = '';
    /** @var float */
    protected $chunkTime = self::DEFAULT_CHUNK_TIME;
    /** @var int */
    protected $offset = 0;
    /** @var int */
    protected $fullSize = -1;
    /** @var float */
    protected $lastSize = -1;
    /** @var float */
    protected $lastTime = -1;
    /** @var bool */
    protected $complete = false;
    /** @var mixed[] */
    protected array $extraData;

    /**
     * Class constructor
     *
     * @param string  $url       request URL
     * @param mixed[] $extraData Extra data, useful to save for persistence
     */
    public function __construct($url = "", $extraData = [])
    {
        $this->downloadUrl = $url;
        $this->extraData   = (array) $extraData;
    }

    /**
     * Set downloadUrl
     *
     * @param string $url Download url address
     *
     * @return void
     */
    public function setDownloadUrl($url): void
    {
        $this->downloadUrl = $url;
    }

    /**
     * Set chunk time
     *
     * @param int|float $time in seconds
     *
     * @return float
     */
    public function setChunkTime($time)
    {
        $maxTime         = SnapUtil::phpIniGet('max_execution_time', 30, 'float') - 5;
        $this->chunkTime = (float) max(1, min($time, $maxTime));
        return $this->chunkTime;
    }

    /**
     * Main interface for HTTP requests.
     *
     * This function wraps the Requests::request function of WordPress application a dynamic range based on class settings
     *
     * @param array<string, mixed>      $headers        Extra headers to send with the request
     * @param array<string, mixed>|null $data           Data to send either as a query string for GET/HEAD requests, or in the body for POST requests
     * @param string                    $type           HTTP request type (Requests constants)
     * @param array<string, mixed>      $options        Options for the request (see description for more information)
     * @param bool                      $resetOnFailure If true in case of failure, the chunk status is reset
     *
     * @return Response|bool returns xxx or false if no request was made,
     *                                false is not necessarily an error. Simply that the offset is out of range
     */
    public function request($headers = [], $data = [], $type = Requests::GET, $options = [], $resetOnFailure = true)
    {
        if (($range = $this->getRequestRange()) === false) {
            $this->lastSize = -1;
            $this->lastTime = -1;
            $this->complete = true;
            return false;
        }

        $headers['Range'] = 'bytes=' . $range;
        DupLog::trace('REQUEST HEADERS ' . SnapLog::v2str($headers));
        $startTime          = microtime(true);
        $options['timeout'] = ($this->chunkTime * 100); // make sure avoid the request timeout
        // $options['protocol_version'] = 1.1;
        // $options['transport'] = "\\VendorDuplicator\\WpOrg\\Requests\\Transport\\Fsockopen";

        try {
            $response = Requests::request($this->downloadUrl, $headers, $data, $type, $options);
        } catch (Exception $e) {
            $response          = new Response();
            $response->success = false;
        }

        if ($response->success !== true) {
            if ($resetOnFailure) {
                $this->lastSize = -1;
                $this->lastTime = -1;
                $this->complete = true;
            }
            return $response;
        }

        $headers        = $response->headers->getAll();
        $this->lastTime = microtime(true) - $startTime;
        $this->lastSize = (int) self::getLastHeaderValue($response->headers, 'content-length', -1);
        if ($this->lastSize == -1) {
            /**
             * @todo Implement a protocol extension system based on cloud type.
             */
            $this->lastSize = (int) self::getLastHeaderValue($response->headers, 'x-dropbox-content-length', -1);
        }
        if ($this->lastSize == -1) {
            // In case of OneDrive sometimes there is no content-length in headers.
            // In order to prevent download speed from getting too low because of lastSize being -1
            // (because of missing content-length), we take lastSize from last used $range.
            $dashPos        = (int)strpos($range, '-');
            $this->lastSize = (int)substr($range, $dashPos + 1) - (int)substr($range, 0, $dashPos) + 1;
        }
        DupLog::trace('REMOTE RESPONSE CONTENT LEN ' . SnapLog::v2str($this->lastSize));

        if ($response->status_code == 200) {
            $this->fullSize = $this->lastSize;
            $this->offset   = $this->lastSize + 1;
            $this->complete = true;
            return $response;
        }

        $matches      = [];
        $contentRange = self::getLastHeaderValue($response->headers, 'content-range', '');
        DupLog::trace('REMOTE RESPONSE CONTENT RANGE ' . SnapLog::v2str($contentRange) . "\n");

        if (
            $response->status_code != 206 ||
            preg_match('/bytes\s+(\d+)-(\d+)\/(\d+|\*)/', $contentRange, $matches) !== 1
        ) {
            $this->lastSize = -1;
            $this->complete = true;
            return $response;
        }

        $this->fullSize = ($matches[3] == '*' ? -1 : (int) $matches[3]);
        $this->offset   = ((int) $matches[2]) + 1;
        $this->complete = ($this->offset >= $this->fullSize);

        return $response;
    }

    /**
     * Reset current chunk download.
     *
     * @return void
     */
    public function reset(): void
    {
        $this->offset   = 0;
        $this->fullSize = -1;
        $this->lastSize = -1;
        $this->lastTime = -1;
        $this->complete = false;
    }

    /**
     * Return complete status
     *
     * @return bool
     */
    public function isComplete()
    {
        return $this->complete;
    }

    /**
     * Calculate request range
     *
     * @return false|string
     */
    protected function getRequestRange()
    {
        DupLog::trace('LAST SIZE ' . SnapLog::v2str($this->lastSize) . ' TIME ' . SnapLog::v2str($this->lastTime) . ' CHUNK TIME ' . $this->chunkTime);

        if ($this->fullSize >= 0 && $this->offset >= $this->fullSize) {
            return false;
        }

        if ($this->lastSize <= 0 || $this->lastTime <= 0) {
            return ($this->offset . '-' . ($this->offset + self::CHUNK_SIZE_MIN - 1));
        }

        $size = SnapUtil::getIntBetween(
            floor($this->lastSize / $this->lastTime * $this->chunkTime),
            self::CHUNK_SIZE_MIN,
            self::CHUNK_SIZE_MAX
        );

        DupLog::trace('NEW SIZE ' . SnapLog::v2str($size));
        if (($this->offset + $size) >= $this->fullSize) {
            // It is important to explicitly specify right side of range, because range
            // might be parsed in caller's function, so numbers are expected
            return ($this->offset . '-' . ($this->fullSize - 1));
        }

        return ($this->offset . '-' . ($this->offset + $size - 1));
    }

    /**
     * Function that returns the last value of a key in the header.
     * In case of redirect of the demand the values can be multiple therefore the last one makes reference to the last loaded URL
     *
     * @param Headers $headers response headers
     * @param string  $key     header key
     * @param mixed   $default default value if header don't exists
     *
     * @return mixed
     */
    protected static function getLastHeaderValue(Headers $headers, $key, $default = false)
    {
        if (($result = $headers->getValues($key)) === null) {
            return $default;
        }

        return end($result);
    }

    /**
     * Get extra data array or singl valur if key is set
     *
     * @param string $key     extra data key, if empty return all data
     * @param mixed  $default default value if key don't exists
     *
     * @return mixed
     */
    public function getExtraData($key = '', $default = false)
    {
        if (strlen($key) == 0) {
            return $this->extraData;
        }

        return ($this->extraData[$key] ?? $default);
    }

    /**
     * Set extra data value
     *
     * @param string $key   extra data key
     * @param mixed  $value extra data value
     *
     * @return void
     */
    public function setExtraData($key, $value): void
    {
        $this->extraData[$key] = $value;
    }

    /**
     * Get the value of fullSize
     *
     * @return int -1 if is unknown
     */
    public function getFullSize()
    {
        return $this->fullSize;
    }
}
