<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use UnexpectedValueException;
use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Auth\CredentialsSerializer;

/**
 * Keeps credentials in the same account transaction domain as Signal state,
 * deduplication and the application outbox.
 */
final class TransactionalCredentialStore implements CredentialStoreInterface
{
    public const COLLECTION = 'auth';
    public const KEY = 'credentials';

    /** @var AccountStoreInterface */ private $accounts;
    /** @var CredentialsSerializer */ private $serializer;

    public function __construct(AccountStoreInterface $accounts, ?CredentialsSerializer $serializer = null)
    {
        $this->accounts = $accounts;
        $this->serializer = $serializer ?? new CredentialsSerializer();
    }

    public function load(string $accountId): ?CredentialRecord
    {
        return $this->accounts->transaction($accountId, function (AccountTransactionInterface $transaction): ?CredentialRecord {
            return $this->loadFromTransaction($transaction);
        });
    }

    /**
     * Creates the initial credentials inside the same account transaction
     * that checks for their absence. This keeps concurrent Client
     * constructors from racing a read followed by a separate insert.
     *
     * @param callable():AuthenticationCredentials $factory
     */
    public function initializeIfMissing(string $accountId, callable $factory): CredentialRecord
    {
        return $this->accounts->transaction(
            $accountId,
            function (AccountTransactionInterface $transaction) use ($factory): CredentialRecord {
                $record = $this->loadFromTransaction($transaction);
                if ($record instanceof CredentialRecord) {
                    return $record;
                }
                $credentials = $factory();
                if (!$credentials instanceof AuthenticationCredentials) {
                    throw new UnexpectedValueException('Credential factory returned an invalid value.');
                }
                return $this->saveInTransaction($transaction, $credentials, 0);
            }
        );
    }

    public function save(string $accountId, AuthenticationCredentials $credentials, int $expectedRevision): CredentialRecord
    {
        self::assertExpectedRevision($expectedRevision, true);
        return $this->accounts->transaction(
            $accountId,
            function (AccountTransactionInterface $transaction) use ($credentials, $expectedRevision): CredentialRecord {
                return $this->saveInTransaction($transaction, $credentials, $expectedRevision);
            }
        );
    }

    public function delete(string $accountId, int $expectedRevision): void
    {
        self::assertExpectedRevision($expectedRevision, false);
        $this->accounts->transaction($accountId, static function (AccountTransactionInterface $transaction) use ($expectedRevision): void {
            $transaction->delete(self::COLLECTION, self::KEY, $expectedRevision);
        });
    }

    public function loadFromTransaction(AccountTransactionInterface $transaction): ?CredentialRecord
    {
        $stored = $transaction->get(self::COLLECTION, self::KEY);
        if ($stored === null) { return null; }
        return new CredentialRecord($this->serializer->deserialize($stored->value()), $stored->revision());
    }

    public function saveInTransaction(
        AccountTransactionInterface $transaction,
        AuthenticationCredentials $credentials,
        int $expectedRevision
    ): CredentialRecord {
        self::assertExpectedRevision($expectedRevision, true);
        $stored = $transaction->put(
            self::COLLECTION,
            self::KEY,
            $this->serializer->serialize($credentials),
            $expectedRevision
        );
        return new CredentialRecord($credentials, $stored->revision());
    }

    private static function assertExpectedRevision(int $revision, bool $allowZero): void
    {
        if ($revision < ($allowZero ? 0 : 1) || $revision === PHP_INT_MAX) {
            throw new InvalidArgumentException('Expected credential revision is invalid.');
        }
    }
}
