<?php

declare(strict_types=1);

namespace PhpWA\Runtime;

use InvalidArgumentException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\OutboxEvent;

/** Delivers durable application events without holding a database transaction. */
final class OutboxWorker
{
    private const ERROR_CODE = 'outbox_handler_failed';
    /** @var string */ private $accountId;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var OutboxHandlerInterface */ private $handler;
    /** @var string */ private $leaseOwner;
    /** @var callable */ private $clock;
    /** @var int */ private $leaseSeconds;
    /** @var int */ private $maximumAttempts;
    /** @var int */ private $baseDelaySeconds;

    public function __construct(
        string $accountId,
        AccountStoreInterface $accounts,
        OutboxHandlerInterface $handler,
        ?string $leaseOwner = null,
        ?callable $clock = null,
        int $leaseSeconds = 60,
        int $maximumAttempts = 10,
        int $baseDelaySeconds = 2
    ) {
        self::assertAccountId($accountId);
        if ($leaseSeconds < 5 || $leaseSeconds > 3600 || $maximumAttempts < 1 || $maximumAttempts > 100
            || $baseDelaySeconds < 1 || $baseDelaySeconds > 300) {
            throw new InvalidArgumentException('Outbox worker bounds are invalid.');
        }
        if ($leaseOwner === null) { $leaseOwner = 'phpwa-' . bin2hex(random_bytes(16)); }
        if (preg_match('/\A[A-Za-z0-9._:-]{1,128}\z/D', $leaseOwner) !== 1) {
            throw new InvalidArgumentException('Outbox lease owner is invalid.');
        }
        $this->accountId = $accountId; $this->accounts = $accounts; $this->handler = $handler;
        $this->leaseOwner = $leaseOwner; $this->clock = $clock ?? static function (): int { return time(); };
        $this->leaseSeconds = $leaseSeconds; $this->maximumAttempts = $maximumAttempts;
        $this->baseDelaySeconds = $baseDelaySeconds;
    }

    public function run(Deadline $deadline, int $maximumEvents = 100): OutboxRunResult
    {
        if ($maximumEvents < 1 || $maximumEvents > 10000) {
            throw new InvalidArgumentException('Outbox run event limit is invalid.');
        }
        $delivered = 0; $failed = 0; $deadLettered = 0; $lastError = null;
        while ($delivered + $failed < $maximumEvents && !$deadline->isExpired()) {
            $now = $this->now();
            $event = $this->accounts->transaction(
                $this->accountId,
                function (AccountTransactionInterface $tx) use ($now): ?OutboxEvent {
                    $events = $tx->claimOutbox($this->leaseOwner, 1, $now, $this->leaseSeconds);
                    return $events[0] ?? null;
                }
            );
            if (!$event instanceof OutboxEvent) { break; }
            try {
                $this->handler->handle($this->accountId, $event, $deadline);
                $this->accounts->transaction(
                    $this->accountId,
                    function (AccountTransactionInterface $tx) use ($event): void {
                        $tx->completeOutbox($event->id(), $this->leaseOwner);
                    }
                );
                $delivered++;
            } catch (\Throwable $error) {
                // Handler exceptions may contain SQL, paths, tokens or message
                // data. Their text is deliberately neither returned nor saved.
                $failed++; $lastError = self::ERROR_CODE;
                if ($event->attempts() >= $this->maximumAttempts) {
                    $failedAt = $this->now();
                    $this->accounts->transaction(
                        $this->accountId,
                        function (AccountTransactionInterface $tx) use ($event, $failedAt, $lastError): void {
                            $payload = $this->json([
                                'v' => 2, 'event_id' => $event->id(), 'type' => $event->type(),
                                'payload' => json_decode($event->payload(), true, 128, JSON_THROW_ON_ERROR),
                                'occurred_at' => $event->occurredAt(), 'attempts' => $event->attempts(),
                                'failed_at' => $failedAt, 'error_code' => $lastError,
                            ]);
                            $known = $tx->get('outbox_dead_letter', $event->id());
                            $tx->put('outbox_dead_letter', $event->id(), $payload, $known === null ? 0 : $known->revision());
                            $tx->completeOutbox($event->id(), $this->leaseOwner);
                        }
                    );
                    $deadLettered++;
                } else {
                    $availableAt = $this->retryAt($this->now(), $event->attempts());
                    $this->accounts->transaction(
                        $this->accountId,
                        function (AccountTransactionInterface $tx) use ($event, $availableAt): void {
                            $tx->releaseOutbox($event->id(), $this->leaseOwner, $availableAt);
                        }
                    );
                }
                // Preserve event order and avoid a tight loop around a broken sink.
                break;
            }
        }
        return new OutboxRunResult($delivered, $failed, $deadLettered, $lastError);
    }

    private function now(): int
    {
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('Outbox worker clock returned an invalid timestamp.'); }
        return $now;
    }

    private function retryAt(int $now, int $attempt): int
    {
        $power = min(10, max(0, $attempt - 1));
        $delay = min(3600, $this->baseDelaySeconds * (1 << $power));
        return $now > PHP_INT_MAX - $delay ? PHP_INT_MAX : $now + $delay;
    }

    /** @param array<string,mixed> $value */
    private function json(array $value): string
    {
        try { $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Unable to serialize an outbox dead letter.', 0, $error); }
        if (!is_string($json) || $json === '' || strlen($json) > 16777216) {
            throw new ProtocolException('Outbox dead letter exceeds its size limit.');
        }
        return $json;
    }

    private static function assertAccountId(string $accountId): void
    {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Outbox worker account ID is invalid.');
        }
    }
}
