<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\AppState\AppStateName;
use PhpWA\Exception\ProtocolException;
use PhpWA\Integration\EventType;
use PhpWA\Protocol\Binary\BinaryCodec;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;

/** Commits raw notifications and derived sync jobs before acknowledging them. */
final class NotificationProcessor
{
    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var BinaryCodec */ private $binary;
    /** @var callable */ private $clock;

    public function __construct(string $accountId, ProtocolConnectionInterface $connection, AccountStoreInterface $accounts, ?BinaryCodec $binary = null, ?callable $clock = null)
    {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Notification processor account ID is invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->accounts = $accounts;
        $this->binary = $binary ?? new BinaryCodec(); $this->clock = $clock ?? static function (): int { return time(); };
    }

    public function process(BinaryNode $node, Deadline $deadline): bool
    {
        if ($node->tag() !== 'notification') { return false; }
        $id = $this->identifier($node->attribute('id'), 'notification ID');
        $type = $this->identifier($node->attribute('type'), 'notification type');
        $from = $this->identifier($node->attribute('from'), 'notification source');
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('Notification clock returned an invalid timestamp.'); }
        $wire = $this->binary->encode($node);
        $payload = $this->json([
            'schema_version' => 1, 'id' => $id, 'type' => $type, 'from' => $from,
            'timestamp' => $this->timestamp($node->attribute('t')), 'node_base64' => base64_encode($wire),
        ]);
        $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($node, $id, $type, $payload, $now): void {
                $tx->stageIncoming('notification:' . $id, EventType::NOTIFICATION_RECEIVED, $payload, $now);
                if ($type === 'server_sync') { $this->stageAppStateJobs($tx, $node, $now); }
                if ($type === 'encrypt') {
                    $count = $node->child('count');
                    if ($count instanceof BinaryNode && $count->attribute('value') !== null) {
                        $remaining = $this->decimal($count->attribute('value'), 'pre-key count', 1000000);
                        if ($remaining < 20) {
                            $tx->put('maintenance_job', 'prekey_upload', $this->json(['v' => 1, 'requested_at' => $now, 'remaining' => $remaining]));
                        }
                    }
                }
            }
        );
        $attrs = ['class' => 'notification', 'id' => $id, 'to' => $from, 'type' => $type];
        $participant = $node->attribute('participant'); if ($participant !== null) { $attrs['participant'] = $participant; }
        $this->connection->send(new BinaryNode('ack', $attrs), $deadline);
        return true;
    }

    private function stageAppStateJobs(AccountTransactionInterface $tx, BinaryNode $node, int $now): void
    {
        $collections = $node->childrenNamed('collection');
        if ($collections === []) {
            $container = $node->child('sync');
            if ($container instanceof BinaryNode) { $collections = $container->childrenNamed('collection'); }
        }
        if (count($collections) > 20) { throw new ProtocolException('Server-sync notification contains too many collections.'); }
        foreach ($collections as $collection) {
            $name = (string) $collection->attribute('name'); AppStateName::assert($name);
            $version = $this->decimal($collection->attribute('version'), 'app-state version', PHP_INT_MAX);
            $known = $tx->get('app_state_sync_job', $name);
            $target = $version;
            $attempts = 0; $availableAt = 0; $leaseOwner = null; $leaseUntil = 0; $requestedAt = $now;
            if ($known !== null) {
                try { $old = json_decode($known->value(), true, 8, JSON_THROW_ON_ERROR); }
                catch (\Throwable $error) { throw new ProtocolException('Stored app-state sync job is malformed.', 0, $error); }
                if (!is_array($old) || ($old['v'] ?? null) !== 1 || !is_int($old['target_version'] ?? null)
                    || $old['target_version'] < 0 || !is_int($old['requested_at'] ?? null) || $old['requested_at'] < 0) {
                    throw new ProtocolException('Stored app-state sync job has an invalid shape.');
                }
                $target = max($target, $old['target_version']);
                $requestedAt = min($requestedAt, $old['requested_at']);
                $attempts = $old['attempts'] ?? 0; $availableAt = $old['available_at'] ?? 0;
                $leaseOwner = $old['lease_owner'] ?? null; $leaseUntil = $old['lease_until'] ?? 0;
                if (!is_int($attempts) || $attempts < 0 || !is_int($availableAt) || $availableAt < 0
                    || ($leaseOwner !== null && (!is_string($leaseOwner) || preg_match('/\A[A-Za-z0-9._:-]{1,128}\z/D', $leaseOwner) !== 1))
                    || !is_int($leaseUntil) || $leaseUntil < 0) {
                    throw new ProtocolException('Stored app-state sync job lease is invalid.');
                }
            }
            $json = $this->json([
                'v' => 1, 'target_version' => $target, 'requested_at' => $requestedAt,
                'attempts' => $attempts, 'available_at' => $availableAt,
                'lease_owner' => $leaseOwner, 'lease_until' => $leaseUntil,
            ]);
            $tx->put('app_state_sync_job', $name, $json, $known === null ? 0 : $known->revision());
        }
    }

    /** @param array<string,mixed> $data */
    private function json(array $data): string
    {
        try { $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Unable to serialize notification state.', 0, $error); }
        if (!is_string($json) || $json === '' || strlen($json) > 4194304) { throw new ProtocolException('Notification state exceeds its limit.'); }
        return $json;
    }

    private function identifier(?string $value, string $name): string
    {
        if ($value === null || $value === '' || strlen($value) > 512 || preg_match('//u', $value) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $value) === 1) { throw new ProtocolException(ucfirst($name) . ' is invalid.'); }
        return $value;
    }

    private function timestamp(?string $value): int { return $value === null ? 0 : $this->decimal($value, 'notification timestamp', PHP_INT_MAX); }

    private function decimal(?string $value, string $name, int $maximum): int
    {
        if ($value === null || preg_match('/\A(?:0|[1-9][0-9]{0,18})\z/D', $value) !== 1 || (float) $value > $maximum) {
            throw new ProtocolException(ucfirst($name) . ' is invalid.');
        }
        return (int) $value;
    }
}
