<?php

declare(strict_types=1);

namespace PhpWA\AppState;

use PhpWA\Crypto\CryptoPrimitives;
use PhpWA\Exception\ProtocolException;
use PhpWA\Integration\EventType;
use PhpWA\Protocol\Proto\SyncActionData;
use PhpWA\Protocol\Proto\SyncdMutation;
use PhpWA\Protocol\Proto\SyncdMutation\SyncdOperation;
use PhpWA\Protocol\Proto\SyncdPatch;
use PhpWA\Protocol\Proto\SyncdRecord;
use PhpWA\Protocol\Proto\SyncdSnapshot;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\StoredValue;

/** Validates and atomically applies authenticated app-state patches/snapshots. */
final class AppStatePatchProcessor
{
    /** @var AppStateKeyStore */ private $keys;
    /** @var AppStateCrypto */ private $crypto;

    public function __construct(?AppStateKeyStore $keys = null, ?AppStateCrypto $crypto = null)
    {
        $this->keys = $keys ?? new AppStateKeyStore();
        $this->crypto = $crypto ?? new AppStateCrypto();
    }

    public function applyPatch(
        AccountTransactionInterface $transaction,
        string $name,
        SyncdPatch $patch,
        int $occurredAt
    ): AppStateApplyResult {
        AppStateName::assert($name);
        if ($occurredAt < 0 || !$patch->hasVersion() || $patch->getVersion() === null) {
            throw new ProtocolException('App-state patch metadata is incomplete.');
        }
        $version = $this->boundedVersion($patch->getVersion()->getVersion());
        $digest = hash('sha256', $patch->serializeToString(), true);
        $digestKey = $name . ':' . $version;
        $knownDigest = $transaction->get('app_state_patch', $digestKey);
        if ($knownDigest !== null) {
            if (!hash_equals($knownDigest->value(), $digest)) { throw new ProtocolException('App-state patch version conflicts with stored data.'); }
            return new AppStateApplyResult($name, $version, 0, true);
        }
        [$state, $stateRevision] = $this->loadState($transaction, $name);
        if ($state->version() === PHP_INT_MAX || $version !== $state->version() + 1) {
            throw new ProtocolException('App-state patch version is not the next expected version.');
        }
        if (count($patch->getMutations()) < 1 || count($patch->getMutations()) > 1000) {
            throw new ProtocolException('App-state patch mutation count is invalid.');
        }
        $patchKeyId = $patch->getKeyId() === null ? '' : $patch->getKeyId()->getId();
        $patchKeys = $this->keys->expanded($transaction, $patchKeyId);
        $decoded = []; $valueMacs = []; $subtract = []; $add = []; $values = []; $touched = [];
        foreach ($patch->getMutations() as $position => $mutation) {
            $item = $this->decodeMutation($transaction, $mutation, $version, $position);
            $indexHex = bin2hex($item['index_mac']);
            if (!array_key_exists($indexHex, $values)) {
                $storedMac = $transaction->get(AppStateName::macCollection($name), $indexHex);
                if ($storedMac !== null && strlen($storedMac->value()) !== 32) {
                    throw new ProtocolException('Stored app-state value MAC has an invalid length.');
                }
                $values[$indexHex] = $storedMac === null ? null : $storedMac->value();
            }
            if (is_string($values[$indexHex])) { $subtract[] = $values[$indexHex]; }
            if ($item['operation'] === SyncdOperation::SET) {
                $add[] = $item['value_mac']; $values[$indexHex] = $item['value_mac'];
            } else {
                $values[$indexHex] = null;
            }
            $touched[$indexHex] = true; $valueMacs[] = $item['value_mac']; $decoded[] = $item;
        }
        $newHash = $this->crypto->updateHash($state->hash(), $subtract, $add);
        $expectedSnapshotMac = $this->crypto->snapshotMac($newHash, $version, $name, $patchKeys->snapshotMac());
        if (strlen($patch->getSnapshotMac()) !== 32 || !hash_equals($expectedSnapshotMac, $patch->getSnapshotMac())) {
            throw new ProtocolException('App-state patch snapshot MAC verification failed.');
        }
        $expectedPatchMac = $this->crypto->patchMac($patch->getSnapshotMac(), $valueMacs, $version, $name, $patchKeys->patchMac());
        if (strlen($patch->getPatchMac()) !== 32 || !hash_equals($expectedPatchMac, $patch->getPatchMac())) {
            throw new ProtocolException('App-state patch MAC verification failed.');
        }

        foreach (array_keys($touched) as $indexHex) {
            $known = $transaction->get(AppStateName::macCollection($name), $indexHex);
            if (is_string($values[$indexHex])) {
                $transaction->put(AppStateName::macCollection($name), $indexHex, $values[$indexHex], $known === null ? 0 : $known->revision());
            } elseif ($known !== null) {
                $transaction->delete(AppStateName::macCollection($name), $indexHex, $known->revision());
            }
        }
        $this->saveState($transaction, $name, new AppStateHashState($version, $newHash), $stateRevision);
        $transaction->put('app_state_patch', $digestKey, $digest, 0);
        $this->stageMutations($transaction, $name, $version, $decoded, $occurredAt, 'patch');
        return new AppStateApplyResult($name, $version, count($decoded), false);
    }

    public function applySnapshot(
        AccountTransactionInterface $transaction,
        string $name,
        SyncdSnapshot $snapshot,
        int $occurredAt
    ): AppStateApplyResult {
        AppStateName::assert($name);
        if ($occurredAt < 0 || !$snapshot->hasVersion() || $snapshot->getVersion() === null) {
            throw new ProtocolException('App-state snapshot metadata is incomplete.');
        }
        $version = $this->boundedVersion($snapshot->getVersion()->getVersion());
        $digest = hash('sha256', $snapshot->serializeToString(), true); $digestKey = $name . ':' . $version;
        [$currentState, $stateRevision] = $this->loadState($transaction, $name);
        $knownDigest = $transaction->get('app_state_snapshot', $digestKey);
        if ($knownDigest !== null) {
            if (!hash_equals($knownDigest->value(), $digest)) { throw new ProtocolException('App-state snapshot version conflicts with stored data.'); }
            if ($currentState->version() === $version) {
                return new AppStateApplyResult($name, $version, 0, true);
            }
        }
        if ($version < 1 || count($snapshot->getRecords()) > 1000) {
            throw new ProtocolException('App-state snapshot version or record count is invalid.');
        }
        $snapshotKeyId = $snapshot->getKeyId() === null ? '' : $snapshot->getKeyId()->getId();
        $snapshotKeys = $this->keys->expanded($transaction, $snapshotKeyId);
        $decoded = []; $add = []; $newValues = [];
        foreach ($snapshot->getRecords() as $position => $record) {
            $mutation = new SyncdMutation(['operation' => SyncdOperation::SET, 'record' => $record]);
            $item = $this->decodeMutation($transaction, $mutation, $version, $position);
            $hex = bin2hex($item['index_mac']);
            if (isset($newValues[$hex])) { throw new ProtocolException('App-state snapshot contains a duplicate mutation index.'); }
            $newValues[$hex] = $item['value_mac']; $add[] = $item['value_mac']; $decoded[] = $item;
        }
        $newHash = $this->crypto->updateHash(str_repeat("\0", 128), [], $add);
        $expectedMac = $this->crypto->snapshotMac($newHash, $version, $name, $snapshotKeys->snapshotMac());
        if (strlen($snapshot->getMac()) !== 32 || !hash_equals($expectedMac, $snapshot->getMac())) {
            throw new ProtocolException('App-state snapshot MAC verification failed.');
        }
        $oldMacs = $transaction->list(AppStateName::macCollection($name), 10000);
        if (count($oldMacs) >= 10000) { throw new ProtocolException('Stored app-state mutation MAC set exceeds its safe reset limit.'); }
        foreach ($oldMacs as $key => $stored) { $transaction->delete(AppStateName::macCollection($name), $key, $stored->revision()); }
        foreach ($newValues as $hex => $mac) { $transaction->put(AppStateName::macCollection($name), $hex, $mac, 0); }
        $this->saveState($transaction, $name, new AppStateHashState($version, $newHash), $stateRevision);
        $patchDigests = $transaction->list('app_state_patch', 10000);
        if (count($patchDigests) >= 10000) { throw new ProtocolException('Stored app-state patch journal exceeds its safe reset limit.'); }
        foreach ($patchDigests as $key => $stored) {
            if (strncmp($key, $name . ':', strlen($name) + 1) === 0) {
                $transaction->delete('app_state_patch', $key, $stored->revision());
            }
        }
        if ($knownDigest === null) { $transaction->put('app_state_snapshot', $digestKey, $digest, 0); }
        $this->stageMutations($transaction, $name, $version, $decoded, $occurredAt, 'snapshot');
        return new AppStateApplyResult($name, $version, count($decoded), false);
    }

    /** @return array{operation:int,index:list<string>,index_mac:string,value_mac:string,action_version:int,action_bytes:string} */
    private function decodeMutation(AccountTransactionInterface $transaction, SyncdMutation $mutation, int $patchVersion, int $position): array
    {
        $operation = $mutation->getOperation(); $record = $mutation->getRecord();
        if (($operation !== SyncdOperation::SET && $operation !== SyncdOperation::REMOVE) || !$record instanceof SyncdRecord
            || $record->getIndex() === null || $record->getValue() === null || $record->getKeyId() === null) {
            throw new ProtocolException('App-state mutation #' . ($position + 1) . ' is incomplete.');
        }
        $indexMac = $record->getIndex()->getBlob(); $blob = $record->getValue()->getBlob(); $keyId = $record->getKeyId()->getId();
        if (strlen($indexMac) !== 32 || strlen($blob) < 80 || strlen($blob) > 3145728 || $keyId === '' || strlen($keyId) > 64) {
            throw new ProtocolException('App-state mutation #' . ($position + 1) . ' violates its byte bounds.');
        }
        $keys = $this->keys->expanded($transaction, $keyId);
        $content = substr($blob, 0, -32); $valueMac = substr($blob, -32);
        $expectedContentMac = $this->crypto->contentMac($operation, $content, $keyId, $keys->valueMac());
        if (!hash_equals($expectedContentMac, $valueMac)) {
            throw new ProtocolException('App-state mutation #' . ($position + 1) . ' content MAC verification failed.');
        }
        $iv = substr($content, 0, 16); $ciphertext = substr($content, 16);
        try { $plaintext = CryptoPrimitives::aesCbcDecrypt($keys->valueEncryption(), $ciphertext, $iv); }
        catch (\Throwable $error) { throw new ProtocolException('Unable to decrypt app-state mutation #' . ($position + 1) . '.', 0, $error); }
        $action = new SyncActionData();
        try { $action->mergeFromString($plaintext); }
        catch (\Throwable $error) { throw new ProtocolException('App-state mutation #' . ($position + 1) . ' action protobuf is malformed.', 0, $error); }
        if (!$action->hasValue() || $action->getValue() === null || !$action->hasIndex()) {
            throw new ProtocolException('App-state mutation #' . ($position + 1) . ' action is incomplete.');
        }
        $expectedIndexMac = $this->crypto->indexMac($action->getIndex(), $keys->index());
        if (!hash_equals($expectedIndexMac, $indexMac)) {
            throw new ProtocolException('App-state mutation #' . ($position + 1) . ' index MAC verification failed.');
        }
        try { $index = json_decode($action->getIndex(), true, 32, JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('App-state mutation #' . ($position + 1) . ' index JSON is malformed.', 0, $error); }
        if (!$this->isStringList($index)) { throw new ProtocolException('App-state mutation #' . ($position + 1) . ' index is invalid.'); }
        $actionBytes = $action->getValue()->serializeToString();
        if ($actionBytes === '' || strlen($actionBytes) > 2800000) { throw new ProtocolException('App-state action protobuf is empty or too large.'); }
        return [
            'operation' => $operation, 'index' => $index, 'index_mac' => $indexMac, 'value_mac' => $valueMac,
            'action_version' => $action->getVersion(), 'action_bytes' => $actionBytes, 'patch_version' => $patchVersion,
        ];
    }

    /** @param mixed $value */
    private function isStringList($value): bool
    {
        if (!is_array($value) || count($value) < 1 || count($value) > 32 || array_keys($value) !== range(0, count($value) - 1)) { return false; }
        foreach ($value as $part) {
            if (!is_string($part) || strlen($part) > 1024 || preg_match('//u', $part) !== 1 || strpos($part, "\0") !== false) { return false; }
        }
        return true;
    }

    /** @return array{0:AppStateHashState,1:int} */
    private function loadState(AccountTransactionInterface $transaction, string $name): array
    {
        $stored = $transaction->get('app_state_state', $name);
        return $stored === null ? [AppStateHashState::initial(), 0] : [AppStateHashState::decode($stored->value()), $stored->revision()];
    }

    private function saveState(AccountTransactionInterface $transaction, string $name, AppStateHashState $state, int $revision): void
    {
        $transaction->put('app_state_state', $name, $state->encode(), $revision);
    }

    /** @param list<array<string,mixed>> $mutations */
    private function stageMutations(AccountTransactionInterface $transaction, string $name, int $version, array $mutations, int $occurredAt, string $source): void
    {
        foreach ($mutations as $position => $mutation) {
            $payload = [
                'schema_version' => 1, 'collection' => $name, 'patch_version' => $version,
                'source' => $source, 'position' => $position, 'operation' => $mutation['operation'] === SyncdOperation::SET ? 'set' : 'remove',
                'index' => $mutation['index'], 'action_version' => $mutation['action_version'],
                'sync_action_proto_base64' => base64_encode($mutation['action_bytes']),
            ];
            try { $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }
            catch (\Throwable $error) { throw new ProtocolException('Unable to serialize an app-state mutation event.', 0, $error); }
            if (!is_string($json) || strlen($json) > 4194304) { throw new ProtocolException('App-state mutation event exceeds the outbox limit.'); }
            $dedupeId = 'appstate:' . hash('sha256', $name . "\0" . $version . "\0" . $source . "\0" . $position);
            $transaction->stageIncoming($dedupeId, EventType::APPSTATE_MUTATION, $json, $occurredAt);
        }
    }

    /** @param int|string $value */
    private function boundedVersion($value): int
    {
        if (is_int($value) && $value >= 0) { return $value; }
        if (is_string($value) && preg_match('/\A(?:0|[1-9][0-9]*)\z/D', $value) === 1
            && strlen($value) <= strlen((string) PHP_INT_MAX)
            && strcmp(str_pad($value, strlen((string) PHP_INT_MAX), '0', STR_PAD_LEFT), (string) PHP_INT_MAX) <= 0) {
            return (int) $value;
        }
        throw new ProtocolException('App-state version exceeds this PHP runtime integer range.');
    }
}
