<?php

declare(strict_types=1);

namespace PhpWA\Client;

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

/** Persists signalling-only call events before acknowledging them. */
final class CallProcessor
{
    /** @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('Call 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() !== 'call') { return false; }

        $stanzaId = $this->identifier($node->attribute('id'), 'call stanza ID');
        $chatJid = $this->jid($node->attribute('from'), 'call source JID');
        $children = $node->childNodes();
        if (count($children) !== 1) { throw new ProtocolException('Call stanza must contain exactly one signalling event.'); }
        $event = $children[0];
        $status = $this->identifier($event->tag(), 'call status');
        $callId = $this->identifier($event->attribute('call-id'), 'call ID');
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('Call processor clock returned an invalid timestamp.'); }

        $current = $this->metadata(
            $node, $event, $chatJid, $stanzaId, $callId, $status, $this->binary->encode($node)
        );
        $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($stanzaId, $callId, $status, $current, $now): void {
                $dedupe = 'call:' . $stanzaId;
                if ($tx->hasIncoming($dedupe)) { return; }

                $stored = $tx->get('call_state', $callId);
                $previous = $stored instanceof StoredValue ? $this->decodeStored($stored) : [];
                $payload = $this->mergeMetadata($previous, $current);
                $json = $this->json($payload);

                if ($status === 'offer' || $status === 'offer_notice') {
                    $state = $this->json([
                        'v' => 1,
                        'call_id' => $callId,
                        'chat_jid' => $payload['chat_jid'],
                        'caller_jid' => $payload['caller_jid'],
                        'caller_pn_jid' => $payload['caller_pn_jid'],
                        'caller_lid_jid' => $payload['caller_lid_jid'],
                        'group_jid' => $payload['group_jid'],
                        'is_group' => $payload['is_group'],
                        'is_video' => $payload['is_video'],
                        'started_at' => $payload['timestamp'],
                    ]);
                    $tx->put('call_state', $callId, $state, $stored === null ? 0 : $stored->revision());
                } elseif (in_array($status, ['accept', 'reject', 'terminate', 'timeout'], true) && $stored !== null) {
                    $tx->delete('call_state', $callId, $stored->revision());
                }

                if (!$tx->stageIncoming($dedupe, EventType::CALL_RECEIVED, $json, $now)) {
                    throw new ProtocolException('Call event deduplication changed inside one transaction.');
                }
            }
        );

        $attrs = ['class' => 'call', 'id' => $stanzaId, 'to' => $chatJid];
        foreach (['participant', 'recipient', 'type'] as $name) {
            $value = $node->attribute($name);
            if ($value !== null) { $attrs[$name] = $this->identifier($value, 'call ' . $name); }
        }
        $this->connection->send(new BinaryNode('ack', $attrs), $deadline);
        return true;
    }

    /** @return array<string,mixed> */
    private function metadata(
        BinaryNode $node,
        BinaryNode $event,
        string $chatJid,
        string $stanzaId,
        string $callId,
        string $status,
        string $wire
    ): array {
        $caller = $event->attribute('from') ?? $event->attribute('call-creator') ?? $chatJid;
        $callerJid = $this->jid($caller, 'call creator JID');
        $callerPn = $this->optionalJid($event->attribute('caller_pn'), 'caller PN JID');
        $callerLid = $this->optionalJid($event->attribute('caller_lid'), 'caller LID JID');
        $group = $this->optionalJid($event->attribute('group-jid'), 'call group JID');
        $timestamp = $this->decimal($node->attribute('t'), 'call timestamp', PHP_INT_MAX, true);
        $latency = $event->attribute('latency') ?? $event->attribute('latency_ms') ?? $event->attribute('latency-ms');
        return [
            'schema_version' => 1,
            'stanza_id' => $stanzaId,
            'call_id' => $callId,
            'status' => $status,
            'chat_jid' => $chatJid,
            'caller_jid' => $callerJid,
            'caller_pn_jid' => $callerPn,
            'caller_lid_jid' => $callerLid,
            'group_jid' => $group,
            'timestamp' => $timestamp,
            'offline' => $node->attribute('offline') !== null,
            'is_group' => $event->attribute('type') === 'group' || $group !== null,
            'is_video' => $event->child('video') instanceof BinaryNode || $event->attribute('media') === 'video',
            'reason' => $this->optionalText($event->attribute('reason'), 'call reason'),
            'media' => $this->optionalText($event->attribute('media'), 'call media'),
            'call_type' => $this->optionalText($event->attribute('type'), 'call type'),
            'platform' => $this->optionalText($node->attribute('platform'), 'call platform'),
            'version' => $this->optionalText($node->attribute('version'), 'call version'),
            'latency_ms' => $latency === null ? null : $this->decimal($latency, 'call latency', 86400000, false),
            'node_base64' => base64_encode($wire),
        ];
    }

    /** @param array<string,mixed> $previous @param array<string,mixed> $current @return array<string,mixed> */
    private function mergeMetadata(array $previous, array $current): array
    {
        foreach (['chat_jid', 'caller_jid', 'caller_pn_jid', 'caller_lid_jid', 'group_jid'] as $key) {
            if (($current[$key] ?? null) === null || ($current[$key] ?? '') === '') {
                $current[$key] = $previous[$key] ?? $current[$key] ?? null;
            }
        }
        if (!($current['is_group'] ?? false) && ($previous['is_group'] ?? false)) { $current['is_group'] = true; }
        if (!($current['is_video'] ?? false) && ($previous['is_video'] ?? false)) { $current['is_video'] = true; }
        if (($current['timestamp'] ?? 0) === 0 && isset($previous['started_at']) && is_int($previous['started_at'])) {
            $current['timestamp'] = $previous['started_at'];
        }
        return $current;
    }

    /** @return array<string,mixed> */
    private function decodeStored(StoredValue $stored): array
    {
        try { $data = json_decode($stored->value(), true, 16, JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Stored call state is malformed.', 0, $error); }
        if (!is_array($data) || ($data['v'] ?? null) !== 1) { throw new ProtocolException('Stored call state has an invalid version.'); }
        return $data;
    }

    /** @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 call state.', 0, $error); }
        if (!is_string($json) || $json === '' || strlen($json) > 4194304) { throw new ProtocolException('Call state exceeds its limit.'); }
        return $json;
    }

    private function jid(?string $value, string $name): string
    {
        $jid = Jid::parse($value);
        if (!$jid instanceof Jid) { throw new ProtocolException(ucfirst($name) . ' is invalid.'); }
        return (string) $jid;
    }

    private function optionalJid(?string $value, string $name): ?string
    {
        return $value === null ? null : $this->jid($value, $name);
    }

    private function identifier(?string $value, string $name): string
    {
        if ($value === null || preg_match('/\A[A-Za-z0-9._:@-]{1,512}\z/D', $value) !== 1) {
            throw new ProtocolException(ucfirst($name) . ' is invalid.');
        }
        return $value;
    }

    private function optionalText(?string $value, string $name): ?string
    {
        if ($value === null) { return null; }
        if ($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 decimal(?string $value, string $name, int $maximum, bool $optional): int
    {
        if ($optional && $value === null) { return 0; }
        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;
    }
}
