<?php

declare(strict_types=1);

/**
 * Full-site backup and restore helpers.
 *
 * Backup packages are kept below storage/backups and are never included inside
 * another backup. Every package contains manifest.json, database.sql, and a
 * site/ directory holding the website files and uploads.
 */

function sparrow_backup_site_root(): string
{
    return dirname(__DIR__);
}

function sparrow_backup_directory(): string
{
    return sparrow_backup_site_root() . DIRECTORY_SEPARATOR . 'storage'
        . DIRECTORY_SEPARATOR . 'backups';
}

function sparrow_backup_ensure_directory(): string
{
    $directory = sparrow_backup_directory();

    if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
        throw new RuntimeException('The backup folder could not be created.');
    }

    $protection_file = $directory . DIRECTORY_SEPARATOR . '.htaccess';
    if (!is_file($protection_file)) {
        @file_put_contents(
            $protection_file,
            "Require all denied\nDeny from all\n"
        );
    }

    $index_file = $directory . DIRECTORY_SEPARATOR . 'index.php';
    if (!is_file($index_file)) {
        @file_put_contents($index_file, "<?php http_response_code(404); exit;\n");
    }

    return $directory;
}

function sparrow_backup_csrf_token(): string
{
    sparrow_start_session();

    if (empty($_SESSION['sparrow_backup_csrf'])) {
        $_SESSION['sparrow_backup_csrf'] = bin2hex(random_bytes(32));
    }

    return (string) $_SESSION['sparrow_backup_csrf'];
}

function sparrow_backup_verify_csrf(?string $token): bool
{
    $expected = sparrow_backup_csrf_token();
    return is_string($token) && hash_equals($expected, $token);
}

function sparrow_backup_assert_supported(): void
{
    if (!class_exists('ZipArchive')) {
        throw new RuntimeException(
            'PHP ZIP support is not enabled. Enable the zip extension before creating or restoring backups.'
        );
    }

    if (!extension_loaded('pdo_mysql')) {
        throw new RuntimeException('PHP PDO MySQL support is required for database backups.');
    }
}

function sparrow_backup_safe_filename(string $filename): ?string
{
    $filename = basename(trim($filename));

    if (
        $filename === ''
        || !preg_match('/^sparrow-(?:full-backup|pre-restore)-[0-9]{8}-[0-9]{6}(?:-[0-9]+)?\.zip$/', $filename)
    ) {
        return null;
    }

    return $filename;
}

function sparrow_backup_path(string $filename): ?string
{
    $safe_name = sparrow_backup_safe_filename($filename);
    if ($safe_name === null) {
        return null;
    }

    return sparrow_backup_ensure_directory() . DIRECTORY_SEPARATOR . $safe_name;
}

function sparrow_backup_format_bytes(int $bytes): string
{
    if ($bytes < 1024) {
        return $bytes . ' B';
    }

    $units = ['KB', 'MB', 'GB', 'TB'];
    $value = $bytes / 1024;

    foreach ($units as $unit) {
        if ($value < 1024 || $unit === 'TB') {
            return number_format($value, $value >= 100 ? 0 : 1) . ' ' . $unit;
        }
        $value /= 1024;
    }

    return $bytes . ' B';
}

function sparrow_backup_list(): array
{
    $directory = sparrow_backup_ensure_directory();
    $items = [];

    foreach (glob($directory . DIRECTORY_SEPARATOR . 'sparrow-*.zip') ?: [] as $path) {
        if (!is_file($path)) {
            continue;
        }

        $filename = basename($path);
        if (sparrow_backup_safe_filename($filename) === null) {
            continue;
        }

        $items[] = [
            'filename' => $filename,
            'path' => $path,
            'size' => (int) filesize($path),
            'created_at' => (int) filemtime($path),
            'type' => str_starts_with($filename, 'sparrow-pre-restore-')
                ? 'Pre-restore safety backup'
                : 'Full backup',
        ];
    }

    usort($items, static fn(array $a, array $b): int => $b['created_at'] <=> $a['created_at']);
    return $items;
}

function sparrow_backup_unique_filename(string $type): string
{
    $directory = sparrow_backup_ensure_directory();
    $prefix = $type === 'pre-restore' ? 'sparrow-pre-restore-' : 'sparrow-full-backup-';
    $base = $prefix . date('Ymd-His');
    $filename = $base . '.zip';
    $counter = 2;

    while (is_file($directory . DIRECTORY_SEPARATOR . $filename)) {
        $filename = $base . '-' . $counter . '.zip';
        $counter++;
    }

    return $filename;
}

function sparrow_backup_quote_identifier(string $name): string
{
    return '`' . str_replace('`', '``', $name) . '`';
}

function sparrow_backup_sql_value(PDO $pdo, $value): string
{
    if ($value === null) {
        return 'NULL';
    }

    if (is_resource($value)) {
        $value = stream_get_contents($value);
    }

    $quoted = $pdo->quote((string) $value);
    if ($quoted === false) {
        throw new RuntimeException('A database value could not be safely exported.');
    }

    return $quoted;
}

function sparrow_backup_export_database(PDO $pdo, string $sql_path): array
{
    $handle = fopen($sql_path, 'wb');
    if ($handle === false) {
        throw new RuntimeException('The temporary database export could not be created.');
    }

    $table_count = 0;
    $row_count = 0;

    try {
        fwrite($handle, "-- Sparrow & Seed Creations full database backup\n");
        fwrite($handle, '-- Created: ' . date(DATE_ATOM) . "\n\n");
        fwrite($handle, "SET NAMES utf8mb4;\n");
        fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n");
        fwrite($handle, "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n\n");

        $tables = $pdo->query('SHOW FULL TABLES WHERE Table_type = \'BASE TABLE\'')->fetchAll(PDO::FETCH_NUM);

        foreach ($tables as $table_row) {
            $table = (string) ($table_row[0] ?? '');
            if ($table === '') {
                continue;
            }

            $quoted_table = sparrow_backup_quote_identifier($table);
            $create_row = $pdo->query('SHOW CREATE TABLE ' . $quoted_table)->fetch(PDO::FETCH_NUM);
            $create_sql = (string) ($create_row[1] ?? '');
            if ($create_sql === '') {
                throw new RuntimeException('Could not read the structure for database table ' . $table . '.');
            }

            // MySQL 8's 0900 collations are not available on many MariaDB hosts.
            // Use a broadly supported utf8mb4 collation in portable backups.
            $create_sql = preg_replace(
                '/utf8mb4_[a-z0-9_]*0900[a-z0-9_]*/i',
                'utf8mb4_unicode_ci',
                $create_sql
            ) ?? $create_sql;

            fwrite($handle, '-- Table: ' . $table . "\n");
            fwrite($handle, 'DROP TABLE IF EXISTS ' . $quoted_table . ";\n");
            fwrite($handle, $create_sql . ";\n\n");

            $statement = $pdo->query('SELECT * FROM ' . $quoted_table);
            $columns = null;

            while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
                if ($columns === null) {
                    $columns = array_keys($row);
                }

                $column_sql = implode(', ', array_map('sparrow_backup_quote_identifier', $columns));
                $value_sql = implode(', ', array_map(
                    static fn($value): string => sparrow_backup_sql_value($pdo, $value),
                    array_values($row)
                ));

                fwrite(
                    $handle,
                    'INSERT INTO ' . $quoted_table . ' (' . $column_sql . ') VALUES (' . $value_sql . ");\n"
                );
                $row_count++;
            }

            fwrite($handle, "\n");
            $table_count++;
        }

        fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");
    } finally {
        fclose($handle);
    }

    return [
        'tables' => $table_count,
        'rows' => $row_count,
        'bytes' => (int) filesize($sql_path),
        'sha256' => hash_file('sha256', $sql_path),
        'server_version' => (string) $pdo->query('SELECT VERSION()')->fetchColumn(),
    ];
}

function sparrow_backup_should_skip(string $relative_path): bool
{
    $relative_path = str_replace('\\', '/', ltrim($relative_path, '/'));

    if ($relative_path === 'storage/backups' || str_starts_with($relative_path, 'storage/backups/')) {
        return true;
    }

    $parts = explode('/', $relative_path);
    return in_array('.git', $parts, true)
        || in_array('node_modules', $parts, true)
        || in_array('.idea', $parts, true);
}

function sparrow_backup_site_files(): Generator
{
    $root = sparrow_backup_site_root();
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY
    );

    foreach ($iterator as $file) {
        if (!$file->isFile() || $file->isLink()) {
            continue;
        }

        $absolute = $file->getPathname();
        $relative = ltrim(substr($absolute, strlen($root)), DIRECTORY_SEPARATOR);
        $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative);

        if (!sparrow_backup_should_skip($relative)) {
            yield $relative => $absolute;
        }
    }
}

function sparrow_backup_acquire_lock()
{
    $lock_path = sparrow_backup_ensure_directory() . DIRECTORY_SEPARATOR . '.backup.lock';
    $handle = fopen($lock_path, 'c+');

    if ($handle === false || !flock($handle, LOCK_EX | LOCK_NB)) {
        if (is_resource($handle)) {
            fclose($handle);
        }
        throw new RuntimeException('Another backup or restore is already running.');
    }

    return $handle;
}

function sparrow_backup_release_lock($handle): void
{
    if (is_resource($handle)) {
        flock($handle, LOCK_UN);
        fclose($handle);
    }
}

function sparrow_backup_create(PDO $pdo, array $user, string $type = 'full'): array
{
    sparrow_backup_assert_supported();
    @set_time_limit(0);

    $lock = sparrow_backup_acquire_lock();
    $directory = sparrow_backup_ensure_directory();
    $filename = sparrow_backup_unique_filename($type);
    $final_path = $directory . DIRECTORY_SEPARATOR . $filename;
    $partial_path = $final_path . '.partial';
    $temporary_sql = tempnam(sys_get_temp_dir(), 'sparrow-db-');

    if ($temporary_sql === false) {
        sparrow_backup_release_lock($lock);
        throw new RuntimeException('A temporary database file could not be created.');
    }

    $zip = null;

    try {
        $database = sparrow_backup_export_database($pdo, $temporary_sql);
        $zip = new ZipArchive();

        if ($zip->open($partial_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('The backup ZIP file could not be opened.');
        }

        if (!$zip->addFile($temporary_sql, 'database.sql')) {
            throw new RuntimeException('The database export could not be added to the backup.');
        }

        $site_files = [];
        $site_bytes = 0;

        foreach (sparrow_backup_site_files() as $relative => $absolute) {
            $archive_name = 'site/' . $relative;
            if (!$zip->addFile($absolute, $archive_name)) {
                throw new RuntimeException('Could not add site file to backup: ' . $relative);
            }

            $size = (int) filesize($absolute);
            $site_files[] = [
                'path' => $relative,
                'size' => $size,
                'sha256' => hash_file('sha256', $absolute),
            ];
            $site_bytes += $size;
        }

        $manifest = [
            'format' => 'sparrow-full-backup',
            'format_version' => 1,
            'created_at' => date(DATE_ATOM),
            'created_by' => [
                'id' => (int) ($user['id'] ?? 0),
                'name' => (string) ($user['name'] ?? ''),
                'role' => (string) ($user['role'] ?? ''),
            ],
            'backup_type' => $type === 'pre-restore' ? 'pre-restore' : 'full',
            'php_version' => PHP_VERSION,
            'database' => $database,
            'site' => [
                'files' => count($site_files),
                'bytes' => $site_bytes,
                'entries' => $site_files,
            ],
        ];

        $manifest_json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if ($manifest_json === false || !$zip->addFromString('manifest.json', $manifest_json)) {
            throw new RuntimeException('The backup manifest could not be created.');
        }

        if (!$zip->close()) {
            throw new RuntimeException('The backup ZIP file could not be finalized.');
        }
        $zip = null;

        if (!rename($partial_path, $final_path)) {
            throw new RuntimeException('The completed backup could not be moved into place.');
        }

        return [
            'filename' => $filename,
            'path' => $final_path,
            'size' => (int) filesize($final_path),
            'manifest' => $manifest,
        ];
    } catch (Throwable $exception) {
        if ($zip instanceof ZipArchive) {
            $zip->close();
        }
        @unlink($partial_path);
        throw $exception;
    } finally {
        @unlink($temporary_sql);
        sparrow_backup_release_lock($lock);
    }
}

function sparrow_backup_validate_archive(string $archive_path): array
{
    sparrow_backup_assert_supported();

    $zip = new ZipArchive();
    if ($zip->open($archive_path) !== true) {
        throw new RuntimeException('The selected backup could not be opened.');
    }

    try {
        for ($index = 0; $index < $zip->numFiles; $index++) {
            $entry = (string) $zip->getNameIndex($index);
            $normalized = str_replace('\\', '/', $entry);

            if (
                $normalized === ''
                || str_starts_with($normalized, '/')
                || preg_match('/^[A-Za-z]:\//', $normalized)
                || in_array('..', explode('/', $normalized), true)
            ) {
                throw new RuntimeException('The backup contains an unsafe file path.');
            }
        }

        $manifest_json = $zip->getFromName('manifest.json');
        $database_sql = $zip->getFromName('database.sql');

        if (!is_string($manifest_json) || !is_string($database_sql)) {
            throw new RuntimeException('The backup is missing its manifest or database export.');
        }

        $manifest = json_decode($manifest_json, true);
        if (
            !is_array($manifest)
            || ($manifest['format'] ?? '') !== 'sparrow-full-backup'
            || (int) ($manifest['format_version'] ?? 0) !== 1
        ) {
            throw new RuntimeException('This is not a supported Sparrow full backup package.');
        }

        $expected_sql_hash = (string) ($manifest['database']['sha256'] ?? '');
        if ($expected_sql_hash === '' || !hash_equals($expected_sql_hash, hash('sha256', $database_sql))) {
            throw new RuntimeException('The database export failed its integrity check.');
        }

        $entries = $manifest['site']['entries'] ?? null;
        if (!is_array($entries)) {
            throw new RuntimeException('The backup file list is missing.');
        }

        foreach ($entries as $file) {
            $relative = (string) ($file['path'] ?? '');
            $expected_hash = (string) ($file['sha256'] ?? '');
            $content = $zip->getFromName('site/' . $relative);

            if ($relative === '' || $expected_hash === '' || !is_string($content)) {
                throw new RuntimeException('A file listed in the backup manifest is missing.');
            }

            if (!hash_equals($expected_hash, hash('sha256', $content))) {
                throw new RuntimeException('A site file failed its integrity check: ' . $relative);
            }
        }

        return $manifest;
    } finally {
        $zip->close();
    }
}

function sparrow_backup_sql_statements(string $sql): Generator
{
    $statement = '';
    $length = strlen($sql);
    $quote = null;
    $escaped = false;
    $line_comment = false;
    $block_comment = false;

    for ($index = 0; $index < $length; $index++) {
        $character = $sql[$index];
        $next = $index + 1 < $length ? $sql[$index + 1] : '';

        if ($line_comment) {
            if ($character === "\n") {
                $line_comment = false;
                $statement .= $character;
            }
            continue;
        }

        if ($block_comment) {
            if ($character === '*' && $next === '/') {
                $block_comment = false;
                $index++;
            }
            continue;
        }

        if ($quote !== null) {
            $statement .= $character;

            if ($escaped) {
                $escaped = false;
                continue;
            }

            if ($character === '\\') {
                $escaped = true;
                continue;
            }

            if ($character === $quote) {
                if ($next === $quote && $quote !== '`') {
                    $statement .= $next;
                    $index++;
                } else {
                    $quote = null;
                }
            }
            continue;
        }

        if ($character === '-' && $next === '-' && ($index + 2 >= $length || ctype_space($sql[$index + 2]))) {
            $line_comment = true;
            $index++;
            continue;
        }

        if ($character === '#') {
            $line_comment = true;
            continue;
        }

        if ($character === '/' && $next === '*') {
            $block_comment = true;
            $index++;
            continue;
        }

        if ($character === "'" || $character === '"' || $character === '`') {
            $quote = $character;
            $statement .= $character;
            continue;
        }

        if ($character === ';') {
            $trimmed = trim($statement);
            if ($trimmed !== '') {
                yield $trimmed;
            }
            $statement = '';
            continue;
        }

        $statement .= $character;
    }

    $trimmed = trim($statement);
    if ($trimmed !== '') {
        yield $trimmed;
    }
}

function sparrow_backup_remove_tree(string $path): void
{
    if (!is_dir($path)) {
        @unlink($path);
        return;
    }

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($iterator as $item) {
        if ($item->isDir()) {
            @rmdir($item->getPathname());
        } else {
            @unlink($item->getPathname());
        }
    }

    @rmdir($path);
}

function sparrow_backup_restore(PDO $pdo, array $user, string $archive_path): array
{
    sparrow_backup_assert_supported();
    @set_time_limit(0);

    $manifest = sparrow_backup_validate_archive($archive_path);
    $safety_backup = sparrow_backup_create($pdo, $user, 'pre-restore');
    $lock = sparrow_backup_acquire_lock();
    $temporary_root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sparrow-restore-' . bin2hex(random_bytes(8));

    if (!mkdir($temporary_root, 0700, true) && !is_dir($temporary_root)) {
        sparrow_backup_release_lock($lock);
        throw new RuntimeException('The temporary restore folder could not be created.');
    }

    $zip = new ZipArchive();
    $zip_open = false;

    try {
        if ($zip->open($archive_path) !== true) {
            throw new RuntimeException('The backup package could not be opened.');
        }
        $zip_open = true;

        if (!$zip->extractTo($temporary_root)) {
            throw new RuntimeException('The backup package could not be extracted.');
        }
        $zip->close();
        $zip_open = false;

        $site_source = $temporary_root . DIRECTORY_SEPARATOR . 'site';
        $site_root = sparrow_backup_site_root();
        $restored_files = 0;

        // Keep the destination database credentials during a host migration.
        // The source config remains available inside the ZIP for disaster recovery.
        $preserved_files = ['app/config.php'];

        foreach ($manifest['site']['entries'] as $file) {
            $manifest_path = str_replace('\\', '/', (string) $file['path']);
            if (in_array($manifest_path, $preserved_files, true)) {
                continue;
            }

            $relative = str_replace('/', DIRECTORY_SEPARATOR, $manifest_path);
            $source = $site_source . DIRECTORY_SEPARATOR . $relative;
            $destination = $site_root . DIRECTORY_SEPARATOR . $relative;
            $destination_directory = dirname($destination);

            if (!is_dir($destination_directory) && !mkdir($destination_directory, 0755, true) && !is_dir($destination_directory)) {
                throw new RuntimeException('Could not create a restored site folder: ' . dirname((string) $file['path']));
            }

            if (!copy($source, $destination)) {
                throw new RuntimeException('Could not restore site file: ' . (string) $file['path']);
            }
            $restored_files++;
        }

        $sql = file_get_contents($temporary_root . DIRECTORY_SEPARATOR . 'database.sql');
        if (!is_string($sql)) {
            throw new RuntimeException('The restored database export could not be read.');
        }

        $statement_count = 0;
        foreach (sparrow_backup_sql_statements($sql) as $statement) {
            $pdo->exec($statement);
            $statement_count++;
        }

        return [
            'manifest' => $manifest,
            'safety_backup' => $safety_backup,
            'restored_files' => $restored_files,
            'sql_statements' => $statement_count,
            'preserved_files' => $preserved_files,
        ];
    } catch (Throwable $exception) {
        throw new RuntimeException(
            'Restore stopped. The pre-restore safety backup was saved as '
            . $safety_backup['filename'] . '. Error: ' . $exception->getMessage(),
            0,
            $exception
        );
    } finally {
        if ($zip_open) {
            @$zip->close();
        }
        sparrow_backup_remove_tree($temporary_root);
        sparrow_backup_release_lock($lock);
    }
}
