<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use PhpWA\Exception\StorageConflictException;
use PhpWA\Exception\StorageException;

/** @internal Mutable snapshot owned by EncryptedFileAccountStore. */
final class FileAccountTransaction implements AccountTransactionInterface
{
    private const MAX_VALUE_BYTES = 16777216;
    private const MAX_STATE_ROWS = 100000;
    private const MAX_DEDUPE_ROWS = 500000;
    private const MAX_OUTBOX_ROWS = 100000;
    private const MAX_RETRY_ROWS = 100000;

    /** @var string */ private $accountHash;
    /** @var array<string,array<string,array{key:string,value:string,revision:int}>> */ private $state;
    /** @var array<string,int> */ private $dedupe;
    /** @var array<string,array<string,mixed>> */ private $outbox;
    /** @var array<string,array{attempts:int,next_allowed_at:int,updated_at:int}> */ private $retries;
    /** @var bool */ private $dirty = false;
    /** @var bool */ private $active = true;

    /**
     * @param array<string,array<string,array{key:string,value:string,revision:int}>> $state
     * @param array<string,int> $dedupe
     * @param array<string,array<string,mixed>> $outbox
     * @param array<string,array{attempts:int,next_allowed_at:int,updated_at:int}> $retries
     */
    public function __construct(string $accountHash, array $state, array $dedupe, array $outbox, array $retries)
    {
        $this->accountHash = $accountHash;
        $this->state = $state;
        $this->dedupe = $dedupe;
        $this->outbox = $outbox;
        $this->retries = $retries;
    }

    public function get(string $collection, string $key): ?StoredValue
    {
        $this->assertActive();
        self::assertCollectionAndKey($collection, $key);
        $index = self::keyIndex($key);
        if (!isset($this->state[$collection][$index])) {
            return null;
        }
        $row = $this->state[$collection][$index];
        return new StoredValue($row['value'], $row['revision']);
    }

    public function list(string $collection, int $limit = 1000): array
    {
        $this->assertActive();
        self::assertCollectionAndKey($collection, 'probe');
        if ($limit < 1 || $limit > 10000) {
            throw new InvalidArgumentException('Account state list limit is invalid.');
        }
        $rows = $this->state[$collection] ?? [];
        uasort($rows, static function (array $left, array $right): int { return strcmp($left['key'], $right['key']); });
        $result = [];
        foreach ($rows as $row) {
            if (count($result) >= $limit) { break; }
            $result[$row['key']] = new StoredValue($row['value'], $row['revision']);
        }
        return $result;
    }

    public function put(string $collection, string $key, string $value, ?int $expectedRevision = null): StoredValue
    {
        $this->assertActive();
        self::assertCollectionAndKey($collection, $key);
        if (strlen($value) > self::MAX_VALUE_BYTES) {
            throw new InvalidArgumentException('Account state value exceeds its limit.');
        }
        self::assertExpectedRevision($expectedRevision);
        $index = self::keyIndex($key);
        $current = $this->state[$collection][$index] ?? null;
        $currentRevision = $current === null ? 0 : $current['revision'];
        if ($expectedRevision !== null && $expectedRevision !== $currentRevision) {
            throw new StorageConflictException('Account state was changed by another operation.');
        }
        if ($currentRevision === PHP_INT_MAX) {
            throw new StorageException('Account state revision is exhausted.');
        }
        if ($current === null && $this->stateRowCount() >= self::MAX_STATE_ROWS) {
            throw new StorageException('Account state row limit is reached.');
        }
        $newRevision = $currentRevision + 1;
        $this->state[$collection][$index] = ['key' => $key, 'value' => $value, 'revision' => $newRevision];
        $this->dirty = true;
        return new StoredValue($value, $newRevision);
    }

    public function delete(string $collection, string $key, ?int $expectedRevision = null): void
    {
        $this->assertActive();
        self::assertCollectionAndKey($collection, $key);
        self::assertExpectedRevision($expectedRevision);
        $index = self::keyIndex($key);
        $current = $this->state[$collection][$index] ?? null;
        $currentRevision = $current === null ? 0 : $current['revision'];
        if ($expectedRevision !== null && $expectedRevision !== $currentRevision) {
            throw new StorageConflictException('Account state was changed by another operation.');
        }
        if ($current !== null) {
            unset($this->state[$collection][$index]);
            if ($this->state[$collection] === []) {
                unset($this->state[$collection]);
            }
            $this->dirty = true;
        }
    }

    public function hasIncoming(string $messageId): bool
    {
        $this->assertActive();
        return isset($this->dedupe[self::messageHash($messageId)]);
    }

    public function stageIncoming(string $messageId, string $eventType, string $jsonPayload, int $receivedAt): bool
    {
        $this->assertActive();
        self::assertMessageId($messageId);
        OutboxEvent::assertType($eventType);
        OutboxEvent::assertPayload($jsonPayload);
        if ($receivedAt < 0) {
            throw new InvalidArgumentException('Incoming message timestamp is invalid.');
        }
        $messageHash = self::messageHash($messageId);
        if (isset($this->dedupe[$messageHash])) {
            return false;
        }
        if (count($this->dedupe) >= self::MAX_DEDUPE_ROWS || count($this->outbox) >= self::MAX_OUTBOX_ROWS) {
            throw new StorageException('Incoming dedupe or outbox limit is reached; prune or drain the account first.');
        }
        $eventId = OutboxEvent::incomingId($this->accountHash, $messageId, $eventType);
        $this->dedupe[$messageHash] = $receivedAt;
        $this->outbox[$eventId] = [
            'type' => $eventType,
            'payload' => $jsonPayload,
            'occurred_at' => $receivedAt,
            'attempts' => 0,
            'available_at' => $receivedAt,
            'lease_owner' => null,
            'lease_until' => 0,
        ];
        unset($this->retries[$messageHash]);
        $this->dirty = true;
        return true;
    }

    public function claimOutbox(string $leaseOwner, int $limit, int $now, int $leaseSeconds): array
    {
        $this->assertActive();
        self::assertLeaseOwner($leaseOwner);
        if ($limit < 1 || $limit > 100 || $now < 0 || $leaseSeconds < 5 || $leaseSeconds > 3600 || $now > PHP_INT_MAX - $leaseSeconds) {
            throw new InvalidArgumentException('Outbox claim bounds are invalid.');
        }
        $ids = array_keys($this->outbox);
        usort($ids, function (string $left, string $right): int {
            $a = $this->outbox[$left];
            $b = $this->outbox[$right];
            return [$a['available_at'], $a['occurred_at'], $left] <=> [$b['available_at'], $b['occurred_at'], $right];
        });
        $events = [];
        foreach ($ids as $id) {
            if (count($events) >= $limit) { break; }
            $row = $this->outbox[$id];
            if ($row['available_at'] > $now || ($row['lease_owner'] !== null && $row['lease_until'] > $now)) {
                continue;
            }
            if ($row['attempts'] === PHP_INT_MAX) {
                throw new StorageException('Outbox attempt counter is exhausted.');
            }
            $row['attempts']++;
            $row['lease_owner'] = $leaseOwner;
            $row['lease_until'] = $now + $leaseSeconds;
            $this->outbox[$id] = $row;
            $events[] = new OutboxEvent($id, $row['type'], $row['payload'], $row['occurred_at'], $row['attempts'], $leaseOwner);
            $this->dirty = true;
        }
        return $events;
    }

    public function completeOutbox(string $eventId, string $leaseOwner): void
    {
        $this->assertActive();
        self::assertEventId($eventId);
        self::assertLeaseOwner($leaseOwner);
        $this->assertLeaseOwnership($eventId, $leaseOwner);
        unset($this->outbox[$eventId]);
        $this->dirty = true;
    }

    public function releaseOutbox(string $eventId, string $leaseOwner, int $availableAt): void
    {
        $this->assertActive();
        self::assertEventId($eventId);
        self::assertLeaseOwner($leaseOwner);
        if ($availableAt < 0) {
            throw new InvalidArgumentException('Outbox availability time is invalid.');
        }
        $this->assertLeaseOwnership($eventId, $leaseOwner);
        $this->outbox[$eventId]['lease_owner'] = null;
        $this->outbox[$eventId]['lease_until'] = 0;
        $this->outbox[$eventId]['available_at'] = $availableAt;
        $this->dirty = true;
    }

    public function hasPendingOutbox(): bool
    {
        $this->assertActive();
        return $this->outbox !== [];
    }

    public function registerRetry(string $messageId, int $now, int $maximumAttempts = 5, int $baseDelaySeconds = 2): RetryDecision
    {
        $this->assertActive();
        self::assertMessageId($messageId);
        if ($now < 0 || $maximumAttempts < 1 || $maximumAttempts > 10 || $baseDelaySeconds < 1 || $baseDelaySeconds > 60) {
            throw new InvalidArgumentException('Retry bounds are invalid.');
        }
        $hash = self::messageHash($messageId);
        $row = $this->retries[$hash] ?? ['attempts' => 0, 'next_allowed_at' => 0, 'updated_at' => $now];
        if ($row['attempts'] >= $maximumAttempts) {
            return new RetryDecision($row['attempts'], false, true, $row['next_allowed_at']);
        }
        if ($row['next_allowed_at'] > $now) {
            return new RetryDecision($row['attempts'], false, false, $row['next_allowed_at']);
        }
        if (!isset($this->retries[$hash]) && count($this->retries) >= self::MAX_RETRY_ROWS) {
            throw new StorageException('Retry state limit is reached.');
        }
        $attempt = $row['attempts'] + 1;
        $exponent = min(7, $attempt - 1);
        $delay = min(300, $baseDelaySeconds * (1 << $exponent));
        $nextAllowed = $now > PHP_INT_MAX - $delay ? PHP_INT_MAX : $now + $delay;
        $this->retries[$hash] = ['attempts' => $attempt, 'next_allowed_at' => $nextAllowed, 'updated_at' => $now];
        $this->dirty = true;
        return new RetryDecision($attempt, true, false, $nextAllowed);
    }

    public function clearRetry(string $messageId): void
    {
        $this->assertActive();
        $hash = self::messageHash($messageId);
        if (isset($this->retries[$hash])) {
            unset($this->retries[$hash]);
            $this->dirty = true;
        }
    }

    public function prune(int $dedupeBefore, int $retryBefore): void
    {
        $this->assertActive();
        if ($dedupeBefore < 0 || $retryBefore < 0) {
            throw new InvalidArgumentException('Prune timestamps are invalid.');
        }
        // Keep every dedupe marker while an application event is still in the
        // outbox. Without the original message ID in an outbox row, pruning a
        // subset would allow a redelivery to reset/replace a pending event.
        if ($this->outbox === []) {
            foreach ($this->dedupe as $hash => $timestamp) {
                if ($timestamp < $dedupeBefore) {
                    unset($this->dedupe[$hash]);
                    $this->dirty = true;
                }
            }
        }
        foreach ($this->retries as $hash => $row) {
            if ($row['updated_at'] < $retryBefore) {
                unset($this->retries[$hash]);
                $this->dirty = true;
            }
        }
    }

    public function isDirty(): bool { return $this->dirty; }

    /** @return array<string,mixed> */
    public function exportAndClose(): array
    {
        $this->assertActive();
        $this->active = false;
        return ['state' => $this->state, 'dedupe' => $this->dedupe, 'outbox' => $this->outbox, 'retries' => $this->retries];
    }

    public function closeWithoutExport(): void
    {
        $this->active = false;
    }

    private function assertLeaseOwnership(string $eventId, string $leaseOwner): void
    {
        if (!isset($this->outbox[$eventId]) || !is_string($this->outbox[$eventId]['lease_owner']) || !hash_equals($this->outbox[$eventId]['lease_owner'], $leaseOwner)) {
            throw new StorageConflictException('Outbox event is not leased by this worker.');
        }
    }

    private function stateRowCount(): int
    {
        $count = 0;
        foreach ($this->state as $rows) { $count += count($rows); }
        return $count;
    }

    private function assertActive(): void
    {
        if (!$this->active) {
            throw new StorageException('Account transaction is already closed.');
        }
    }

    private static function assertCollectionAndKey(string $collection, string $key): void
    {
        if (preg_match('/\A[a-z][a-z0-9_]{0,63}\z/D', $collection) !== 1) {
            throw new InvalidArgumentException('Account state collection is invalid.');
        }
        if ($key === '' || strlen($key) > 1024 || preg_match('//u', $key) !== 1 || preg_match('/[\x00-\x1f\x7f]/', $key) === 1) {
            throw new InvalidArgumentException('Account state key is invalid.');
        }
    }

    private static function assertExpectedRevision(?int $revision): void
    {
        if ($revision !== null && $revision < 0) {
            throw new InvalidArgumentException('Expected account state revision is invalid.');
        }
    }

    private static function assertMessageId(string $messageId): void
    {
        if ($messageId === '' || strlen($messageId) > 512 || preg_match('/[\x00-\x1f\x7f]/', $messageId) === 1) {
            throw new InvalidArgumentException('WhatsApp message ID is invalid.');
        }
    }

    private static function messageHash(string $messageId): string
    {
        self::assertMessageId($messageId);
        return hash('sha256', $messageId);
    }

    private static function assertLeaseOwner(string $owner): void
    {
        if (preg_match('/\A[A-Za-z0-9._:-]{1,128}\z/D', $owner) !== 1) {
            throw new InvalidArgumentException('Outbox lease owner is invalid.');
        }
    }

    private static function assertEventId(string $eventId): void
    {
        if (preg_match('/\A[a-f0-9]{64}\z/D', $eventId) !== 1) {
            throw new InvalidArgumentException('Outbox event ID is invalid.');
        }
    }

    private static function keyIndex(string $key): string
    {
        return 'b64:' . base64_encode($key);
    }
}
