<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Auth\CredentialsSerializer;
use PhpWA\Exception\StorageConflictException;
use PhpWA\Exception\StorageException;

final class EncryptedFileCredentialStore implements CredentialStoreInterface
{
    private const RECORD_VERSION = 1;
    private const MAX_FILE_BYTES = 25165824;

    /** @var PrivateFileArea */ private $files;
    /** @var SecretCipherInterface */ private $cipher;
    /** @var CredentialsSerializer */ private $serializer;

    public function __construct(
        string $directory,
        SecretCipherInterface $cipher,
        ?CredentialsSerializer $serializer = null
    ) {
        $this->files = new PrivateFileArea($directory);
        $this->cipher = $cipher;
        $this->serializer = $serializer ?? new CredentialsSerializer();
    }

    public function load(string $accountId): ?CredentialRecord
    {
        $lock = $this->lock($accountId);
        try {
            return $this->loadUnlocked($accountId);
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    public function save(string $accountId, AuthenticationCredentials $credentials, int $expectedRevision): CredentialRecord
    {
        if ($expectedRevision < 0 || $expectedRevision === PHP_INT_MAX) {
            throw new InvalidArgumentException('Expected credential revision is invalid.');
        }
        $lock = $this->lock($accountId);
        try {
            $current = $this->loadUnlocked($accountId);
            $currentRevision = $current === null ? 0 : $current->revision();
            if ($currentRevision !== $expectedRevision) {
                throw new StorageConflictException('Credential state was changed by another worker.');
            }
            $newRevision = $expectedRevision + 1;
            $recordJson = $this->encodeRecord($accountId, $credentials, $newRevision);
            $envelope = $this->cipher->encrypt($this->context($accountId), $recordJson);
            $this->files->writeAtomically($this->dataPath($accountId), $envelope);
            return new CredentialRecord($credentials, $newRevision);
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    public function delete(string $accountId, int $expectedRevision): void
    {
        if ($expectedRevision < 1) {
            throw new InvalidArgumentException('Expected credential revision is invalid.');
        }
        $lock = $this->lock($accountId);
        try {
            $current = $this->loadUnlocked($accountId);
            if ($current === null || $current->revision() !== $expectedRevision) {
                throw new StorageConflictException('Credential state was changed by another worker.');
            }
            $this->files->delete($this->dataPath($accountId));
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    private function loadUnlocked(string $accountId): ?CredentialRecord
    {
        $envelope = $this->files->read($this->dataPath($accountId), self::MAX_FILE_BYTES);
        if ($envelope === null) {
            return null;
        }
        $recordJson = $this->cipher->decrypt($this->context($accountId), $envelope);
        try {
            $record = json_decode($recordJson, true, 16, JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Credential record is malformed.', 0, $error);
        }
        if (!is_array($record) || array_keys($record) !== ['v', 'account_hash', 'revision', 'credentials']) {
            throw new StorageException('Credential record has an invalid shape.');
        }
        if (($record['v'] ?? null) !== self::RECORD_VERSION || !is_int($record['revision'] ?? null) || $record['revision'] < 1) {
            throw new StorageException('Credential record version or revision is invalid.');
        }
        if (!is_string($record['account_hash'] ?? null) || !hash_equals(hash('sha256', $accountId), $record['account_hash'])) {
            throw new StorageException('Credential record belongs to a different account.');
        }
        if (!is_string($record['credentials'] ?? null)) {
            throw new StorageException('Credential record payload is malformed.');
        }
        $credentialsJson = base64_decode($record['credentials'], true);
        if (!is_string($credentialsJson) || base64_encode($credentialsJson) !== $record['credentials']) {
            throw new StorageException('Credential record payload is malformed.');
        }
        return new CredentialRecord($this->serializer->deserialize($credentialsJson), $record['revision']);
    }

    private function encodeRecord(string $accountId, AuthenticationCredentials $credentials, int $revision): string
    {
        try {
            $json = json_encode([
                'v' => self::RECORD_VERSION,
                'account_hash' => hash('sha256', $accountId),
                'revision' => $revision,
                'credentials' => base64_encode($this->serializer->serialize($credentials)),
            ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        } catch (StorageException $error) {
            throw $error;
        } catch (\Throwable $error) {
            throw new StorageException('Unable to encode the credential record.', 0, $error);
        }
        if (!is_string($json)) {
            throw new StorageException('Unable to encode the credential record.');
        }
        return $json;
    }

    /** @return resource */
    private function lock(string $accountId)
    {
        $handle = $this->files->openLock($this->files->accountPath('credentials', $accountId, '.lock'), false);
        if (!is_resource($handle)) {
            throw new StorageException('Unable to acquire the credential storage lock.');
        }
        return $handle;
    }

    private function dataPath(string $accountId): string
    {
        return $this->files->accountPath('credentials', $accountId, '.enc');
    }

    private function context(string $accountId): string
    {
        return "credentials\0" . $accountId;
    }
}
