<?php

declare(strict_types=1);

namespace PhpWA\AppState;

use InvalidArgumentException;
use PhpWA\Client\MessageIdGeneratorInterface;
use PhpWA\Client\QueryExecutorInterface;
use PhpWA\Client\RandomMessageIdGenerator;
use PhpWA\Exception\ProtocolException;
use PhpWA\Media\MediaDownloader;
use PhpWA\Media\MediaType;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\ServerJid;
use PhpWA\Protocol\Proto\ExternalBlobReference;
use PhpWA\Protocol\Proto\SyncdMutations;
use PhpWA\Protocol\Proto\SyncdPatch;
use PhpWA\Protocol\Proto\SyncdSnapshot;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\PrivateFileArea;

/** Fetches, downloads and atomically applies one app-state collection page. */
final class AppStateSyncClient implements AppStateSynchronizerInterface
{
    /** @var string */ private $accountId;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var QueryExecutorInterface */ private $queries;
    /** @var MediaDownloader */ private $downloader;
    /** @var MessageIdGeneratorInterface */ private $ids;
    /** @var AppStatePatchProcessor */ private $processor;
    /** @var PrivateFileArea */ private $files;
    /** @var callable */ private $clock;
    /** @var int */ private $maximumExternalBytes;

    public function __construct(
        string $accountId,
        AccountStoreInterface $accounts,
        QueryExecutorInterface $queries,
        MediaDownloader $downloader,
        string $workingDirectory,
        ?MessageIdGeneratorInterface $ids = null,
        ?AppStatePatchProcessor $processor = null,
        ?callable $clock = null,
        int $maximumExternalBytes = 67108864
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1
            || $maximumExternalBytes < 1 || $maximumExternalBytes > 268435456) {
            throw new InvalidArgumentException('App-state sync client configuration is invalid.');
        }
        $this->accountId = $accountId; $this->accounts = $accounts; $this->queries = $queries;
        $this->downloader = $downloader; $this->files = new PrivateFileArea($workingDirectory);
        $this->ids = $ids ?? new RandomMessageIdGenerator(); $this->processor = $processor ?? new AppStatePatchProcessor();
        $this->clock = $clock ?? static function (): int { return time(); };
        $this->maximumExternalBytes = $maximumExternalBytes;
    }

    public function sync(string $name, Deadline $deadline, bool $forceSnapshot = false): AppStateSyncResult
    {
        AppStateName::assert($name);
        $state = $this->accounts->transaction($this->accountId, static function (AccountTransactionInterface $tx) use ($name): AppStateHashState {
            $stored = $tx->get('app_state_state', $name);
            return $stored === null ? AppStateHashState::initial() : AppStateHashState::decode($stored->value());
        });
        $snapshot = $forceSnapshot || $state->version() === 0;
        $attributes = ['name' => $name, 'return_snapshot' => $snapshot ? 'true' : 'false'];
        if (!$snapshot) { $attributes['version'] = (string) $state->version(); }
        $request = BinaryNode::children('iq', [
            'to' => ServerJid::WHATSAPP, 'type' => 'set', 'xmlns' => 'w:sync:app:state', 'id' => $this->ids->generate(),
        ], [BinaryNode::children('sync', [], [new BinaryNode('collection', $attributes)])]);
        $response = $this->queries->query($request, $deadline);
        if ($response->tag() !== 'iq' || $response->attribute('type') !== 'result') {
            throw new ProtocolException('App-state sync returned an invalid IQ result.');
        }
        $sync = $response->child('sync'); $collection = $sync instanceof BinaryNode ? $sync->child('collection') : null;
        if (!$collection instanceof BinaryNode || $collection->attribute('name') !== $name) {
            throw new ProtocolException('App-state sync response omitted or changed the requested collection.');
        }
        $hasMore = $this->booleanAttribute($collection->attribute('has_more_patches'), 'has_more_patches');
        $snapshotProto = $this->parseSnapshot($collection, $deadline, $name);
        $patches = $this->parsePatches($collection, $deadline, $name);
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('App-state sync clock returned an invalid timestamp.'); }
        $applied = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($name, $snapshotProto, $patches, $now): array {
                $mutations = 0; $version = 0;
                if ($snapshotProto instanceof SyncdSnapshot) {
                    $result = $this->processor->applySnapshot($tx, $name, $snapshotProto, $now);
                    $mutations += $result->mutations(); $version = $result->version();
                }
                foreach ($patches as $patch) {
                    $result = $this->processor->applyPatch($tx, $name, $patch, $now);
                    $mutations += $result->mutations(); $version = $result->version();
                }
                if ($snapshotProto === null && $patches === []) {
                    $stored = $tx->get('app_state_state', $name);
                    $version = $stored === null ? 0 : AppStateHashState::decode($stored->value())->version();
                }
                return [$version, $mutations];
            }
        );
        return new AppStateSyncResult($name, $applied[0], $applied[1], $hasMore, $snapshotProto !== null);
    }

    private function parseSnapshot(BinaryNode $collection, Deadline $deadline, string $name): ?SyncdSnapshot
    {
        $node = $collection->child('snapshot');
        if (!$node instanceof BinaryNode) { return null; }
        $bytes = $node->bytesContent();
        if (!is_string($bytes) || $bytes === '' || strlen($bytes) > 65536) {
            throw new ProtocolException('App-state snapshot reference is malformed.');
        }
        $reference = new ExternalBlobReference();
        try { $reference->mergeFromString($bytes); }
        catch (\Throwable $error) { throw new ProtocolException('App-state snapshot reference protobuf is malformed.', 0, $error); }
        $raw = $this->downloadExternal($reference, $deadline, $name . ':snapshot');
        $snapshot = new SyncdSnapshot();
        try { $snapshot->mergeFromString($raw); }
        catch (\Throwable $error) { throw new ProtocolException('Downloaded app-state snapshot protobuf is malformed.', 0, $error); }
        return $snapshot;
    }

    /** @return list<SyncdPatch> */
    private function parsePatches(BinaryNode $collection, Deadline $deadline, string $name): array
    {
        $container = $collection->child('patches');
        if (!$container instanceof BinaryNode) { return []; }
        $nodes = $container->childrenNamed('patch');
        if (count($nodes) > 100) { throw new ProtocolException('App-state response contains too many patches.'); }
        $patches = [];
        foreach ($nodes as $position => $node) {
            $bytes = $node->bytesContent();
            if (!is_string($bytes) || $bytes === '' || strlen($bytes) > 4194304) {
                throw new ProtocolException('App-state patch node #' . ($position + 1) . ' is malformed or too large.');
            }
            $patch = new SyncdPatch();
            try { $patch->mergeFromString($bytes); }
            catch (\Throwable $error) { throw new ProtocolException('App-state patch protobuf #' . ($position + 1) . ' is malformed.', 0, $error); }
            if ($patch->hasExternalMutations() && $patch->getExternalMutations() !== null) {
                $raw = $this->downloadExternal($patch->getExternalMutations(), $deadline, $name . ':patch:' . $position);
                $mutations = new SyncdMutations();
                try { $mutations->mergeFromString($raw); }
                catch (\Throwable $error) { throw new ProtocolException('Downloaded app-state mutations protobuf is malformed.', 0, $error); }
                if (count($mutations->getMutations()) < 1 || count($mutations->getMutations()) > 1000) {
                    throw new ProtocolException('Downloaded app-state mutation list is empty or too large.');
                }
                $patch->setMutations(iterator_to_array($mutations->getMutations()));
            }
            $patches[] = $patch;
        }
        return $patches;
    }

    private function downloadExternal(ExternalBlobReference $reference, Deadline $deadline, string $purpose): string
    {
        $length = $this->boundedInteger($reference->getFileSizeBytes(), $this->maximumExternalBytes, 'external app-state file length');
        if ($length < 1 || strlen($reference->getMediaKey()) !== 32 || strlen($reference->getFileSha256()) !== 32
            || strlen($reference->getFileEncSha256()) !== 32) {
            throw new ProtocolException('External app-state media metadata is incomplete.');
        }
        $path = $this->files->accountPath('appstate', $this->accountId . ':' . $purpose, '.blob');
        $this->files->delete($path);
        $cleanupPath = $path;
        try {
            $this->downloader->download(
                $reference->getDirectPath(), MediaType::APP_STATE, $reference->getMediaKey(),
                $reference->getFileSha256(), $reference->getFileEncSha256(), $length, $path, $deadline
            );
            $raw = $this->files->read($path, $this->maximumExternalBytes);
            if (!is_string($raw) || strlen($raw) !== $length) { throw new ProtocolException('Downloaded app-state blob length is invalid.'); }
            // Do not authenticate/apply remote mutations while their decrypted
            // transport blob remains on disk after a cleanup failure.
            $this->files->delete($path);
            $cleanupPath = null;
            return $raw;
        } finally {
            if (is_string($cleanupPath)) {
                try { $this->files->delete($cleanupPath); } catch (\Throwable $ignored) {}
            }
        }
    }

    private function booleanAttribute(?string $value, string $name): bool
    {
        if ($value === null || $value === 'false' || $value === '0') { return false; }
        if ($value === 'true' || $value === '1') { return true; }
        throw new ProtocolException('App-state ' . $name . ' flag is invalid.');
    }

    /** @param int|string $value */
    private function boundedInteger($value, int $maximum, string $name): int
    {
        if (is_int($value) && $value >= 0 && $value <= $maximum) { return $value; }
        if (is_string($value) && preg_match('/\A(?:0|[1-9][0-9]*)\z/D', $value) === 1
            && strlen($value) <= strlen((string) $maximum)
            && strcmp(str_pad($value, strlen((string) $maximum), '0', STR_PAD_LEFT), (string) $maximum) <= 0) {
            return (int) $value;
        }
        throw new ProtocolException(ucfirst($name) . ' is invalid or too large.');
    }
}
