<?php

declare(strict_types=1);

/**
 * Restorable database and rendered-cache checkpoint for application updates.
 *
 * Registry tables may legitimately change during a declared definition reseed,
 * but page-owned records and already-rendered cache entries must not change as a
 * side effect of migrations or reseeding. The full captured table set is kept so
 * any failed update can restore its pre-update database state.
 */
final class MyronUpdateRuntimeIntegritySnapshot
{
    /**
     * Parents precede children for restoration.
     *
     * @var list<string>
     */
    private const TABLES = [
        'component_registry',
        'section_registry',
        'site_type_registry',
        'template_definitions',
        'page_templates',
        'page_template_versions',
        'pages',
        'page_versions',
        'bundle_seed_records',
        'bundle_activation_state',
    ];

    /**
     * @param array<string, array{columns:list<string>,rows:list<array<string,mixed>>}> $tables
     * @param array<string, string> $pageFingerprints
     * @param array<string, string> $renderedFingerprints
     */
    private function __construct(
        private readonly string $projectRoot,
        private readonly PDO $pdo,
        private readonly array $tables,
        private readonly array $pageFingerprints,
        private readonly array $renderedFingerprints,
        private readonly string $registryFingerprint,
    ) {
    }

    public static function capture(string $projectRoot, PDO $pdo): self
    {
        $tables = [];
        foreach (self::TABLES as $table) {
            try {
                $statement = $pdo->query('SELECT * FROM ' . self::quoteIdentifier($pdo, $table));
                if (!$statement instanceof PDOStatement) {
                    continue;
                }
                $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
                $columns = [];
                for ($index = 0; $index < $statement->columnCount(); $index++) {
                    $metadata = $statement->getColumnMeta($index);
                    $name = is_array($metadata) ? trim((string) ($metadata['name'] ?? '')) : '';
                    if ($name !== '') {
                        $columns[] = $name;
                    }
                }
                if ($columns === [] && isset($rows[0]) && is_array($rows[0])) {
                    $columns = array_keys($rows[0]);
                }
                $tables[$table] = [
                    'columns' => array_values($columns),
                    'rows' => array_values(array_filter($rows, 'is_array')),
                ];
            } catch (Throwable) {
                // Installations predating a table remain snapshot-compatible.
            }
        }

        $pageFingerprints = [];
        foreach (['pages', 'page_versions'] as $table) {
            foreach ((array) ($tables[$table]['rows'] ?? []) as $row) {
                $id = trim((string) ($row['id'] ?? ''));
                if ($id !== '') {
                    $pageFingerprints[$table . ':' . $id] = self::rowFingerprint($row);
                }
            }
        }
        ksort($pageFingerprints);

        $registryState = [];
        foreach (['component_registry', 'section_registry', 'site_type_registry', 'template_definitions', 'page_templates', 'page_template_versions'] as $table) {
            if (isset($tables[$table])) {
                $registryState[$table] = $tables[$table]['rows'];
            }
        }

        $renderedFingerprints = self::renderedCacheFingerprints($projectRoot);

        return new self(
            rtrim($projectRoot, DIRECTORY_SEPARATOR),
            $pdo,
            $tables,
            $pageFingerprints,
            $renderedFingerprints,
            hash('sha256', self::canonicalJson($registryState)),
        );
    }

    /**
     * @return array{pageFingerprint:string,registryFingerprint:string,renderedFingerprint:string,pageCount:int,renderedCount:int}
     */
    public function evidence(): array
    {
        return [
            'pageFingerprint' => hash('sha256', self::canonicalJson($this->pageFingerprints)),
            'registryFingerprint' => $this->registryFingerprint,
            'renderedFingerprint' => hash('sha256', self::canonicalJson($this->renderedFingerprints)),
            'pageCount' => count($this->pageFingerprints),
            'renderedCount' => count($this->renderedFingerprints),
        ];
    }

    public function assertPageOwnedContentUnchanged(): void
    {
        $current = self::capture($this->projectRoot, $this->pdo);
        if ($current->pageFingerprints !== $this->pageFingerprints) {
            throw new RuntimeException('Update attempted to mutate page-owned content or page-version state.');
        }
        if ($current->renderedFingerprints !== $this->renderedFingerprints) {
            throw new RuntimeException('Update attempted to mutate rendered public-cache state before governed invalidation.');
        }
    }

    public function restore(): void
    {
        $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
        $foreignKeysDisabled = false;
        try {
            if ($driver === 'sqlite') {
                $this->pdo->exec('PRAGMA foreign_keys = OFF');
                $foreignKeysDisabled = true;
            } elseif ($driver === 'mysql') {
                $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
                $foreignKeysDisabled = true;
            }

            $this->pdo->beginTransaction();
            foreach (array_reverse(array_keys($this->tables)) as $table) {
                $this->pdo->exec('DELETE FROM ' . self::quoteIdentifier($this->pdo, $table));
            }
            foreach ($this->tables as $table => $snapshot) {
                $columns = $snapshot['columns'];
                if ($columns === []) {
                    continue;
                }
                $quotedColumns = array_map(fn (string $column): string => self::quoteIdentifier($this->pdo, $column), $columns);
                $placeholders = array_map(static fn (string $column): string => ':' . $column, $columns);
                $insert = $this->pdo->prepare(
                    'INSERT INTO ' . self::quoteIdentifier($this->pdo, $table)
                    . ' (' . implode(', ', $quotedColumns) . ') VALUES (' . implode(', ', $placeholders) . ')'
                );
                foreach ($snapshot['rows'] as $row) {
                    $values = [];
                    foreach ($columns as $column) {
                        $values[':' . $column] = $row[$column] ?? null;
                    }
                    $insert->execute($values);
                }
            }
            $this->pdo->commit();
        } catch (Throwable $exception) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw new RuntimeException('Unable to restore update runtime database snapshot.', 0, $exception);
        } finally {
            if ($foreignKeysDisabled) {
                if ($driver === 'sqlite') {
                    $this->pdo->exec('PRAGMA foreign_keys = ON');
                } elseif ($driver === 'mysql') {
                    $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
                }
            }
        }
    }

    /**
     * @return array<string, string>
     */
    private static function renderedCacheFingerprints(string $projectRoot): array
    {
        $root = rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/app/storage/cache/public-pages';
        $fingerprints = [];
        foreach (glob($root . '/*.html') ?: [] as $path) {
            if (!is_file($path)) {
                continue;
            }
            $fingerprints[basename($path)] = hash_file('sha256', $path) ?: '';
        }
        ksort($fingerprints);
        return $fingerprints;
    }

    /**
     * @param array<string, mixed> $row
     */
    private static function rowFingerprint(array $row): string
    {
        ksort($row);
        return hash('sha256', self::canonicalJson($row));
    }

    private static function canonicalJson(mixed $value): string
    {
        return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
    }

    private static function quoteIdentifier(PDO $pdo, string $identifier): string
    {
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) {
            throw new InvalidArgumentException('Unsafe database identifier.');
        }
        return strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)) === 'mysql'
            ? '`' . $identifier . '`'
            : '"' . $identifier . '"';
    }
}
