<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use PDO;
use PDOException;
use PhpWA\Exception\StorageException;

final class PdoAccountStore implements ResettableAccountStoreInterface
{
    /** @var PDO */ private $pdo;
    /** @var SecretCipherInterface */ private $cipher;
    /** @var string */ private $indexKey;
    /** @var string */ private $prefix;
    /** @var string */ private $driver;

    public function __construct(PDO $pdo, SecretCipherInterface $cipher, string $indexKey, string $prefix = 'phpwa_')
    {
        if (strlen($indexKey) !== 32) {
            throw new InvalidArgumentException('PDO account index key must contain exactly 32 bytes.');
        }
        PdoSchema::assertPrefix($prefix);
        $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        if ($driver !== 'mysql' && $driver !== 'sqlite') {
            throw new InvalidArgumentException('Only MySQL and SQLite PDO drivers are supported.');
        }
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        if ($driver === 'mysql') {
            $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
        }
        $this->pdo = $pdo;
        $this->cipher = $cipher;
        $this->indexKey = $indexKey;
        $this->prefix = $prefix;
        $this->driver = $driver;
    }

    public function transaction(string $accountId, callable $callback)
    {
        self::assertAccountId($accountId);
        if ($this->pdo->inTransaction()) {
            throw new StorageException('Nested PhpWA PDO transactions are not supported.');
        }
        $hash = hash_hmac('sha256', $accountId, $this->indexKey);
        $transaction = null;
        try {
            $this->pdo->beginTransaction();
            $revision = $this->lockAccount($hash);
            $transaction = new PdoAccountTransaction(
                $this->pdo,
                $this->cipher,
                $this->indexKey,
                $this->prefix,
                $this->driver,
                $accountId,
                $hash
            );
            $result = $callback($transaction);
            if ($transaction->isDirty()) {
                if ($revision === PHP_INT_MAX) {
                    throw new StorageException('Account storage revision is exhausted.');
                }
                $statement = $this->pdo->prepare("UPDATE {$this->prefix}accounts SET revision = ?, updated_at = ? WHERE account_hash = ? AND revision = ?");
                $statement->execute([$revision + 1, time(), $hash, $revision]);
                if ($statement->rowCount() !== 1) {
                    throw new StorageException('Account storage revision update failed.');
                }
            }
            $transaction->close();
            $this->pdo->commit();
            return $result;
        } catch (\Throwable $error) {
            if ($transaction instanceof PdoAccountTransaction) {
                $transaction->close();
            }
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            if ($error instanceof PDOException) {
                throw new StorageException('PDO account transaction failed.', 0, $error);
            }
            throw $error;
        }
    }

    public function deleteAccount(string $accountId): void
    {
        self::assertAccountId($accountId);
        if ($this->pdo->inTransaction()) {
            throw new StorageException('Account reset cannot run inside a PDO transaction.');
        }
        $hash = hash_hmac('sha256', $accountId, $this->indexKey);
        try {
            $this->pdo->beginTransaction();
            if ($this->driver === 'mysql') {
                // Serialize the reset with every normal account transaction
                // before touching child rows. This also gives InnoDB one
                // consistent lock order and avoids a reset/outbox deadlock.
                $lock = $this->pdo->prepare("SELECT revision FROM {$this->prefix}accounts WHERE account_hash = ? FOR UPDATE");
                $lock->execute([$hash]);
                $lock->fetchColumn();
            }
            // Delete children explicitly as well as relying on the schema's
            // cascades. This remains safe if an SQLite connection was opened
            // without PRAGMA foreign_keys and prevents old ratchets/outbox
            // rows from reappearing when the account row is recreated.
            foreach (['state', 'dedupe', 'outbox', 'retries'] as $table) {
                $statement = $this->pdo->prepare("DELETE FROM {$this->prefix}{$table} WHERE account_hash = ?");
                $statement->execute([$hash]);
            }
            $statement = $this->pdo->prepare("DELETE FROM {$this->prefix}accounts WHERE account_hash = ?");
            $statement->execute([$hash]);
            $this->pdo->commit();
        } catch (\Throwable $error) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            if ($error instanceof StorageException) {
                throw $error;
            }
            throw new StorageException('Unable to reset the PDO account state.', 0, $error);
        }
    }

    private function lockAccount(string $hash): int
    {
        if ($this->driver === 'mysql') {
            $insert = $this->pdo->prepare("INSERT IGNORE INTO {$this->prefix}accounts (account_hash, revision, updated_at) VALUES (?, 0, ?)");
        } else {
            $insert = $this->pdo->prepare("INSERT OR IGNORE INTO {$this->prefix}accounts (account_hash, revision, updated_at) VALUES (?, 0, ?)");
        }
        $insert->execute([$hash, time()]);
        $sql = "SELECT revision FROM {$this->prefix}accounts WHERE account_hash = ?" . ($this->driver === 'mysql' ? ' FOR UPDATE' : '');
        $statement = $this->pdo->prepare($sql);
        $statement->execute([$hash]);
        $revision = $statement->fetchColumn();
        if ($revision === false || !is_numeric($revision) || (int) $revision < 0) {
            throw new StorageException('Unable to lock the PDO account row.');
        }
        return (int) $revision;
    }

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