<?php

// Backup creation, storage, retention and restore
// Shared by the admin pages, cron/backup.php and scripts/restore_cli.php
//
// Loaded from both sides: the admin tier requires it in a web request and cron/backup.php
// and scripts/restore_cli.php require it on the command line. Nothing in here may touch
// $_SERVER, $_SESSION or any other superglobal - there is no DOCUMENT_ROOT, no session and
// no request when cron runs it.
//
// The archive is a zip whose entries are encrypted with WinZip AES-256. The key is a single
// per-install value kept in config.php and NEVER in the database, so a backup that leaks
// (from the web root, from a synced folder, from a stolen laptop) cannot be opened with
// anything the database contains. It is not in the file name either - the random token in
// the name is only there to make the path unguessable.

DEFINE("BACKUP_TYPE_FULL", "full");
DEFINE("BACKUP_TYPE_DATABASE", "database");
DEFINE("BACKUP_TYPE_MASTER_KEY", "master_key");

/**
 * The backup types that can be produced without a logged-in user.
 * master_key needs the session master key, so cron can never make one.
 */
function backupUnattendedTypes(): array
{
    return [BACKUP_TYPE_FULL, BACKUP_TYPE_DATABASE];
}

function backupAllTypes(): array
{
    return [BACKUP_TYPE_FULL, BACKUP_TYPE_DATABASE, BACKUP_TYPE_MASTER_KEY];
}

function backupTypeLabel(string $type): string
{
    switch ($type) {
        case BACKUP_TYPE_FULL:
            return "Full Backup";
        case BACKUP_TYPE_DATABASE:
            return "Database Only";
        case BACKUP_TYPE_MASTER_KEY:
            return "Master Key";
    }
    return $type;
}

/**
 * Absolute path of the ITFlow root, worked out from this file rather than DOCUMENT_ROOT
 * so it is identical under the web server and under cron.
 */
function backupAppRoot(): string
{
    return dirname(__DIR__);
}

/**
 * The encryption key for this install.
 *
 * Lives in config.php only. Generated and appended on first use so nobody has to think
 * about it, then displayed in Maintenance > Backup so it can be written down - without it a
 * backup cannot be restored, on this server or any other.
 *
 * Returns an empty string if config.php could not be written, which every caller treats
 * as a hard failure: an unencrypted backup is worse than no backup.
 */
function backupEncryptionKey(): string
{
    global $config_backup_key;

    if (!empty($config_backup_key)) {
        return $config_backup_key;
    }

    $config_file = backupAppRoot() . "/config.php";

    if (!is_writable($config_file)) {
        return '';
    }

    $key = randomString(32);

    // Re-read rather than trusting the global: another request may have generated one
    // between the check above and here, and two keys would orphan the first backup.
    $existing = @file_get_contents($config_file);
    if ($existing !== false && preg_match('/\$config_backup_key\s*=\s*[\'"]([^\'"]+)[\'"]/', $existing, $match)) {
        $config_backup_key = $match[1];
        return $config_backup_key;
    }

    $line = "\n// Backup encryption key - keep a copy somewhere safe, backups cannot be restored without it\n";
    $line .= "\$config_backup_key = '" . $key . "';\n";

    if (@file_put_contents($config_file, $line, FILE_APPEND | LOCK_EX) === false) {
        return '';
    }

    $config_backup_key = $key;

    return $key;
}

/**
 * Keep the database handle usable across long stretches of non-database work.
 *
 * A backup dumps, zips and encrypts for minutes at a time without issuing a single query.
 * The connection is idle throughout, and a server with a short wait_timeout closes it - so
 * the tiny UPDATE that marks the backup complete is the thing that fails, long after the
 * archive was written correctly. Under PHP 8.1's default report mode that surfaces as an
 * uncaught mysqli_sql_exception, which under the dispatcher takes the rest of the cron
 * cycle with it.
 *
 * Called before every write that follows long file work. Returns a working handle, which
 * may be a new one - callers must assign the result. $GLOBALS['mysqli'] is updated too,
 * because logAudit(), appNotify() and the job scripts all reach for the global.
 */
function backupDbEnsure($mysqli)
{
    try {
        if (@mysqli_query($mysqli, "SELECT 1")) {
            return $mysqli;
        }
    } catch (Throwable $e) {
        // Connection is gone - fall through and rebuild it
    }

    global $dbhost, $dbusername, $dbpassword, $database;

    try {
        $fresh = @mysqli_connect($dbhost, $dbusername, $dbpassword, $database);
    } catch (Throwable $e) {
        $fresh = false;
    }

    if ($fresh instanceof mysqli) {
        backupDbHoldOpen($fresh);
        $GLOBALS['mysqli'] = $fresh;
        return $fresh;
    }

    // Nothing more to be done here. The caller's query will throw and be reported as the
    // job failing, which is the correct outcome - better than pretending it succeeded.
    return $mysqli;
}

/**
 * Ask the server not to hang up during the quiet stretches. Best effort: a host may cap
 * or refuse this, which is why backupDbEnsure() still exists.
 */
function backupDbHoldOpen($mysqli): void
{
    try {
        @mysqli_query($mysqli, "SET SESSION wait_timeout = 28800");
    } catch (Throwable $e) {
        // Not permitted here - the reconnect path covers it
    }
}

/**
 * Where finished archives are kept. Overridable with $config_backup_path in config.php
 * for anyone who would rather keep them off the web root entirely - which is the better
 * place for them, and what the docs recommend.
 */
function backupStorageDir(): string
{
    global $config_backup_path;

    if (!empty($config_backup_path)) {
        $dir = rtrim($config_backup_path, '/\\');
    } else {
        $dir = backupAppRoot() . "/uploads/backups";
    }

    if (!is_dir($dir)) {
        @mkdir($dir, 0750, true);
    }

    backupHardenStorageDir($dir);

    return $dir;
}

/**
 * Re-assert the guards on a directory that must never be served.
 *
 * The .htaccess only covers Apache. It is deliberately a deny-all rather than the
 * "turn PHP off" rule used elsewhere in uploads/, because nothing in here should ever
 * be reachable over HTTP by any means. nginx installs get no protection from this file
 * at all, which is why the archives are encrypted and their names carry a random token.
 */
function backupHardenStorageDir(string $dir): void
{
    if (!is_dir($dir)) {
        return;
    }

    $htaccess = $dir . "/.htaccess";
    if (!file_exists($htaccess)) {
        @file_put_contents($htaccess, "Require all denied\nOptions -ExecCGI -Indexes\nphp_flag engine off\n");
    }

    $index = $dir . "/index.php";
    if (!file_exists($index)) {
        @file_put_contents($index, "<?php // Silence is golden\n");
    }
}

/**
 * uploads/ carries its own guards, and a restore wipes the directory before extracting.
 * A backup taken before those guards existed would therefore silently remove them, so
 * they are rewritten after every restore whatever the archive contained.
 */
function backupAssertUploadsGuards(): void
{
    $uploads = backupAppRoot() . "/uploads";

    if (!is_dir($uploads)) {
        @mkdir($uploads, 0750, true);
    }

    $htaccess = $uploads . "/.htaccess";
    $wanted = "Options -ExecCGI -Indexes\nphp_flag engine off\nRemoveHandler .php .phtml .phar .phps\nRemoveType .php .phtml .phar .phps\n<FilesMatch \"\\.(php|phtml|phar|phps|cgi|pl|sh)\$\">\n  Require all denied\n</FilesMatch>\n";

    // Overwrite unconditionally - an archive is allowed to carry a .htaccess, it is not
    // allowed to decide what ours says.
    @file_put_contents($htaccess, $wanted);

    $index = $uploads . "/index.php";
    if (!file_exists($index)) {
        @file_put_contents($index, "");
    }
}

/**
 * itflow_20260731-184500_full_<32 random chars>.zip
 *
 * The token is an unguessable path component, not a key. It buys nothing on its own -
 * the encryption is what protects the contents - but it stops a directory guess on an
 * install whose web server serves the folder anyway.
 */
function backupBuildFileName(string $type, string $token, ?int $timestamp = null): string
{
    $timestamp = $timestamp ?? time();

    return "itflow_" . date('Ymd-His', $timestamp) . "_" . $type . "_" . $token . ".zip";
}

/**
 * Branch and commit read straight out of .git - CONTRIBUTING rule 6 rules out shelling
 * out to git, and the old backup handler was one of the last places still doing it.
 */
function backupGitInfo(): array
{
    $info = ['branch' => 'N/A', 'commit' => 'N/A'];

    $git_dir = backupAppRoot() . "/.git";
    if (!is_dir($git_dir)) {
        return $info;
    }

    $head = @file_get_contents($git_dir . "/HEAD");
    if ($head === false) {
        return $info;
    }

    $head = trim($head);

    if (str_starts_with($head, 'ref:')) {
        $ref = trim(substr($head, 4));
        $info['branch'] = basename($ref);

        $ref_file = $git_dir . "/" . $ref;
        if (is_file($ref_file)) {
            $info['commit'] = trim(@file_get_contents($ref_file) ?: 'N/A');
        } else {
            // Packed refs - the loose file is gone once git gc has run
            $packed = @file_get_contents($git_dir . "/packed-refs");
            if ($packed !== false) {
                foreach (explode("\n", $packed) as $line) {
                    if (str_ends_with(trim($line), " " . $ref)) {
                        $info['commit'] = strtok(trim($line), ' ');
                        break;
                    }
                }
            }
        }
    } else {
        // Detached head
        $info['commit'] = $head;
    }

    return $info;
}

/**
 * Stream a SQL dump of schema and data into $sql_file.
 *
 * Every value goes through real_escape_string, which turns newlines into \n, so no
 * statement in the output ever contains a raw newline inside a quoted value. That is what
 * lets the importer split statements by accumulating lines until one ends in a semicolon.
 * Anything that changes the escaping here has to change the importer too.
 */
function backupDumpDatabase(mysqli $mysqli, string $sql_file, ?string &$error = null): bool
{
    try {
        return backupDumpDatabaseInner($mysqli, $sql_file, $error);
    } catch (Throwable $e) {
        $error = $error ?: $e->getMessage();
        return false;
    }
}

function backupDumpDatabaseInner(mysqli $mysqli, string $sql_file, ?string &$error = null): bool
{
    $fh = fopen($sql_file, 'wb');
    if (!$fh) {
        $error = "Cannot open dump file for writing";
        return false;
    }

    $write = function ($line) use ($fh) {
        fwrite($fh, $line);
        fwrite($fh, "\n");
    };

    $write("-- ITFlow database backup");
    $write("-- Generated " . date('Y-m-d H:i:s'));
    $write("SET NAMES 'utf8mb4';");
    $write("SET FOREIGN_KEY_CHECKS = 0;");
    $write("SET UNIQUE_CHECKS = 0;");
    $write("");

    $tables = [];
    $views = [];

    $res = mysqli_query($mysqli, "SHOW FULL TABLES");
    if (!$res) {
        fclose($fh);
        $error = "Could not list tables: " . mysqli_error($mysqli);
        return false;
    }
    while ($row = mysqli_fetch_array($res, MYSQLI_NUM)) {
        if (strtoupper($row[1] ?? '') === 'VIEW') {
            $views[] = $row[0];
        } else {
            $tables[] = $row[0];
        }
    }
    mysqli_free_result($res);

    if (empty($tables)) {
        fclose($fh);
        $error = "Database contains no tables - refusing to write an empty backup";
        return false;
    }

    foreach ($tables as $table) {
        $create_res = mysqli_query($mysqli, "SHOW CREATE TABLE `$table`");
        if (!$create_res) {
            fclose($fh);
            $error = "Could not read structure of `$table`: " . mysqli_error($mysqli);
            return false;
        }
        $create_row = mysqli_fetch_assoc($create_res);
        $create_sql = array_values($create_row)[1] ?? '';
        mysqli_free_result($create_res);

        $write("-- Table `$table`");
        $write("DROP TABLE IF EXISTS `$table`;");
        $write($create_sql . ";");
        $write("");

        // Unbuffered so a large table does not have to fit in memory. Nothing else may
        // query on this connection until the result is closed.
        $data_res = mysqli_query($mysqli, "SELECT * FROM `$table`", MYSQLI_USE_RESULT);
        if ($data_res) {
            while ($row = mysqli_fetch_assoc($data_res)) {
                $cols = [];
                $vals = [];
                foreach ($row as $col => $val) {
                    $cols[] = '`' . $col . '`';
                    $vals[] = is_null($val) ? "NULL" : "'" . mysqli_real_escape_string($mysqli, $val) . "'";
                }
                $write("INSERT INTO `$table` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");");
            }
            mysqli_free_result($data_res);
            $write("");
        }
    }

    foreach ($views as $view) {
        $view_res = mysqli_query($mysqli, "SHOW CREATE VIEW `$view`");
        if ($view_res) {
            $row = mysqli_fetch_assoc($view_res);
            $create_view = $row['Create View'] ?? '';
            mysqli_free_result($view_res);

            $write("-- View `$view`");
            $write("DROP VIEW IF EXISTS `$view`;");
            $write(rtrim($create_view, ';') . ";");
            $write("");
        }
    }

    $write("SET FOREIGN_KEY_CHECKS = 1;");
    $write("SET UNIQUE_CHECKS = 1;");

    fclose($fh);

    return true;
}

/**
 * Zip the uploads folder, skipping symlinks and the backup storage directory itself -
 * without that exclusion every full backup would contain all previous full backups.
 */
function backupZipUploads(string $folder, string $zip_path, ?string &$error = null): bool
{
    $zip = new ZipArchive();
    if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
        $error = "Could not create the uploads archive";
        return false;
    }

    $folder_real = realpath($folder);
    if (!$folder_real || !is_dir($folder_real)) {
        // Nothing to add - a fresh install may not have written to uploads yet
        $zip->close();
        return true;
    }

    $exclude = realpath(backupStorageDir());

    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($folder_real, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY
    );

    foreach ($files as $file) {
        if ($file->isDir() || $file->isLink()) {
            continue;
        }

        $file_path = $file->getRealPath();
        if ($file_path === false) {
            continue;
        }

        // Stay inside the uploads boundary
        if (strpos($file_path, $folder_real . DIRECTORY_SEPARATOR) !== 0) {
            continue;
        }

        // Never nest backups inside a backup
        if ($exclude && strpos($file_path, $exclude . DIRECTORY_SEPARATOR) === 0) {
            continue;
        }

        $zip->addFile($file_path, substr($file_path, strlen($folder_real) + 1));
    }

    $zip->close();

    return true;
}

/**
 * Put the finished parts into an AES-256 encrypted zip.
 *
 * ZipArchive encrypts entry data but not entry names, which is fine - the names are
 * db.sql, uploads.zip and version.txt on every archive we make.
 */
function backupSealArchive(array $entries, string $zip_path, string $key, ?string &$error = null): bool
{
    if ($key === '') {
        $error = "No backup encryption key available";
        return false;
    }

    $zip = new ZipArchive();
    if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
        $error = "Could not create the backup archive";
        return false;
    }

    $zip->setPassword($key);

    foreach ($entries as $name => $path) {
        if (!$zip->addFile($path, $name)) {
            $zip->close();
            $error = "Could not add $name to the archive";
            return false;
        }
        if (!$zip->setEncryptionName($name, ZipArchive::EM_AES_256)) {
            $zip->close();
            $error = "This server's zip library cannot produce AES-256 encrypted archives (libzip 1.2 or newer is required)";
            return false;
        }
    }

    if (!$zip->close()) {
        $error = "Could not finish writing the archive";
        return false;
    }

    return true;
}

/**
 * Put a backup on the queue for the dispatcher to build.
 *
 * The web tier never generates an archive inline. A dump of a real install takes longer
 * than a web request is allowed to live on most hosts - PHP-FPM's request_terminate_timeout
 * and the front end's read timeout both cut it off, and neither is affected by
 * set_time_limit() - so the button records the intent and cron/backup.php does the work
 * within the minute. Same shape as the Run Now button on Maintenance > Cron.
 */
function backupQueue(mysqli $mysqli, string $type, string $created_by, ?string &$error = null): int
{
    if (!in_array($type, backupUnattendedTypes(), true)) {
        $error = "That backup type cannot be queued";
        return 0;
    }

    if (backupEncryptionKey() === '') {
        $error = "No backup encryption key is set and config.php is not writable - cannot make an encrypted backup";
        return 0;
    }

    $type_esc = escapeSql($type);
    $created_by_esc = escapeSql($created_by);

    mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '', backup_status = 'Pending', backup_source = 'Manual', backup_created_by = '$created_by_esc'");

    $backup_id = intval(mysqli_insert_id($mysqli));

    // Ask the dispatcher to run the backup job on its next pass. Without this the row sits
    // Pending for ever on a default install: the job ships disabled, so the schedule never
    // calls it and nothing ever builds what the button just queued. run_now is honoured
    // whether or not a job is enabled, which is exactly the case this needs.
    mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'backup'");

    return $backup_id;
}

/**
 * Build every queued backup. Called by cron/backup.php.
 */
function backupRunQueued(mysqli $mysqli): int
{
    $built = 0;

    $sql = mysqli_query($mysqli, "SELECT backup_id, backup_type, backup_created_by FROM backups WHERE backup_status = 'Pending' ORDER BY backup_created_at ASC");
    if (!$sql) {
        return 0;
    }

    $queued = [];
    while ($row = mysqli_fetch_assoc($sql)) {
        $queued[] = $row;
    }

    foreach ($queued as $row) {
        $backup_id = intval($row['backup_id']);

        // Claim before building so a second dispatcher cannot pick up the same row
        mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Running' WHERE backup_id = $backup_id AND backup_status = 'Pending'");
        if (mysqli_affected_rows($mysqli) !== 1) {
            continue;
        }

        $error = null;
        $built_ok = backupCreate($mysqli, $row['backup_type'], $row['backup_created_by'] ?: 'Cron', 'Manual', $error, [], $backup_id);

        // backupCreate may have rebuilt the connection under us
        $mysqli = backupDbEnsure($GLOBALS['mysqli'] ?? $mysqli);

        if ($built_ok) {
            $built++;
            appNotify("Backup", backupTypeLabel($row['backup_type']) . " is ready to download", "/admin/backup.php");
            logAudit("Backup", "Create", backupTypeLabel($row['backup_type']) . " completed");
        } else {
            appNotify("Backup", backupTypeLabel($row['backup_type']) . " failed: " . $error, "/admin/backup.php");
            logAudit("Backup", "Create", backupTypeLabel($row['backup_type']) . " failed: " . $error);
        }
    }

    return $built;
}

/**
 * Create a backup and record it.
 *
 * $extra['master_key'] is required for the master_key type and ignored for the others.
 * $backup_id updates an existing row (a queued one) rather than inserting a new one.
 * Returns the backup_id, or 0 on failure with $error set.
 */
function backupCreate(mysqli $mysqli, string $type, string $created_by, string $source, ?string &$error = null, array $extra = [], int $backup_id = 0): int
{
    if (!in_array($type, backupAllTypes(), true)) {
        $error = "Unknown backup type";
        return 0;
    }

    if ($type === BACKUP_TYPE_MASTER_KEY && empty($extra['master_key'])) {
        $error = "The master key backup needs the master key and can only be made from the web interface";
        return 0;
    }

    $key = backupEncryptionKey();
    if ($key === '') {
        $error = "No backup encryption key is set and config.php is not writable - cannot make an encrypted backup";
        return 0;
    }

    @set_time_limit(0);
    backupDbHoldOpen($mysqli);

    $token = randomString(32);
    $file_name = backupBuildFileName($type, $token);
    $storage_dir = backupStorageDir();
    $final_path = $storage_dir . "/" . $file_name;

    $created_by_esc = escapeSql($created_by);
    $type_esc = escapeSql($type);
    $source_esc = escapeSql($source);
    $file_name_esc = escapeSql($file_name);

    if ($backup_id > 0) {
        mysqli_query($mysqli, "UPDATE backups SET backup_file_name = '$file_name_esc', backup_status = 'Running' WHERE backup_id = $backup_id");
    } else {
        mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '$file_name_esc', backup_status = 'Running', backup_source = '$source_esc', backup_created_by = '$created_by_esc'");
        $backup_id = intval(mysqli_insert_id($mysqli));
    }

    if (!is_dir($storage_dir) || !is_writable($storage_dir)) {
        $error = "Backup directory is not writable: $storage_dir";
        $error_esc = escapeSql($error);
        mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = '$error_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");
        return 0;
    }

    // Temp files live outside the web root and are removed however this function exits
    $temp_files = [];
    $cleanup = function () use (&$temp_files) {
        foreach ($temp_files as $file) {
            if (is_file($file)) {
                @unlink($file);
            }
        }
    };

    $fail = function ($message) use (&$mysqli, $backup_id, $cleanup, $final_path, &$error) {
        $cleanup();
        $mysqli = backupDbEnsure($mysqli);
        if (is_file($final_path)) {
            @unlink($final_path);
        }
        $error = $message;
        $message_esc = escapeSql($message);
        mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = '$message_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");
        return 0;
    };

    $entries = [];
    $sub_error = null;

    // --- db.sql ---
    if ($type === BACKUP_TYPE_FULL || $type === BACKUP_TYPE_DATABASE) {
        $sql_file = tempnam(sys_get_temp_dir(), "itflow_sql_");
        $temp_files[] = $sql_file;
        @chmod($sql_file, 0600);

        if (!backupDumpDatabase($mysqli, $sql_file, $sub_error)) {
            return $fail($sub_error ?? "Database dump failed");
        }
        $entries['db.sql'] = $sql_file;
    }

    // --- uploads.zip ---
    if ($type === BACKUP_TYPE_FULL) {
        $uploads_zip = tempnam(sys_get_temp_dir(), "itflow_uploads_");
        $temp_files[] = $uploads_zip;
        @chmod($uploads_zip, 0600);

        if (!backupZipUploads(backupAppRoot() . "/uploads", $uploads_zip, $sub_error)) {
            return $fail($sub_error ?? "Could not archive the uploads directory");
        }
        $entries['uploads.zip'] = $uploads_zip;
    }

    // --- master_key.txt ---
    if ($type === BACKUP_TYPE_MASTER_KEY) {
        $key_file = tempnam(sys_get_temp_dir(), "itflow_mk_");
        $temp_files[] = $key_file;
        @chmod($key_file, 0600);

        $key_content = "ITFlow master encryption key\n";
        $key_content .= "============================\n\n";
        $key_content .= $extra['master_key'] . "\n\n";
        $key_content .= "This key decrypts every credential stored in this ITFlow install.\n";
        $key_content .= "It is only needed if every user password is lost - a normal restore\n";
        $key_content .= "recovers the vault from the database on its own.\n";
        $key_content .= "Exported " . date('Y-m-d H:i:s') . " by " . $created_by . "\n";

        file_put_contents($key_file, $key_content);
        $entries['master_key.txt'] = $key_file;
    }

    // --- version.txt ---
    $git = backupGitInfo();
    $version_file = tempnam(sys_get_temp_dir(), "itflow_ver_");
    $temp_files[] = $version_file;
    @chmod($version_file, 0600);

    $meta = "ITFlow Backup Metadata\n";
    $meta .= "-----------------------------\n";
    $meta .= "Backup Type: " . $type . "\n";
    $meta .= "Generated: " . date('Y-m-d H:i:s') . "\n";
    $meta .= "Generated By: " . $created_by . "\n";
    $meta .= "Source: " . $source . "\n";
    $meta .= "Host: " . gethostname() . "\n";
    $meta .= "Git Branch: " . $git['branch'] . "\n";
    $meta .= "Git Commit: " . $git['commit'] . "\n";
    $meta .= "ITFlow Version: " . (defined('APP_VERSION') ? APP_VERSION : 'Unknown') . "\n";
    $meta .= "Database Version: " . backupCurrentDatabaseVersion($mysqli) . "\n";
    $meta .= "Checksums (SHA256):\n";
    foreach ($entries as $name => $path) {
        $meta .= "  " . $name . ": " . (hash_file('sha256', $path) ?: 'N/A') . "\n";
    }

    file_put_contents($version_file, $meta);
    $entries['version.txt'] = $version_file;

    // --- seal ---
    if (!backupSealArchive($entries, $final_path, $key, $sub_error)) {
        return $fail($sub_error ?? "Could not encrypt the archive");
    }

    @chmod($final_path, 0600);
    $cleanup();

    $size = filesize($final_path) ?: 0;
    $sha = hash_file('sha256', $final_path) ?: '';
    $sha_esc = escapeSql($sha);

    // The archive is written by this point. Everything below is bookkeeping, and it runs
    // after minutes of dumping, zipping and encrypting with the connection idle.
    $mysqli = backupDbEnsure($mysqli);

    mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Complete', backup_size = $size, backup_sha256 = '$sha_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");

    return $backup_id;
}

/**
 * The database version the install is currently stamped at, read from settings so it is
 * correct under cron as well as in a request.
 */
function backupCurrentDatabaseVersion(mysqli $mysqli): string
{
    $res = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
    if ($res && $row = mysqli_fetch_assoc($res)) {
        return $row['config_current_database_version'] ?? 'Unknown';
    }
    return 'Unknown';
}

/**
 * Remove a backup, file and row together. Missing files are not an error - the point is
 * that neither half is left behind.
 */
function backupDeleteById(mysqli $mysqli, int $backup_id): bool
{
    $backup_id = intval($backup_id);

    $res = mysqli_query($mysqli, "SELECT backup_file_name FROM backups WHERE backup_id = $backup_id");
    if (!$res || mysqli_num_rows($res) !== 1) {
        return false;
    }
    $row = mysqli_fetch_assoc($res);

    $path = backupResolvePath($row['backup_file_name']);
    if ($path !== false && is_file($path)) {
        @unlink($path);
    }

    mysqli_query($mysqli, "DELETE FROM backups WHERE backup_id = $backup_id");

    return true;
}

/**
 * Turn a stored file name into an absolute path, refusing anything that tries to leave
 * the backup directory. The name comes from our own row, but this is the only function
 * that turns a database value into a filesystem path so the check belongs here.
 */
function backupResolvePath(string $file_name)
{
    $file_name = basename($file_name);

    if ($file_name === '' || !preg_match('/^itflow_[0-9]{8}-[0-9]{6}_[a-z_]+_[A-Za-z0-9\-_]{32}\.zip$/', $file_name)) {
        return false;
    }

    $dir = realpath(backupStorageDir());
    if ($dir === false) {
        return false;
    }

    return $dir . "/" . $file_name;
}

/**
 * Delete backups past the retention settings, and reconcile both kinds of orphan.
 *
 * Safe to run more than once in a day - everything here is a delete. Never removes the
 * most recent complete backup whatever the settings say, so a badly set retention cannot
 * leave an install with nothing.
 */
function backupRunRetention(mysqli $mysqli): array
{
    $result = ['deleted' => 0, 'orphan_files' => 0, 'orphan_rows' => 0, 'recovered' => 0];

    $res = mysqli_query($mysqli, "SELECT config_backup_retention_days, config_backup_retention_count FROM settings WHERE company_id = 1");
    $settings = $res ? mysqli_fetch_assoc($res) : [];

    $days = intval($settings['config_backup_retention_days'] ?? 30);
    $count = intval($settings['config_backup_retention_count'] ?? 5);

    // A run whose connection died before it could mark itself complete leaves a Running row
    // for an archive that is sitting there perfectly good. Anything still Running after six
    // hours is finished one way or the other, decided by whether the file exists.
    $stale = mysqli_query($mysqli, "SELECT backup_id, backup_file_name FROM backups WHERE backup_status IN ('Running','Pending') AND backup_created_at < NOW() - INTERVAL 6 HOUR");
    if ($stale) {
        while ($row = mysqli_fetch_assoc($stale)) {
            $stale_id = intval($row['backup_id']);
            $stale_path = $row['backup_file_name'] === '' ? false : backupResolvePath($row['backup_file_name']);

            if ($stale_path !== false && is_file($stale_path)) {
                $stale_size = filesize($stale_path) ?: 0;
                $stale_sha = escapeSql(hash_file('sha256', $stale_path) ?: '');
                mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Complete', backup_size = $stale_size, backup_sha256 = '$stale_sha', backup_completed_at = backup_created_at WHERE backup_id = $stale_id");
                $result['recovered'] = ($result['recovered'] ?? 0) + 1;
            } else {
                mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = 'Run did not finish', backup_completed_at = NOW() WHERE backup_id = $stale_id");
            }
        }
    }


    // Reconciliation runs BEFORE the retention maths, not after. A row this rescues or
    // adopts has to be counted by the same pass that decides what to delete - otherwise it
    // escapes retention until tomorrow, and a second run on the same day is not the no-op
    // CONTRIBUTING's third cron rule asks for.
    // Orphans: rows whose file is gone, and files with no row
    $known = [];
    $rows = mysqli_query($mysqli, "SELECT backup_id, backup_file_name, backup_status FROM backups");
    if ($rows) {
        while ($row = mysqli_fetch_assoc($rows)) {
            $known[$row['backup_file_name']] = true;
            if ($row['backup_status'] !== 'Complete') {
                continue;
            }
            $path = backupResolvePath($row['backup_file_name']);
            if ($path === false || !is_file($path)) {
                mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Missing' WHERE backup_id = " . intval($row['backup_id']));
                $result['orphan_rows']++;
            }
        }
    }

    // Archives on disk that the table does not know about are ADOPTED, not deleted.
    // A restore brings back the backups table as it was when the backup was taken, so
    // every archive made since then looks unknown - deleting them would quietly destroy
    // good backups, including the one that was just restored from. Once adopted they age
    // out under the normal rules. The strict name check in backupResolvePath is what stops
    // an unrelated file being adopted.
    $dir = backupStorageDir();
    foreach (glob($dir . "/itflow_*.zip") ?: [] as $file) {
        $name = basename($file);

        if (isset($known[$name])) {
            continue;
        }

        if (backupResolvePath($name) === false) {
            continue;
        }

        $type = 'full';
        $created = date('Y-m-d H:i:s', filemtime($file) ?: time());
        if (preg_match('/^itflow_([0-9]{8})-([0-9]{6})_([a-z_]+)_[A-Za-z0-9\-_]{32}\.zip$/', $name, $m)) {
            $stamp = strtotime($m[1] . ' ' . $m[2]);
            if ($stamp !== false) {
                $created = date('Y-m-d H:i:s', $stamp);
            }
            if (in_array($m[3], backupAllTypes(), true)) {
                $type = $m[3];
            }
        }

        $name_esc = escapeSql($name);
        $type_esc = escapeSql($type);
        $size = filesize($file) ?: 0;

        mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '$name_esc', backup_size = $size, backup_status = 'Complete', backup_source = 'Adopted', backup_created_at = '$created', backup_completed_at = '$created'");

        $result['orphan_files']++;
    }


    // Keep the newest $count complete backups OF EACH TYPE, regardless of age.
    //
    // Per type rather than one shared pool: a master key export is a few hundred bytes and
    // a full backup is gigabytes, so counting them together means five master key exports
    // silently evict every real backup on the box. They are also the artefact you would
    // least want retention to quietly remove.
    $keep = [];
    foreach (backupAllTypes() as $keep_type) {
        $keep_type_esc = escapeSql($keep_type);
        $keep_res = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Complete' AND backup_type = '$keep_type_esc' ORDER BY backup_created_at DESC LIMIT " . max(1, $count));
        if ($keep_res) {
            while ($row = mysqli_fetch_assoc($keep_res)) {
                $keep[] = intval($row['backup_id']);
            }
        }
    }

    $keep_clause = empty($keep) ? "" : " AND backup_id NOT IN (" . implode(",", $keep) . ")";

    // Age-based removal
    if ($days > 0) {
        $old = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_created_at < CURDATE() - INTERVAL $days DAY $keep_clause");
        if ($old) {
            while ($row = mysqli_fetch_assoc($old)) {
                if (backupDeleteById($mysqli, intval($row['backup_id']))) {
                    $result['deleted']++;
                }
            }
        }
    }

    // Count-based removal
    if ($count > 0 && !empty($keep)) {
        $surplus = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Complete' $keep_clause");
        if ($surplus) {
            while ($row = mysqli_fetch_assoc($surplus)) {
                if (backupDeleteById($mysqli, intval($row['backup_id']))) {
                    $result['deleted']++;
                }
            }
        }
    }

    // Failed rows never had a usable file
    $failed = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Failed' AND backup_created_at < CURDATE() - INTERVAL 7 DAY");
    if ($failed) {
        while ($row = mysqli_fetch_assoc($failed)) {
            if (backupDeleteById($mysqli, intval($row['backup_id']))) {
                $result['deleted']++;
            }
        }
    }

    return $result;
}

/*
 * ###############################################################################################################
 *  RESTORE
 * ###############################################################################################################
 */

/**
 * Open an encrypted archive and read its version.txt without touching the database.
 *
 * This is the pre-flight: it proves the key is right and the archive is one of ours
 * before anything destructive happens.
 */
function backupInspectArchive(string $zip_path, string $key, ?string &$error = null)
{
    if (!is_file($zip_path) || !is_readable($zip_path)) {
        $error = "Backup file not found or not readable";
        return false;
    }

    $zip = new ZipArchive();
    if ($zip->open($zip_path) !== true) {
        $error = "This file is not a readable zip archive";
        return false;
    }

    $names = [];
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = $zip->getNameIndex($i);
        if ($name !== false) {
            $names[] = $name;
        }
    }

    if (!in_array('version.txt', $names, true)) {
        $zip->close();
        $error = "This does not look like an ITFlow backup - version.txt is missing";
        return false;
    }

    $zip->setPassword($key);
    $meta_raw = $zip->getFromName('version.txt');

    if ($meta_raw === false) {
        $status = $zip->getStatusString();
        $zip->close();
        if (stripos($status, 'password') !== false) {
            $error = "Wrong backup encryption key for this archive";
        } else {
            $error = "Could not read the archive: $status";
        }
        return false;
    }

    $zip->close();

    $meta = ['raw' => $meta_raw, 'entries' => $names, 'type' => BACKUP_TYPE_FULL, 'database_version' => 'Unknown', 'app_version' => 'Unknown', 'generated' => 'Unknown'];

    foreach (explode("\n", $meta_raw) as $line) {
        if (preg_match('/^Backup Type:\s*(.+)$/', $line, $m)) {
            $meta['type'] = trim($m[1]);
        } elseif (preg_match('/^Database Version:\s*(.+)$/', $line, $m)) {
            $meta['database_version'] = trim($m[1]);
        } elseif (preg_match('/^ITFlow Version:\s*(.+)$/', $line, $m)) {
            $meta['app_version'] = trim($m[1]);
        } elseif (preg_match('/^Generated:\s*(.+)$/', $line, $m)) {
            $meta['generated'] = trim($m[1]);
        }
    }

    if ($meta['type'] === BACKUP_TYPE_MASTER_KEY) {
        $error = "This is a master key export, not a restorable backup";
        return false;
    }

    if (!in_array('db.sql', $names, true)) {
        $error = "This archive contains no database dump";
        return false;
    }

    return $meta;
}

/**
 * Run a SQL file into the database, one statement at a time.
 *
 * Statements are accumulated until a line ends with the delimiter. That is safe for our
 * own dumps because backupDumpDatabase escapes every value, so no raw newline can appear
 * inside a quoted string - see the note there.
 */
function backupImportSql(mysqli $mysqli, $handle, ?string &$error = null): bool
{
    $delimiter = ';';
    $statement = '';
    $line_number = 0;

    while (($line = fgets($handle)) !== false) {
        $line_number++;
        $trimmed = trim($line);

        if ($trimmed === '' || str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
            continue;
        }

        if (preg_match('/^DELIMITER\s+(.+)$/i', $trimmed, $m)) {
            $delimiter = trim($m[1]);
            continue;
        }

        $statement .= $line;

        if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) {
            $sql = substr(rtrim($statement), 0, -strlen($delimiter));
            $statement = '';

            if (trim($sql) === '') {
                continue;
            }

            // mysqli throws on error under PHP 8.1's default report mode, so a bad
            // statement has to be caught here rather than tested for. Letting it escape
            // would abort the restore with the tables already dropped and the rollback
            // below never reached - which is the exact failure this function exists to
            // survive.
            try {
                if (!mysqli_query($mysqli, $sql)) {
                    $error = "SQL error near line $line_number: " . mysqli_error($mysqli);
                    return false;
                }
            } catch (Throwable $e) {
                $error = "SQL error near line $line_number: " . $e->getMessage();
                return false;
            }
        }
    }

    if (trim($statement) !== '') {
        $error = "The dump ended in the middle of a statement - the file is truncated";
        return false;
    }

    return true;
}

/**
 * Restore an encrypted archive over this install.
 *
 * Order matters. Everything that can fail without consequence happens first: the key is
 * checked, the archive is unpacked to a temp directory, and the current database is dumped
 * to a rollback file. Only then are the existing tables dropped. If the import fails after
 * that point the rollback dump is put back, so a bad archive cannot leave an install with
 * no database at all.
 *
 * $progress is called with a short status string so the CLI can print it and the web path
 * can ignore it.
 */
function backupRestoreArchive(mysqli $mysqli, string $zip_path, string $key, ?string &$error = null, ?callable $progress = null): bool
{
    $say = function ($message) use ($progress) {
        if ($progress) {
            $progress($message);
        }
    };

    @set_time_limit(0);

    $meta = backupInspectArchive($zip_path, $key, $error);
    if ($meta === false) {
        return false;
    }

    $say("Archive verified (" . backupTypeLabel($meta['type']) . " taken " . $meta['generated'] . ")");

    $temp_dir = sys_get_temp_dir() . "/itflow_restore_" . bin2hex(random_bytes(8));
    if (!mkdir($temp_dir, 0700, true)) {
        $error = "Could not create a temporary directory for the restore";
        return false;
    }

    $cleanup = function () use ($temp_dir) {
        backupDeleteDirectory($temp_dir);
    };

    $zip = new ZipArchive();
    if ($zip->open($zip_path) !== true) {
        $cleanup();
        $error = "Could not reopen the archive";
        return false;
    }

    // Zip-slip guard on the outer archive
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = $zip->getNameIndex($i);
        if ($name === false) {
            continue;
        }
        if (!backupSafeEntryName($name)) {
            $zip->close();
            $cleanup();
            $error = "The archive contains an unsafe path: $name";
            return false;
        }
    }

    $zip->setPassword($key);
    if (!$zip->extractTo($temp_dir)) {
        $status = $zip->getStatusString();
        $zip->close();
        $cleanup();
        $error = "Could not extract the archive: $status";
        return false;
    }
    $zip->close();

    // Unpacking a multi-gigabyte archive is minutes of idle connection, and everything
    // below is database work.
    $mysqli = backupDbEnsure($mysqli);
    backupDbHoldOpen($mysqli);

    $sql_path = $temp_dir . "/db.sql";
    if (!is_file($sql_path)) {
        $cleanup();
        $error = "The archive did not contain db.sql";
        return false;
    }

    $say("Dumping the current database so it can be put back if this fails");

    $rollback = tempnam(sys_get_temp_dir(), "itflow_rollback_");
    @chmod($rollback, 0600);
    $rollback_error = null;
    $have_rollback = backupDumpDatabase($mysqli, $rollback, $rollback_error);

    if (!$have_rollback) {
        // An empty database is the normal case on a fresh install, and there is nothing
        // to roll back to. Any other failure means we cannot guarantee recovery.
        if (stripos((string)$rollback_error, 'no tables') === false) {
            $cleanup();
            @unlink($rollback);
            $error = "Could not dump the current database before restoring: $rollback_error";
            return false;
        }
    }

    $say("Replacing the database");

    backupDropAllTables($mysqli);

    $fh = fopen($sql_path, 'r');
    if (!$fh) {
        $cleanup();
        $error = "Could not open db.sql from the archive";
        return false;
    }

    $import_error = null;
    $imported = backupImportSql($mysqli, $fh, $import_error);
    fclose($fh);

    if (!$imported) {
        $error = "Restore failed: $import_error";

        if ($have_rollback) {
            $say("Import failed - putting the previous database back");
            backupDropAllTables($mysqli);

            $rb = fopen($rollback, 'r');
            if ($rb) {
                $rb_error = null;
                if (backupImportSql($mysqli, $rb, $rb_error)) {
                    $error .= " - the previous database has been restored, nothing was lost";
                } else {
                    $error .= " - AND the rollback also failed ($rb_error). The dump of your previous database is at $rollback - do not delete it";
                }
                fclose($rb);
            }
        }

        $cleanup();
        if (strpos($error, $rollback) === false) {
            @unlink($rollback);
        }
        return false;
    }

    @unlink($rollback);

    // --- uploads ---
    $uploads_zip = $temp_dir . "/uploads.zip";
    if (is_file($uploads_zip)) {
        $say("Restoring uploads");

        $uploads_dir = backupAppRoot() . "/uploads";

        $uz = new ZipArchive();
        if ($uz->open($uploads_zip) !== true) {
            $cleanup();
            backupAssertUploadsGuards();
            $error = "The database was restored but uploads.zip could not be opened";
            return false;
        }

        for ($i = 0; $i < $uz->numFiles; $i++) {
            $name = $uz->getNameIndex($i);
            if ($name === false) {
                continue;
            }
            if (!backupSafeEntryName($name)) {
                $uz->close();
                $cleanup();
                backupAssertUploadsGuards();
                $error = "The database was restored but uploads.zip contains an unsafe path: $name";
                return false;
            }
        }

        if (!is_dir($uploads_dir)) {
            mkdir($uploads_dir, 0750, true);
        } else {
            // Clear uploads, but never the backup directory. It lives under uploads/ by
            // default, and wiping it would destroy every other archive on the box -
            // including the one being restored from, if it was copied in there.
            backupEmptyDirectory($uploads_dir, [backupStorageDir()]);
        }

        $extracted = $uz->extractTo($uploads_dir);
        $uz->close();

        if (!$extracted) {
            $cleanup();
            backupAssertUploadsGuards();
            $error = "The database was restored but the uploads could not be extracted";
            return false;
        }
    }

    // Whatever the archive carried, our own guards are what ends up on disk
    backupAssertUploadsGuards();
    backupHardenStorageDir(backupStorageDir());

    $cleanup();

    $say("Restore complete");

    return true;
}

/**
 * Drop every table in the current database. Used before an import and again before a
 * rollback import, so it is written once.
 */
function backupDropAllTables(mysqli $mysqli): void
{
    try {
        mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0");
        $tables = mysqli_query($mysqli, "SHOW TABLES");
        $names = [];
        if ($tables) {
            while ($row = mysqli_fetch_row($tables)) {
                $names[] = $row[0];
            }
        }
        foreach ($names as $name) {
            mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $name . "`");
        }
        mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1");
    } catch (Throwable $e) {
        // Nothing useful to do here - the import that follows will report the real problem
    }
}

/**
 * Reject absolute paths, traversal and anything that resolves oddly on Windows.
 */
function backupSafeEntryName(string $name): bool
{
    if ($name === '') {
        return false;
    }
    if (strpos($name, '..') !== false) {
        return false;
    }
    if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
        return false;
    }
    return true;
}

function backupEmptyDirectory(string $dir, array $preserve = []): void
{
    if (!is_dir($dir)) {
        return;
    }

    $preserve = array_filter(array_map('realpath', $preserve));

    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($items as $item) {
        $path = $item->getPathname();

        $skip = false;
        foreach ($preserve as $keep) {
            if ($path === $keep || strpos($path, $keep . DIRECTORY_SEPARATOR) === 0) {
                $skip = true;
                break;
            }
        }
        if ($skip) {
            continue;
        }

        $item->isDir() ? @rmdir($path) : @unlink($path);
    }
}

function backupDeleteDirectory(string $dir): void
{
    backupEmptyDirectory($dir);
    @rmdir($dir);
}

/**
 * The effective upload ceiling for the setup restore form, in bytes.
 * The smaller of upload_max_filesize and post_max_size is what actually applies.
 */
function backupMaxUploadBytes(): int
{
    $upload = backupParseIniBytes(ini_get('upload_max_filesize'));
    $post = backupParseIniBytes(ini_get('post_max_size'));

    $limits = array_filter([$upload, $post], fn($v) => $v > 0);

    return empty($limits) ? 0 : min($limits);
}

function backupParseIniBytes($value): int
{
    $value = trim((string)$value);
    if ($value === '') {
        return 0;
    }

    $unit = strtolower(substr($value, -1));
    $number = (int)$value;

    switch ($unit) {
        case 'g':
            return $number * 1024 * 1024 * 1024;
        case 'm':
            return $number * 1024 * 1024;
        case 'k':
            return $number * 1024;
    }

    return $number;
}

function backupFormatBytes($bytes): string
{
    $bytes = (float)$bytes;
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
    $i = 0;
    while ($bytes >= 1024 && $i < count($units) - 1) {
        $bytes /= 1024;
        $i++;
    }
    return round($bytes, $i === 0 ? 0 : 1) . ' ' . $units[$i];
}
