<?php

declare(strict_types=1);

namespace PhpWA\Media;

use InvalidArgumentException;
use PhpWA\Crypto\CryptoPrimitives;
use PhpWA\Crypto\RandomSourceInterface;
use PhpWA\Crypto\SystemRandomSource;
use PhpWA\Exception\CryptoException;
use PhpWA\Runtime\Deadline;
use PhpWA\Runtime\SafeRuntime;

final class MediaCrypto
{
    private const BLOCK = 16;
    private const MAC_BYTES = 10;
    /** @var RandomSourceInterface */ private $random;
    /** @var int */ private $maximumBytes;
    /** @var int */ private $chunkBytes;

    public function __construct(?RandomSourceInterface $random = null, int $maximumBytes = 2147483647, int $chunkBytes = 65536)
    {
        if ($maximumBytes < 1 || $chunkBytes < 16 || $chunkBytes > 8388608) {
            throw new InvalidArgumentException('Media streaming limits are invalid.');
        }
        $this->random = $random ?? new SystemRandomSource(); $this->maximumBytes = $maximumBytes; $this->chunkBytes = $chunkBytes;
    }

    public function encryptFile(string $inputPath, string $outputPath, string $type, Deadline $deadline, ?string $mediaKey = null): EncryptedMedia
    {
        MediaType::hkdfInfo($type); $mediaKey = $mediaKey ?? $this->random->bytes(32);
        if (strlen($mediaKey) !== 32) { throw new CryptoException('Media key generation failed.'); }
        [$iv, $cipherKey, $macKey] = self::keys($mediaKey, $type);
        [$input, $size] = $this->openInput($inputPath);
        if ($size > $this->maximumBytes) { fclose($input); throw new CryptoException('Media input exceeds the configured limit.'); }
        [$output, $temporary] = $this->openTemporaryOutput($outputPath);
        $plainHash = hash_init('sha256'); $encryptedHash = hash_init('sha256');
        $hmac = hash_init('sha256', HASH_HMAC, $macKey); hash_update($hmac, $iv);
        $previous = $iv; $buffer = ''; $plainLength = 0; $encryptedLength = 0;
        try {
            while (!feof($input)) {
                $deadline->remaining('media encryption');
                $chunk = fread($input, $this->chunkBytes);
                if (!is_string($chunk)) { throw new CryptoException('Unable to read media input.'); }
                if ($chunk === '') { if (feof($input)) { break; } continue; }
                $plainLength += strlen($chunk);
                if ($plainLength > $this->maximumBytes) { throw new CryptoException('Media input exceeds the configured limit.'); }
                hash_update($plainHash, $chunk); $buffer .= $chunk;
                while (strlen($buffer) >= self::BLOCK) {
                    $block = substr($buffer, 0, self::BLOCK); $buffer = substr($buffer, self::BLOCK);
                    $cipher = self::encryptBlock($cipherKey, $block ^ $previous); $previous = $cipher;
                    $this->write($output, $cipher); hash_update($hmac, $cipher); hash_update($encryptedHash, $cipher);
                    $encryptedLength += self::BLOCK;
                }
            }
            $padding = self::BLOCK - strlen($buffer);
            $block = $buffer . str_repeat(chr($padding), $padding);
            $cipher = self::encryptBlock($cipherKey, $block ^ $previous);
            $this->write($output, $cipher); hash_update($hmac, $cipher); hash_update($encryptedHash, $cipher);
            $mac = substr(hash_final($hmac, true), 0, self::MAC_BYTES);
            $this->write($output, $mac); hash_update($encryptedHash, $mac); $encryptedLength += self::BLOCK + self::MAC_BYTES;
            $this->flush($output); fclose($output); $output = null; fclose($input); $input = null;
            if (!rename($temporary, $outputPath)) { throw new CryptoException('Unable to publish encrypted media output.'); }
            return new EncryptedMedia(
                $outputPath, $type, $mediaKey, hash_final($plainHash, true), hash_final($encryptedHash, true), $plainLength, $encryptedLength
            );
        } catch (\Throwable $error) {
            if (is_resource($input)) { fclose($input); } if (is_resource($output)) { fclose($output); }
            if (is_file($temporary)) {
                SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            }
            if ($error instanceof CryptoException) { throw $error; }
            throw new CryptoException('Media encryption failed.', 0, $error);
        }
    }

    public function decryptFile(EncryptedMedia $media, string $outputPath, Deadline $deadline): void
    {
        [$iv, $cipherKey, $macKey] = self::keys($media->mediaKey(), $media->type());
        [$input, $size] = $this->openInput($media->path());
        if ($size !== $media->encryptedLength() || $size > $this->maximumBytes + 26) {
            fclose($input); throw new CryptoException('Encrypted media length is invalid.');
        }
        $cipherLength = $size - self::MAC_BYTES;
        if ($cipherLength < self::BLOCK || ($cipherLength % self::BLOCK) !== 0) { fclose($input); throw new CryptoException('Encrypted media framing is invalid.'); }
        [$output, $temporary] = $this->openTemporaryOutput($outputPath);
        $encryptedHash = hash_init('sha256'); $plainHash = hash_init('sha256');
        $hmac = hash_init('sha256', HASH_HMAC, $macKey); hash_update($hmac, $iv);
        $previous = $iv; $pendingPlain = null; $remaining = $cipherLength; $plainLength = 0;
        try {
            while ($remaining > 0) {
                $deadline->remaining('media decryption');
                $block = $this->readExact($input, self::BLOCK); $remaining -= self::BLOCK;
                hash_update($encryptedHash, $block); hash_update($hmac, $block);
                $plain = self::decryptBlock($cipherKey, $block) ^ $previous; $previous = $block;
                if (is_string($pendingPlain)) {
                    $this->write($output, $pendingPlain); hash_update($plainHash, $pendingPlain); $plainLength += self::BLOCK;
                }
                $pendingPlain = $plain;
            }
            $mac = $this->readExact($input, self::MAC_BYTES); hash_update($encryptedHash, $mac);
            if (!hash_equals($media->fileEncSha256(), hash_final($encryptedHash, true))) { throw new CryptoException('Encrypted media SHA-256 verification failed.'); }
            $expectedMac = substr(hash_final($hmac, true), 0, self::MAC_BYTES);
            if (!hash_equals($expectedMac, $mac)) { throw new CryptoException('Media HMAC verification failed.'); }
            if (!is_string($pendingPlain)) { throw new CryptoException('Media ciphertext is empty.'); }
            $padding = ord($pendingPlain[self::BLOCK - 1]);
            if ($padding < 1 || $padding > self::BLOCK || !hash_equals(str_repeat(chr($padding), $padding), substr($pendingPlain, -$padding))) {
                throw new CryptoException('Media padding verification failed.');
            }
            $tail = substr($pendingPlain, 0, self::BLOCK - $padding);
            if ($tail !== '') { $this->write($output, $tail); hash_update($plainHash, $tail); $plainLength += strlen($tail); }
            if ($plainLength !== $media->fileLength() || !hash_equals($media->fileSha256(), hash_final($plainHash, true))) {
                throw new CryptoException('Decrypted media SHA-256 or length verification failed.');
            }
            $this->flush($output); fclose($output); $output = null; fclose($input); $input = null;
            if (!rename($temporary, $outputPath)) { throw new CryptoException('Unable to publish decrypted media output.'); }
        } catch (\Throwable $error) {
            if (is_resource($input)) { fclose($input); } if (is_resource($output)) { fclose($output); }
            if (is_file($temporary)) {
                SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            }
            if ($error instanceof CryptoException) { throw $error; }
            throw new CryptoException('Media decryption failed.', 0, $error);
        }
    }

    /** @return array{0:string,1:string,2:string,3:string} */
    public static function keys(string $mediaKey, string $type): array
    {
        if (strlen($mediaKey) !== 32) { throw new InvalidArgumentException('WhatsApp media key must contain 32 bytes.'); }
        $expanded = CryptoPrimitives::hkdf($mediaKey, 112, '', MediaType::hkdfInfo($type));
        return [substr($expanded, 0, 16), substr($expanded, 16, 32), substr($expanded, 48, 32), substr($expanded, 80, 32)];
    }

    /** @return array{0:resource,1:int} */
    private function openInput(string $path): array
    {
        if ($path === '' || is_link($path) || !is_file($path)) { throw new InvalidArgumentException('Media input must be a regular non-symlink file.'); }
        $handle = SafeRuntime::call(static function () use ($path) { return fopen($path, 'rb'); }, false);
        if (!is_resource($handle)) { throw new CryptoException('Unable to open media input.'); }
        $stat = fstat($handle);
        if (!is_array($stat) || !isset($stat['size']) || !is_int($stat['size']) || $stat['size'] < 0) { fclose($handle); throw new CryptoException('Unable to determine media input size.'); }
        return [$handle, $stat['size']];
    }

    /** @return array{0:resource,1:string} */
    private function openTemporaryOutput(string $outputPath): array
    {
        if ($outputPath === '' || file_exists($outputPath) || is_link($outputPath)) { throw new InvalidArgumentException('Media output must be a new file.'); }
        $directory = realpath(dirname($outputPath));
        if ($directory === false || !is_dir($directory) || !is_writable($directory)) { throw new InvalidArgumentException('Media output directory is unavailable.'); }
        $temporary = $directory . DIRECTORY_SEPARATOR . '.' . basename($outputPath) . '.tmp-' . bin2hex($this->random->bytes(12));
        $handle = SafeRuntime::call(static function () use ($temporary) { return fopen($temporary, 'x+b'); }, false);
        if (!is_resource($handle)) { throw new CryptoException('Unable to create private media output.'); }
        if (!SafeRuntime::call(static function () use ($temporary): bool {
            return chmod($temporary, 0600);
        }, false) || !$this->isPrivateRegularFile($handle, $temporary)) {
            fclose($handle);
            SafeRuntime::call(static function () use ($temporary): bool { return unlink($temporary); }, false);
            throw new CryptoException('Unable to secure private media output.');
        }
        return [$handle, $temporary];
    }

    /** @param resource $handle */
    private function isPrivateRegularFile($handle, string $path): bool
    {
        clearstatcache(true, $path);
        $opened = SafeRuntime::call(static function () use ($handle) { return fstat($handle); }, 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();
    }

    private static function encryptBlock(string $key, string $block): string
    {
        $value = openssl_encrypt($block, 'aes-256-ecb', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
        if (!is_string($value) || strlen($value) !== self::BLOCK) { throw new CryptoException('Media AES encryption failed.'); }
        return $value;
    }

    private static function decryptBlock(string $key, string $block): string
    {
        $value = openssl_decrypt($block, 'aes-256-ecb', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
        if (!is_string($value) || strlen($value) !== self::BLOCK) { throw new CryptoException('Media AES decryption failed.'); }
        return $value;
    }

    /** @param resource $handle */
    private function readExact($handle, int $length): string
    {
        $value = '';
        while (strlen($value) < $length) {
            $part = fread($handle, $length - strlen($value));
            if (!is_string($part) || $part === '') { throw new CryptoException('Encrypted media is truncated.'); }
            $value .= $part;
        }
        return $value;
    }

    /** @param resource $handle */
    private function write($handle, string $bytes): void
    {
        $offset = 0;
        while ($offset < strlen($bytes)) {
            $written = fwrite($handle, substr($bytes, $offset));
            if (!is_int($written) || $written < 1) { throw new CryptoException('Unable to write media output.'); }
            $offset += $written;
        }
    }

    /** @param resource $handle */
    private function flush($handle): void
    {
        if (!fflush($handle)) { throw new CryptoException('Unable to flush media output.'); }
        if (function_exists('fsync') && !fsync($handle)) { throw new CryptoException('Unable to synchronize media output.'); }
    }
}
