<?php

declare(strict_types=1);

namespace PhpWA\Media;

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

final class CurlMediaHttpClient implements ResumableMediaHttpClientInterface
{
    private const MAX_RESPONSE = 1048576;
    public function upload(string $url, string $filePath, int $length, Deadline $deadline): string
    {
        $this->requireCurl(); $this->assertHttps($url);
        if ($length < 1 || !is_file($filePath) || is_link($filePath) || filesize($filePath) !== $length) {
            throw new InvalidArgumentException('Media upload file or length is invalid.');
        }
        $file = fopen($filePath, 'rb'); if (!is_resource($file)) { throw new ProtocolException('Unable to open encrypted media upload.'); }
        $response = '';
        $handle = curl_init($url); if ($handle === false) { fclose($file); throw new ProtocolException('Unable to initialize media upload.'); }
        try {
            $timeout = max(1, (int) ceil($deadline->remaining('media upload')));
            curl_setopt_array($handle, [
                CURLOPT_UPLOAD => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_INFILE => $file, CURLOPT_INFILESIZE => $length,
                CURLOPT_READFUNCTION => static function ($curl, $stream, int $wanted) use ($file) { return fread($file, $wanted); },
                CURLOPT_HTTPHEADER => ['Content-Type: application/octet-stream', 'Content-Length: ' . $length, 'Origin: https://web.whatsapp.com', 'Referer: https://web.whatsapp.com/'],
                CURLOPT_FOLLOWLOCATION => false, CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout,
                CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
                CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
                    if (strlen($response) + strlen($chunk) > self::MAX_RESPONSE) { return 0; }
                    $response .= $chunk; return strlen($chunk);
                },
            ]);
            $ok = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
            if ($ok === false || $status !== 200) { throw new ProtocolException('WhatsApp media upload failed.'); }
            return $response;
        } finally { curl_close($handle); fclose($file); }
    }

    public function download(string $url, string $outputPath, int $maximumBytes, Deadline $deadline): void
    {
        $this->requireCurl(); $this->assertHttps($url);
        if ($maximumBytes < 1 || file_exists($outputPath) || is_link($outputPath)) { throw new InvalidArgumentException('Media download destination or limit is invalid.'); }
        $directory = realpath(dirname($outputPath)); if ($directory === false || !is_writable($directory)) { throw new InvalidArgumentException('Media download directory is unavailable.'); }
        $temporary = $directory . DIRECTORY_SEPARATOR . '.' . basename($outputPath) . '.http-' . bin2hex(random_bytes(12));
        $file = fopen($temporary, 'x+b'); if (!is_resource($file)) { throw new ProtocolException('Unable to create media download file.'); }
        if (!SafeRuntime::call(static function () use ($temporary): bool {
            return chmod($temporary, 0600);
        }, false) || !$this->isPrivateRegularFile($file, $temporary)) {
            fclose($file);
            SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            throw new ProtocolException('Unable to secure media download file.');
        }
        $received = 0; $handle = curl_init($url);
        if ($handle === false) {
            fclose($file);
            SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            throw new ProtocolException('Unable to initialize media download.');
        }
        try {
            $timeout = max(1, (int) ceil($deadline->remaining('media download')));
            curl_setopt_array($handle, [
                CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_RETURNTRANSFER => false,
                CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout,
                CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
                CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use ($file, &$received, $maximumBytes): int {
                    if ($received + strlen($chunk) > $maximumBytes) { return 0; }
                    $written = fwrite($file, $chunk); if (!is_int($written)) { return 0; } $received += $written; return $written;
                },
            ]);
            $ok = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
            if ($ok === false || $status !== 200) { throw new ProtocolException('WhatsApp media download failed.'); }
            if (!fflush($file) || (function_exists('fsync') && !fsync($file))) { throw new ProtocolException('Unable to synchronize media download.'); }
            fclose($file); $file = null;
            if (!rename($temporary, $outputPath)) { throw new ProtocolException('Unable to publish media download.'); }
        } finally {
            curl_close($handle);
            if (is_resource($file)) { fclose($file); }
            if (is_file($temporary)) {
                SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            }
        }
    }

    public function downloadRange(
        string $url,
        string $partialPath,
        int $offset,
        int $expectedBytes,
        Deadline $deadline
    ): MediaDownloadProgress {
        $this->requireCurl(); $this->assertHttps($url);
        if ($expectedBytes < 1 || $offset < 0 || $offset > $expectedBytes || is_link($partialPath)) {
            throw new InvalidArgumentException('Resumable media range is invalid.');
        }
        $directory = realpath(dirname($partialPath));
        if ($directory === false || !is_writable($directory)) { throw new InvalidArgumentException('Media partial directory is unavailable.'); }
        clearstatcache(true, $partialPath);
        if (file_exists($partialPath)) {
            if (!is_file($partialPath) || (int) filesize($partialPath) !== $offset) {
                throw new InvalidArgumentException('Media partial file does not match its resume offset.');
            }
            $file = fopen($partialPath, 'r+b');
        } else {
            if ($offset !== 0) { throw new InvalidArgumentException('Media partial file is missing at a non-zero offset.'); }
            $file = fopen($partialPath, 'x+b');
        }
        if (!is_resource($file)) { throw new ProtocolException('Unable to open resumable media partial file.'); }
        if (!flock($file, LOCK_EX | LOCK_NB)) { fclose($file); throw new ProtocolException('Media partial file is already in use.'); }
        if (!SafeRuntime::call(static function () use ($partialPath): bool {
            return chmod($partialPath, 0600);
        }, false) || !$this->isPrivateRegularFile($file, $partialPath)
            || fseek($file, $offset, SEEK_SET) !== 0) {
            flock($file, LOCK_UN); fclose($file); throw new ProtocolException('Unable to secure resumable media partial file.');
        }
        $received = $offset; $status = 0; $contentLength = null; $contentRange = null; $headersValid = false;
        $handle = curl_init($url);
        if ($handle === false) { flock($file, LOCK_UN); fclose($file); throw new ProtocolException('Unable to initialize resumable media download.'); }
        try {
            $timeout = max(1, (int) ceil($deadline->remaining('resumable media download')));
            $options = [
                CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_RETURNTRANSFER => false,
                CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout,
                CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
                CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$status, &$contentLength, &$contentRange, &$headersValid, $offset, $expectedBytes): int {
                    $trimmed = trim($line);
                    if (preg_match('/\AHTTP\/\d(?:\.\d)? ([0-9]{3})(?: |\z)/D', $trimmed, $match) === 1) {
                        $status = (int) $match[1]; $contentLength = null; $contentRange = null; $headersValid = false;
                    } elseif (stripos($trimmed, 'Content-Length:') === 0) {
                        $value = trim(substr($trimmed, 15));
                        if (preg_match('/\A(?:0|[1-9][0-9]{0,18})\z/D', $value) === 1) { $contentLength = (int) $value; }
                    } elseif (stripos($trimmed, 'Content-Range:') === 0) {
                        $contentRange = trim(substr($trimmed, 14));
                    } elseif ($trimmed === '') {
                        if ($offset === 0 && $status === 200) {
                            $headersValid = $contentLength === null || $contentLength === $expectedBytes;
                        } elseif ($status === 206 && is_string($contentRange)
                            && preg_match('/\Abytes ([0-9]+)-([0-9]+)\/([0-9]+)\z/D', $contentRange, $range) === 1) {
                            $start = (int) $range[1]; $end = (int) $range[2]; $total = (int) $range[3];
                            $headersValid = $start === $offset && $end >= $start && $end < $total && $total === $expectedBytes
                                && ($contentLength === null || $contentLength === $end - $start + 1);
                        }
                    }
                    return strlen($line);
                },
                CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use ($file, &$received, &$headersValid, $expectedBytes): int {
                    if (!$headersValid || $received + strlen($chunk) > $expectedBytes) { return 0; }
                    $written = fwrite($file, $chunk);
                    if (!is_int($written) || $written !== strlen($chunk)) { return 0; }
                    $received += $written; return $written;
                },
            ];
            if ($offset > 0) { $options[CURLOPT_RANGE] = $offset . '-'; }
            curl_setopt_array($handle, $options);
            $ok = curl_exec($handle);
            if (!fflush($file) || (function_exists('fsync') && !fsync($file))) {
                throw new ProtocolException('Unable to synchronize resumable media download.');
            }
            if ($received === $expectedBytes) { return new MediaDownloadProgress($received, $expectedBytes); }
            if ($received > $offset) { return new MediaDownloadProgress($received, $expectedBytes); }
            if ($ok === false || !$headersValid) { throw new ProtocolException('WhatsApp resumable media range request failed.'); }
            return new MediaDownloadProgress($received, $expectedBytes);
        } finally {
            curl_close($handle); flock($file, LOCK_UN); fclose($file);
        }
    }

    private function requireCurl(): void
    {
        if (!function_exists('curl_init')) { throw new ProtocolException('The cURL extension is required for bounded media HTTP transfers.'); }
    }
    private function assertHttps(string $url): void
    {
        $parts = parse_url($url);
        if (!is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || !isset($parts['host']) || isset($parts['user']) || isset($parts['pass'])
            || isset($parts['fragment']) || (isset($parts['port']) && $parts['port'] !== 443)) {
            throw new InvalidArgumentException('Media transfer URL must be a credential-free HTTPS URL.');
        }
    }

    /** @param resource $file */
    private function isPrivateRegularFile($file, string $path): bool
    {
        clearstatcache(true, $path);
        $opened = SafeRuntime::call(static function () use ($file) { return fstat($file); }, false);
        $current = SafeRuntime::call(static function () use ($path) { return lstat($path); }, false);
        if (!is_array($opened) || !is_array($current)
            || (((int) ($opened['mode'] ?? 0)) & 0170000) !== 0100000
            || (((int) ($current['mode'] ?? 0)) & 0170000) !== 0100000
            || (($opened['nlink'] ?? 0) !== 1) || (($current['nlink'] ?? 0) !== 1)
            || (($opened['dev'] ?? null) !== ($current['dev'] ?? null))
            || (($opened['ino'] ?? null) !== ($current['ino'] ?? null))
            || ((((int) ($opened['mode'] ?? 0)) & 0077) !== 0)) {
            return false;
        }
        return !function_exists('posix_geteuid') || !isset($opened['uid']) || $opened['uid'] === posix_geteuid();
    }
}
