<?php

declare(strict_types=1);

namespace PhpWA\Media;

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

final class MediaDownloader
{
    /** @var MediaCrypto */ private $crypto;
    /** @var MediaConnectionDirectory */ private $connections;
    /** @var MediaHttpClientInterface */ private $http;
    /** @var int */ private $maximumBytes;
    public function __construct(MediaCrypto $crypto, MediaConnectionDirectory $connections, ?MediaHttpClientInterface $http = null, int $maximumBytes = 2147483673)
    {
        if ($maximumBytes < 26) { throw new InvalidArgumentException('Media download limit is invalid.'); }
        $this->crypto = $crypto; $this->connections = $connections; $this->http = $http ?? new CurlMediaHttpClient(); $this->maximumBytes = $maximumBytes;
    }

    public function download(
        string $directPath,
        string $type,
        string $mediaKey,
        string $fileSha256,
        string $fileEncSha256,
        int $fileLength,
        string $outputPath,
        Deadline $deadline
    ): void {
        MediaType::hkdfInfo($type);
        if (strlen($mediaKey) !== 32 || strlen($fileSha256) !== 32 || strlen($fileEncSha256) !== 32 || $fileLength < 0
            || $directPath === '' || strlen($directPath) > 8192 || $directPath[0] !== '/' || substr($directPath, 0, 2) === '//'
            || preg_match('/[\x00-\x1f\x7f]/', $directPath) === 1 || $outputPath === ''
            || strpos($outputPath, "\0") !== false || file_exists($outputPath) || is_link($outputPath)) {
            throw new InvalidArgumentException('Media download metadata or destination is invalid.');
        }
        $encryptedLength = ((int) floor($fileLength / 16) + 1) * 16 + 10;
        if ($encryptedLength > $this->maximumBytes) { throw new ProtocolException('Encrypted media exceeds the configured download limit.'); }
        $connection = $this->connections->get($deadline);
        if ($this->http instanceof ResumableMediaHttpClientInterface) {
            $this->downloadResumable(
                $connection->hosts(), $directPath, $type, $mediaKey, $fileSha256, $fileEncSha256,
                $fileLength, $encryptedLength, $outputPath, $deadline
            );
            return;
        }
        $lastError = null;
        foreach ($connection->hosts() as $host) {
            $temporary = dirname($outputPath) . DIRECTORY_SEPARATOR . '.' . basename($outputPath) . '.encrypted-' . bin2hex(random_bytes(12));
            $url = $this->url($host, $directPath, $type, $fileEncSha256);
            try {
                $this->http->download($url, $temporary, $encryptedLength, $deadline);
                $size = filesize($temporary);
                if (!is_int($size) || $size !== $encryptedLength) { throw new ProtocolException('Downloaded encrypted media length is invalid.'); }
                $media = new EncryptedMedia($temporary, $type, $mediaKey, $fileSha256, $fileEncSha256, $fileLength, $encryptedLength);
                $this->crypto->decryptFile($media, $outputPath, $deadline);
                SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
                return;
            } catch (\Throwable $error) {
                $lastError = $error;
                if (is_file($temporary)) {
                    SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
                }
                if (file_exists($outputPath) || $deadline->isExpired()) { break; }
            }
        }
        throw new ProtocolException('All WhatsApp media download hosts failed.', 0, $lastError);
    }

    /** @param list<string> $hosts */
    private function downloadResumable(
        array $hosts,
        string $directPath,
        string $type,
        string $mediaKey,
        string $fileSha256,
        string $fileEncSha256,
        int $fileLength,
        int $encryptedLength,
        string $outputPath,
        Deadline $deadline
    ): void {
        $partial = dirname($outputPath) . DIRECTORY_SEPARATOR . '.' . basename($outputPath)
            . '.phpwa-' . substr(hash('sha256', $directPath . "\0" . $fileEncSha256), 0, 24) . '.part';
        $offset = $this->partialSize($partial, $encryptedLength);
        if ($offset === $encryptedLength) {
            $this->finishPartial($partial, $type, $mediaKey, $fileSha256, $fileEncSha256, $fileLength, $encryptedLength, $outputPath, $deadline);
            return;
        }

        $lastError = null; $downloaded = $offset;
        foreach ($hosts as $host) {
            try {
                /** @var ResumableMediaHttpClientInterface $http */
                $http = $this->http;
                $progress = $http->downloadRange(
                    $this->url($host, $directPath, $type, $fileEncSha256),
                    $partial,
                    $this->partialSize($partial, $encryptedLength),
                    $encryptedLength,
                    $deadline
                );
                $actual = $this->partialSize($partial, $encryptedLength);
                if ($progress->totalBytes() !== $encryptedLength || $progress->downloadedBytes() !== $actual) {
                    throw new ProtocolException('Resumable media client reported inconsistent progress.');
                }
                $downloaded = $actual;
                if ($progress->isComplete()) {
                    $this->finishPartial(
                        $partial, $type, $mediaKey, $fileSha256, $fileEncSha256,
                        $fileLength, $encryptedLength, $outputPath, $deadline
                    );
                    return;
                }
                $lastError = new MediaDownloadPendingException($downloaded, $encryptedLength);
            } catch (\Throwable $error) {
                $lastError = $error;
                $downloaded = $this->partialSize($partial, $encryptedLength);
                if ($downloaded === $encryptedLength) {
                    $this->finishPartial(
                        $partial, $type, $mediaKey, $fileSha256, $fileEncSha256,
                        $fileLength, $encryptedLength, $outputPath, $deadline
                    );
                    return;
                }
            }
            if ($deadline->isExpired()) { break; }
        }
        if ($downloaded > 0 || $lastError instanceof MediaDownloadPendingException) {
            throw new MediaDownloadPendingException($downloaded, $encryptedLength, $lastError);
        }
        if (is_file($partial) && filesize($partial) === 0) {
            SafeRuntime::call(static function () use ($partial): bool { return unlink($partial); }, false);
        }
        throw new ProtocolException('All WhatsApp resumable media download hosts failed.', 0, $lastError);
    }

    private function finishPartial(
        string $partial,
        string $type,
        string $mediaKey,
        string $fileSha256,
        string $fileEncSha256,
        int $fileLength,
        int $encryptedLength,
        string $outputPath,
        Deadline $deadline
    ): void {
        if ($this->partialSize($partial, $encryptedLength) !== $encryptedLength) {
            throw new ProtocolException('Resumable media ciphertext is incomplete.');
        }
        try {
            $media = new EncryptedMedia($partial, $type, $mediaKey, $fileSha256, $fileEncSha256, $fileLength, $encryptedLength);
            $this->crypto->decryptFile($media, $outputPath, $deadline);
        } catch (\Throwable $error) {
            if (is_file($partial)) {
                SafeRuntime::call(static function () use ($partial): bool { return unlink($partial); }, false);
            }
            throw $error;
        }
        if (!SafeRuntime::call(static function () use ($partial): bool { return unlink($partial); }, false) && is_file($partial)) {
            throw new ProtocolException('Unable to remove completed encrypted media partial file.');
        }
    }

    private function partialSize(string $partial, int $expected): int
    {
        // PHP 7.4 keeps an aggressive per-request stat cache. Range clients
        // append through another handle, so every resume boundary must observe
        // the filesystem rather than a stale size from the previous check.
        clearstatcache(true, $partial);
        if (!file_exists($partial)) { return 0; }
        if (is_link($partial) || !is_file($partial)) { throw new ProtocolException('Media partial path is unsafe.'); }
        $size = filesize($partial);
        if (!is_int($size) || $size < 0 || $size > $expected) { throw new ProtocolException('Media partial size is invalid.'); }
        return $size;
    }

    private function url(string $host, string $directPath, string $type, string $fileEncSha256): string
    {
        $separator = strpos($directPath, '?') === false ? '?' : '&';
        $query = http_build_query([
            'hash' => self::base64Url($fileEncSha256), 'mms-type' => MediaType::mmsPath($type), '__wa-mms' => '',
        ], '', '&', PHP_QUERY_RFC3986);
        return 'https://' . $host . $directPath . $separator . $query;
    }
    private static function base64Url(string $bytes): string { return strtr(base64_encode($bytes), '+/', '-_'); }
}
