<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use PhpWA\Exception\StorageException;

/**
 * Crash-safe encrypted snapshot backend for standalone/shared-hosting use.
 * MySQL adapters use the same AccountTransactionInterface contract.
 */
final class EncryptedFileAccountStore implements ResettableAccountStoreInterface
{
    private const VERSION = 1;
    private const MAX_FILE_BYTES = 50331648;

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

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

    public function transaction(string $accountId, callable $callback)
    {
        $lockPath = $this->files->accountPath('state', $accountId, '.lock');
        $lock = $this->files->openLock($lockPath, false);
        if (!is_resource($lock)) {
            throw new StorageException('Unable to acquire the account state lock.');
        }

        $transaction = null;
        try {
            $snapshot = $this->loadSnapshot($accountId);
            $transaction = new FileAccountTransaction(
                hash('sha256', $accountId),
                $snapshot['state'],
                $snapshot['dedupe'],
                $snapshot['outbox'],
                $snapshot['retries']
            );
            $result = $callback($transaction);
            $dirty = $transaction->isDirty();
            $exported = $transaction->exportAndClose();
            if ($dirty) {
                if ($snapshot['revision'] === PHP_INT_MAX) {
                    throw new StorageException('Account snapshot revision is exhausted.');
                }
                $this->saveSnapshot($accountId, $snapshot['revision'] + 1, $exported);
            }
            return $result;
        } catch (\Throwable $error) {
            if ($transaction instanceof FileAccountTransaction) {
                $transaction->closeWithoutExport();
            }
            throw $error;
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    public function deleteAccount(string $accountId): void
    {
        $lockPath = $this->files->accountPath('state', $accountId, '.lock');
        $lock = $this->files->openLock($lockPath, false);
        if (!is_resource($lock)) {
            throw new StorageException('Unable to acquire the account state lock for reset.');
        }
        try {
            // The lock file is deliberately retained: deleting a file that is
            // currently used as the synchronization inode would allow a new
            // process to lock a different inode and enter concurrently.
            $this->files->delete($this->files->accountPath('state', $accountId, '.enc'));
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    /** @return array{revision:int,state:array,dedupe:array,outbox:array,retries:array} */
    private function loadSnapshot(string $accountId): array
    {
        $path = $this->files->accountPath('state', $accountId, '.enc');
        $envelope = $this->files->read($path, self::MAX_FILE_BYTES);
        if ($envelope === null) {
            return ['revision' => 0, 'state' => [], 'dedupe' => [], 'outbox' => [], 'retries' => []];
        }
        $json = $this->cipher->decrypt($this->context($accountId), $envelope);
        try {
            $snapshot = json_decode($json, true, 256, JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Account state snapshot is malformed.', 0, $error);
        }
        return $this->validateSnapshot($snapshot, $accountId);
    }

    /** @param array<string,mixed> $data */
    private function saveSnapshot(string $accountId, int $revision, array $data): void
    {
        $snapshot = [
            'v' => self::VERSION,
            'account_hash' => hash('sha256', $accountId),
            'revision' => $revision,
            'state' => $this->encodeState($data['state']),
            'dedupe' => $data['dedupe'],
            'outbox' => $this->encodeOutbox($data['outbox']),
            'retries' => $data['retries'],
        ];
        try {
            $json = json_encode($snapshot, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Unable to encode the account state snapshot.', 0, $error);
        }
        if (!is_string($json) || strlen($json) > 33554432) {
            throw new StorageException('Account state snapshot exceeds its size limit.');
        }
        $envelope = $this->cipher->encrypt($this->context($accountId), $json);
        $this->files->writeAtomically($this->files->accountPath('state', $accountId, '.enc'), $envelope);
    }

    /** @return array{revision:int,state:array,dedupe:array,outbox:array,retries:array} */
    private function validateSnapshot($snapshot, string $accountId): array
    {
        if (!is_array($snapshot) || array_keys($snapshot) !== ['v', 'account_hash', 'revision', 'state', 'dedupe', 'outbox', 'retries']) {
            throw new StorageException('Account state snapshot has an invalid shape.');
        }
        if ($snapshot['v'] !== self::VERSION || !is_int($snapshot['revision']) || $snapshot['revision'] < 1) {
            throw new StorageException('Account state snapshot version or revision is invalid.');
        }
        if (!is_string($snapshot['account_hash']) || !hash_equals(hash('sha256', $accountId), $snapshot['account_hash'])) {
            throw new StorageException('Account state snapshot belongs to a different account.');
        }
        if (!is_array($snapshot['state']) || !is_array($snapshot['dedupe']) || !is_array($snapshot['outbox']) || !is_array($snapshot['retries'])) {
            throw new StorageException('Account state snapshot collections are malformed.');
        }
        $state = $this->decodeState($snapshot['state']);
        $dedupe = $this->validateDedupe($snapshot['dedupe']);
        $outbox = $this->decodeOutbox($snapshot['outbox']);
        $retries = $this->validateRetries($snapshot['retries']);
        return ['revision' => $snapshot['revision'], 'state' => $state, 'dedupe' => $dedupe, 'outbox' => $outbox, 'retries' => $retries];
    }

    /** @param array<string,array<string,array{key:string,value:string,revision:int}>> $state @return list<array<string,mixed>> */
    private function encodeState(array $state): array
    {
        $rows = [];
        foreach ($state as $collection => $values) {
            foreach ($values as $row) {
                $rows[] = [
                    'collection' => $collection,
                    'key' => base64_encode($row['key']),
                    'value' => base64_encode($row['value']),
                    'revision' => $row['revision'],
                ];
            }
        }
        return $rows;
    }

    /** @param list<mixed> $rows @return array<string,array<string,array{key:string,value:string,revision:int}>> */
    private function decodeState(array $rows): array
    {
        if (!$this->isList($rows) || count($rows) > 100000) {
            throw new StorageException('Account state rows are malformed or excessive.');
        }
        $state = [];
        foreach ($rows as $row) {
            if (!is_array($row) || array_keys($row) !== ['collection', 'key', 'value', 'revision'] || !is_string($row['collection']) || !is_int($row['revision']) || $row['revision'] < 1) {
                throw new StorageException('Account state row is malformed.');
            }
            $key = $this->decodeBase64($row['key'], 1, 1024);
            $value = $this->decodeBase64($row['value'], 0, 16777216);
            $index = 'b64:' . base64_encode($key);
            if (isset($state[$row['collection']][$index])) {
                throw new StorageException('Account state contains a duplicate row.');
            }
            // Constructor-level validation is reused without exposing a public validator.
            $probe = new FileAccountTransaction(str_repeat('0', 64), [], [], [], []);
            $probe->put($row['collection'], $key, $value, 0);
            $state[$row['collection']][$index] = ['key' => $key, 'value' => $value, 'revision' => $row['revision']];
        }
        return $state;
    }

    /** @param array<string,array<string,mixed>> $outbox @return array<string,array<string,mixed>> */
    private function encodeOutbox(array $outbox): array
    {
        foreach ($outbox as &$row) {
            $row['payload'] = base64_encode($row['payload']);
        }
        unset($row);
        return $outbox;
    }

    /** @param array<string,mixed> $outbox @return array<string,array<string,mixed>> */
    private function decodeOutbox(array $outbox): array
    {
        if (count($outbox) > 100000) {
            throw new StorageException('Account outbox contains too many rows.');
        }
        foreach ($outbox as $id => &$row) {
            if (!is_string($id) || preg_match('/\A[a-f0-9]{64}\z/D', $id) !== 1 || !is_array($row)) {
                throw new StorageException('Account outbox row is malformed.');
            }
            $required = ['type', 'payload', 'occurred_at', 'attempts', 'available_at', 'lease_owner', 'lease_until'];
            if (array_keys($row) !== $required || !is_string($row['type']) || !is_int($row['occurred_at']) || !is_int($row['attempts']) || !is_int($row['available_at']) || !is_int($row['lease_until']) || ($row['lease_owner'] !== null && !is_string($row['lease_owner']))) {
                throw new StorageException('Account outbox row is malformed.');
            }
            $row['payload'] = $this->decodeBase64($row['payload'], 1, 4194304);
            OutboxEvent::assertType($row['type']);
            OutboxEvent::assertPayload($row['payload']);
            if ($row['occurred_at'] < 0 || $row['attempts'] < 0 || $row['available_at'] < 0 || $row['lease_until'] < 0) {
                throw new StorageException('Account outbox counters are invalid.');
            }
        }
        unset($row);
        return $outbox;
    }

    /** @param array<string,mixed> $dedupe @return array<string,int> */
    private function validateDedupe(array $dedupe): array
    {
        if (count($dedupe) > 500000) {
            throw new StorageException('Account dedupe set contains too many rows.');
        }
        foreach ($dedupe as $hash => $timestamp) {
            if (!is_string($hash) || preg_match('/\A[a-f0-9]{64}\z/D', $hash) !== 1 || !is_int($timestamp) || $timestamp < 0) {
                throw new StorageException('Account dedupe row is malformed.');
            }
        }
        return $dedupe;
    }

    /** @param array<string,mixed> $retries @return array<string,array{attempts:int,next_allowed_at:int,updated_at:int}> */
    private function validateRetries(array $retries): array
    {
        if (count($retries) > 100000) {
            throw new StorageException('Account retry set contains too many rows.');
        }
        foreach ($retries as $hash => $row) {
            if (!is_string($hash) || preg_match('/\A[a-f0-9]{64}\z/D', $hash) !== 1 || !is_array($row) || array_keys($row) !== ['attempts', 'next_allowed_at', 'updated_at']) {
                throw new StorageException('Account retry row is malformed.');
            }
            foreach ($row as $value) {
                if (!is_int($value) || $value < 0) {
                    throw new StorageException('Account retry counter is invalid.');
                }
            }
        }
        return $retries;
    }

    private function decodeBase64($encoded, int $minimum, int $maximum): string
    {
        if (!is_string($encoded)) {
            throw new StorageException('Account state base64 value is malformed.');
        }
        $decoded = base64_decode($encoded, true);
        if (!is_string($decoded) || strlen($decoded) < $minimum || strlen($decoded) > $maximum || base64_encode($decoded) !== $encoded) {
            throw new StorageException('Account state base64 value is malformed.');
        }
        return $decoded;
    }

    private function isList(array $value): bool
    {
        $index = 0;
        foreach ($value as $key => $_) {
            if ($key !== $index++) { return false; }
        }
        return true;
    }

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