<?php

require_once __DIR__ . "/functions.php";

if (!defined("BACKUP_DIR")) {
    define("BACKUP_DIR", DATA_DIR . "backups/");
}
if (!defined("BACKUP_LOCAL_RETENTION")) {
    define("BACKUP_LOCAL_RETENTION", 4);
}
if (!defined("BACKUP_AUTO_INTERVAL_SECONDS")) {
    define("BACKUP_AUTO_INTERVAL_SECONDS", 604800);
}
if (!defined("BACKUP_REMOTE_TARGETS")) {
    define("BACKUP_REMOTE_TARGETS", []);
}

class BackupRemoteUploadException extends RuntimeException
{
    private array $debugContext;

    public function __construct(string $message, array $debugContext = [])
    {
        parent::__construct($message);
        $this->debugContext = $debugContext;
    }

    public function getDebugContext(): array
    {
        return $this->debugContext;
    }
}

function backupGetDirectory(): string
{
    return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
}

function backupGetIndexFile(): string
{
    return backupGetDirectory() . "backup-index.json";
}

function backupGetLockFile(): string
{
    return backupGetDirectory() . ".backup.lock";
}

function backupEnsureDirectory(string $dir): void
{
    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
        throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
    }

    @chmod($dir, 02775);
}

function backupNormalizePath(string $path): string
{
    return str_replace("\\", "/", $path);
}

function backupIsTemporaryFile(string $path): bool
{
    $name = basename($path);
    return $name === "" ||
        $name[0] === "." ||
        str_ends_with($name, ".tmp") ||
        str_ends_with($name, ".part");
}

function backupGetSourceFiles(): array
{
    $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
    $files = [];

    foreach (glob($dataDir . "*.json") ?: [] as $file) {
        if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
            $files[] = [
                "path" => $file,
                "name" => "data/" . basename($file),
            ];
        }
    }

    $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
    if (is_dir($uploadsDir)) {
        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY,
        );

        foreach ($items as $item) {
            if (!$item->isFile() || !$item->isReadable()) {
                continue;
            }

            $path = $item->getPathname();
            if (backupIsTemporaryFile($path)) {
                continue;
            }

            $relative = ltrim(
                backupNormalizePath(substr($path, strlen($uploadsDir))),
                "/",
            );
            if ($relative === "" || str_contains($relative, "\0")) {
                continue;
            }

            $files[] = [
                "path" => $path,
                "name" => "data/uploads/" . $relative,
            ];
        }
    }

    usort($files, function ($left, $right) {
        return strcmp($left["name"], $right["name"]);
    });

    return $files;
}

function backupGetDosDateTime(int $timestamp): array
{
    $parts = getdate($timestamp);
    $year = max(1980, (int) $parts["year"]);

    return [
        (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
        ((int) $parts["hours"] << 11) |
            ((int) $parts["minutes"] << 5) |
            ((int) floor(((int) $parts["seconds"]) / 2)),
    ];
}

function backupValidateZipEntryName(string $name): void
{
    $name = backupNormalizePath($name);

    if (
        $name === "" ||
        str_contains($name, "\0") ||
        str_starts_with($name, "/") ||
        preg_match('/^[A-Za-z]:\//', $name) === 1
    ) {
        throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
    }

    foreach (explode("/", $name) as $segment) {
        if ($segment === "" || $segment === "." || $segment === "..") {
            throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
        }
    }

    if (strlen($name) > 65535) {
        throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
    }
}

function backupWriteBytes($handle, string $data): void
{
    $offset = 0;
    $length = strlen($data);

    while ($offset < $length) {
        $written = fwrite($handle, substr($data, $offset));
        if ($written === false || $written === 0) {
            throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
        }
        $offset += $written;
    }
}

function backupCopyFileToHandle(string $file, $handle): void
{
    $source = fopen($file, "rb");
    if ($source === false) {
        throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
    }

    while (!feof($source)) {
        $chunk = fread($source, 1048576);
        if ($chunk === false) {
            fclose($source);
            throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
        }
        if ($chunk !== "") {
            try {
                backupWriteBytes($handle, $chunk);
            } catch (Throwable $exception) {
                fclose($source);
                throw $exception;
            }
        }
    }

    fclose($source);
}

function backupWriteZip(string $targetFile, array $files): array
{
    if (empty($files)) {
        throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
    }

    $handle = fopen($targetFile, "wb");
    if ($handle === false) {
        throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
    }

    $centralDirectory = "";
    $fileCount = 0;
    $sourceBytes = 0;

    try {
        foreach ($files as $file) {
            $path = (string) ($file["path"] ?? "");
            $name = backupNormalizePath((string) ($file["name"] ?? ""));
            backupValidateZipEntryName($name);

            if (!is_file($path) || !is_readable($path)) {
                continue;
            }

            $size = filesize($path);
            if ($size === false) {
                throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
            }
            if ($size > 0xffffffff) {
                throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
            }

            $offset = ftell($handle);
            if ($offset === false || $offset > 0xffffffff) {
                throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
            }

            $crcHex = hash_file("crc32b", $path);
            if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
                throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
            }
            $crc = (int) hexdec($crcHex);
            [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
            $nameLength = strlen($name);

            backupWriteBytes(
                $handle,
                pack(
                    "VvvvvvVVVvv",
                    0x04034b50,
                    10,
                    0,
                    0,
                    $dosTime,
                    $dosDate,
                    $crc,
                    $size,
                    $size,
                    $nameLength,
                    0,
                ) . $name,
            );

            backupCopyFileToHandle($path, $handle);

            $centralDirectory .=
                pack(
                    "VvvvvvvVVVvvvvvVV",
                    0x02014b50,
                    0x031e,
                    10,
                    0,
                    0,
                    $dosTime,
                    $dosDate,
                    $crc,
                    $size,
                    $size,
                    $nameLength,
                    0,
                    0,
                    0,
                    0,
                    0,
                    $offset,
                ) .
                $name;

            $fileCount++;
            $sourceBytes += $size;
        }

        if ($fileCount < 1) {
            throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
        }
        if ($fileCount > 65535) {
            throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
        }

        $centralOffset = ftell($handle);
        $centralSize = strlen($centralDirectory);
        if (
            $centralOffset === false ||
            $centralOffset > 0xffffffff ||
            $centralSize > 0xffffffff
        ) {
            throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
        }

        backupWriteBytes($handle, $centralDirectory);
        backupWriteBytes(
            $handle,
            pack(
                "VvvvvVVv",
                0x06054b50,
                0,
                0,
                $fileCount,
                $fileCount,
                $centralSize,
                $centralOffset,
                0,
            ),
        );
    } catch (Throwable $exception) {
        fclose($handle);
        @unlink($targetFile);
        throw $exception;
    }

    fclose($handle);
    @chmod($targetFile, 0660);

    return [
        "file_count" => $fileCount,
        "source_bytes" => $sourceBytes,
        "archive_bytes" => (int) (filesize($targetFile) ?: 0),
        "sha256" => hash_file("sha256", $targetFile) ?: "",
    ];
}

function backupReadIndex(): array
{
    $index = readJsonFile(backupGetIndexFile());
    $records =
        isset($index["backups"]) && is_array($index["backups"])
            ? $index["backups"]
            : [];

    return ["backups" => array_values($records)];
}

function backupWriteIndex(array $records): bool
{
    return writeJsonFile(backupGetIndexFile(), [
        "backups" => array_values($records),
    ]);
}

function backupListBackups(): array
{
    $records = backupReadIndex()["backups"];
    $dir = backupGetDirectory();
    $existing = [];

    foreach ($records as $record) {
        if (!is_array($record)) {
            continue;
        }

        $filename = basename((string) ($record["filename"] ?? ""));
        if ($filename === "" || !is_file($dir . $filename)) {
            continue;
        }

        $record["filename"] = $filename;
        $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
        $existing[] = $record;
    }

    usort($existing, function ($left, $right) {
        return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
    });

    return $existing;
}

function backupFormatBytes(int $bytes): string
{
    if ($bytes >= 1073741824) {
        return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
    }
    if ($bytes >= 1048576) {
        return number_format($bytes / 1048576, 2, ",", ".") . " MB";
    }
    if ($bytes >= 1024) {
        return number_format($bytes / 1024, 1, ",", ".") . " KB";
    }
    return $bytes . " B";
}

function backupGetRetentionLimit(): int
{
    return max(1, (int) BACKUP_LOCAL_RETENTION);
}

function backupApplyRetention(): void
{
    $records = backupListBackups();
    $keep = backupGetRetentionLimit();
    $dir = backupGetDirectory();

    foreach (array_slice($records, $keep) as $record) {
        $filename = basename((string) ($record["filename"] ?? ""));
        if ($filename !== "" && is_file($dir . $filename)) {
            @unlink($dir . $filename);
        }
    }

    backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
}

function backupGetRemoteTargets(): array
{
    return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
}

function backupGetTargetLabel(array $target, int $index): string
{
    $name = trim((string) ($target["name"] ?? ""));
    if ($name !== "") {
        return $name;
    }

    $type = trim((string) ($target["type"] ?? "target"));
    return $type . "-" . ($index + 1);
}

function backupGetSafeTargetContext(array $target): array
{
    $safe = [];
    $allowedKeys = [
        "name",
        "type",
        "url",
        "instance",
        "bucket",
        "region",
        "prefix",
        "endpoint",
        "host",
        "port",
        "username",
        "path",
        "file",
        "callback",
        "timeout",
    ];

    foreach ($allowedKeys as $key) {
        if (array_key_exists($key, $target)) {
            $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
        }
    }

    return $safe;
}

function backupGetHttpUserAgent(): string
{
    $version = defined("APP_VERSION") ? trim((string) APP_VERSION) : "";
    if ($version === "") {
        $version = "unknown";
    }

    return "PSA-Orderform-Backup/" . $version;
}

function backupFormatResponseExcerpt($response): string
{
    if (!is_string($response) || $response === "") {
        return "";
    }

    $response = preg_replace('/\s+/', " ", trim($response));
    if (!is_string($response)) {
        return "";
    }

    return substr($response, 0, 500);
}

function backupFormatHeaderExcerpt(array $headers): array
{
    $result = [];
    foreach ($headers as $header) {
        $header = trim((string) $header);
        if ($header === "") {
            continue;
        }
        $result[] = substr($header, 0, 500);
        if (count($result) >= 20) {
            break;
        }
    }

    return $result;
}

function backupGetLastPhpErrorMessage(): string
{
    $error = error_get_last();
    if (!is_array($error)) {
        return "";
    }

    return substr(trim((string) ($error["message"] ?? "")), 0, 500);
}

function backupRemoteCapabilities(): array
{
    $targets = backupGetRemoteTargets();
    $types = [];
    foreach ($targets as $target) {
        if (is_array($target)) {
            $type = trim((string) ($target["type"] ?? ""));
            if ($type !== "") {
                $types[$type] = true;
            }
        }
    }

    return [
        "s3" => [
            "configured" => !empty($types["s3"]),
            "available" => function_exists("hash_hmac"),
        ],
        "sftp" => [
            "configured" => !empty($types["sftp"]),
            "available" =>
                function_exists("ssh2_connect") &&
                function_exists("ssh2_sftp"),
        ],
        "custom" => [
            "configured" => !empty($types["custom"]),
            "available" => true,
        ],
        "managed" => [
            "configured" => !empty($types["managed"]),
            "available" => true,
        ],
    ];
}

function backupGetHttpStatusFromHeaders(array $headers): int
{
    foreach ($headers as $header) {
        if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
            return (int) $matches[1];
        }
    }

    return 0;
}

function backupUploadToS3(string $archivePath, array $metadata, array $target): array
{
    $bucket = trim((string) ($target["bucket"] ?? ""));
    $region = trim((string) ($target["region"] ?? ""));
    $accessKey = trim((string) ($target["access_key"] ?? ""));
    $secretKey = (string) ($target["secret_key"] ?? "");
    $prefix = trim((string) ($target["prefix"] ?? ""), "/");
    $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");

    if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
        throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
    }

    $filename = basename($archivePath);
    $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
    $host = $endpoint !== ""
        ? parse_url($endpoint, PHP_URL_HOST)
        : $bucket . ".s3." . $region . ".amazonaws.com";
    if (!is_string($host) || $host === "") {
        throw new RuntimeException("S3-Endpunkt ist ungültig.");
    }

    $url = $endpoint !== ""
        ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
        : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));

    $payload = file_get_contents($archivePath);
    if ($payload === false) {
        throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
    }

    $now = gmdate("Ymd\THis\Z");
    $date = substr($now, 0, 8);
    $payloadHash = hash("sha256", $payload);
    $canonicalUri = parse_url($url, PHP_URL_PATH);
    $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
    $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
    $canonicalHeaders =
        "content-type:application/zip\n" .
        "host:" . $host . "\n" .
        "x-amz-content-sha256:" . $payloadHash . "\n" .
        "x-amz-date:" . $now . "\n";
    $canonicalRequest =
        "PUT\n" .
        $canonicalUri .
        "\n\n" .
        $canonicalHeaders .
        "\n" .
        $signedHeaders .
        "\n" .
        $payloadHash;
    $scope = $date . "/" . $region . "/s3/aws4_request";
    $stringToSign =
        "AWS4-HMAC-SHA256\n" .
        $now .
        "\n" .
        $scope .
        "\n" .
        hash("sha256", $canonicalRequest);
    $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
    $kRegion = hash_hmac("sha256", $region, $kDate, true);
    $kService = hash_hmac("sha256", "s3", $kRegion, true);
    $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
    $signature = hash_hmac("sha256", $stringToSign, $kSigning);
    $authorization =
        "AWS4-HMAC-SHA256 Credential=" .
        $accessKey .
        "/" .
        $scope .
        ", SignedHeaders=" .
        $signedHeaders .
        ", Signature=" .
        $signature;

    $context = stream_context_create([
        "http" => [
            "method" => "PUT",
            "timeout" => (int) ($target["timeout"] ?? 120),
            "ignore_errors" => true,
            "header" =>
                "Content-Type: application/zip\r\n" .
                "Content-Length: " . strlen($payload) . "\r\n" .
                "Host: " . $host . "\r\n" .
                "X-Amz-Date: " . $now . "\r\n" .
                "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
                "Authorization: " . $authorization . "\r\n" .
                "User-Agent: " . backupGetHttpUserAgent() . "\r\n",
            "content" => $payload,
        ],
    ]);

    $response = @file_get_contents($url, false, $context);
    $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
    if (function_exists("http_get_last_response_headers")) {
        $lastHeaders = http_get_last_response_headers();
        $headers = is_array($lastHeaders) ? $lastHeaders : [];
    } else {
        $legacyHeaders = ${"http_response_header"} ?? [];
        $headers = is_array($legacyHeaders)
            ? $legacyHeaders
            : [];
    }
    $status = backupGetHttpStatusFromHeaders($headers);

    if ($response === false || $status < 200 || $status >= 300) {
        throw new BackupRemoteUploadException(
            "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
            [
                "http_status" => $status,
                "response_excerpt" => backupFormatResponseExcerpt($response),
                "response_headers" => backupFormatHeaderExcerpt($headers),
                "php_error" => $phpError,
                "bucket" => $bucket,
                "region" => $region,
                "key" => $key,
                "endpoint" => $endpoint,
            ],
        );
    }

    return ["remote_path" => "s3://" . $bucket . "/" . $key];
}

function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
{
    if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
        throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
    }

    $host = trim((string) ($target["host"] ?? ""));
    $username = trim((string) ($target["username"] ?? ""));
    $password = (string) ($target["password"] ?? "");
    $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
    $port = (int) ($target["port"] ?? 22);

    if ($host === "" || $username === "" || $remoteDir === "") {
        throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
    }

    $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
    if ($connection === false) {
        throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
    }

    $authenticated = false;
    $privateKey = trim((string) ($target["private_key"] ?? ""));
    $publicKey = trim((string) ($target["public_key"] ?? ""));
    if (
        $privateKey !== "" &&
        $publicKey !== "" &&
        function_exists("ssh2_auth_pubkey_file")
    ) {
        $authenticated = @ssh2_auth_pubkey_file(
            $connection,
            $username,
            $publicKey,
            $privateKey,
            $password !== "" ? $password : null,
        );
    } elseif (function_exists("ssh2_auth_password")) {
        $authenticated = @ssh2_auth_password($connection, $username, $password);
    }

    if (!$authenticated) {
        throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
    }

    $sftp = @ssh2_sftp($connection);
    if ($sftp === false) {
        throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
    }

    $remotePath = $remoteDir . "/" . basename($archivePath);
    $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
    if ($targetStream === false) {
        throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
    }

    $source = fopen($archivePath, "rb");
    if ($source === false) {
        fclose($targetStream);
        throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
    }

    $copied = stream_copy_to_stream($source, $targetStream);
    fclose($source);
    fclose($targetStream);

    if ($copied === false) {
        throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
    }

    return ["remote_path" => "sftp://" . $host . $remotePath];
}

function backupValidateManagedInstance(string $instance): string
{
    $instance = trim($instance);
    if (
        $instance === "" ||
        strlen($instance) > 120 ||
        preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
    ) {
        throw new RuntimeException("Managed-Backup-Instanz ist ungültig.");
    }

    return $instance;
}

function backupBuildMultipartBody(array $fields, string $fileField, string $filePath, string $fileName, string $boundary): string
{
    $body = "";
    foreach ($fields as $name => $value) {
        $body .= "--" . $boundary . "\r\n";
        $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
        $body .= (string) $value . "\r\n";
    }

    $payload = file_get_contents($filePath);
    if ($payload === false) {
        throw new RuntimeException("Backup-ZIP konnte für Managed Upload nicht gelesen werden.");
    }

    $body .= "--" . $boundary . "\r\n";
    $body .=
        'Content-Disposition: form-data; name="' .
        addcslashes($fileField, "\"\\") .
        '"; filename="' .
        addcslashes($fileName, "\"\\") .
        "\"\r\n";
    $body .= "Content-Type: application/zip\r\n\r\n";
    $body .= $payload . "\r\n";
    $body .= "--" . $boundary . "--\r\n";

    return $body;
}

function backupUploadToManaged(string $archivePath, array $metadata, array $target): array
{
    $url = trim((string) ($target["url"] ?? ""));
    $instance = backupValidateManagedInstance((string) ($target["instance"] ?? ""));

    if (!filter_var($url, FILTER_VALIDATE_URL)) {
        throw new RuntimeException("Managed-Backup-URL ist ungültig.");
    }

    $filename = basename($archivePath);
    $sha256 = trim((string) ($metadata["sha256"] ?? ""));
    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
        $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
    }
    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
        throw new RuntimeException("Managed-Backup-Prüfsumme konnte nicht berechnet werden.");
    }

    $boundary = "----psa-backup-" . bin2hex(random_bytes(12));
    $body = backupBuildMultipartBody(
        [
            "instance" => $instance,
            "filename" => $filename,
            "sha256" => $sha256,
        ],
        "backup",
        $archivePath,
        $filename,
        $boundary,
    );

    $context = stream_context_create([
        "http" => [
            "method" => "POST",
            "timeout" => (int) ($target["timeout"] ?? 120),
            "ignore_errors" => true,
            "header" =>
                "Content-Type: multipart/form-data; boundary=" .
                $boundary .
                "\r\nAccept: application/json\r\nUser-Agent: " .
                backupGetHttpUserAgent() .
                "\r\nContent-Length: " .
                strlen($body) .
                "\r\n",
            "content" => $body,
        ],
    ]);

    $response = @file_get_contents($url, false, $context);
    $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
    if (function_exists("http_get_last_response_headers")) {
        $lastHeaders = http_get_last_response_headers();
        $headers = is_array($lastHeaders) ? $lastHeaders : [];
    } else {
        $legacyHeaders = ${"http_response_header"} ?? [];
        $headers = is_array($legacyHeaders)
            ? $legacyHeaders
            : [];
    }
    $status = backupGetHttpStatusFromHeaders($headers);
    if ($response === false || $status < 200 || $status >= 300) {
        throw new BackupRemoteUploadException(
            "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
            [
                "http_status" => $status,
                "response_excerpt" => backupFormatResponseExcerpt($response),
                "response_headers" => backupFormatHeaderExcerpt($headers),
                "php_error" => $phpError,
                "url" => $url,
                "instance" => $instance,
            ],
        );
    }

    $decoded = json_decode($response, true);
    if (!is_array($decoded) || empty($decoded["success"])) {
        $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
        throw new BackupRemoteUploadException(
            "Managed-Backup-Upload wurde abgelehnt" . ($error !== "" ? ": " . $error : "."),
            [
                "http_status" => $status,
                "response_excerpt" => backupFormatResponseExcerpt($response),
                "response_headers" => backupFormatHeaderExcerpt($headers),
                "url" => $url,
                "instance" => $instance,
                "server_error" => $error,
            ],
        );
    }

    return [
        "remote_path" => $url,
        "instance" => $instance,
        "server_filename" => (string) ($decoded["filename"] ?? ""),
    ];
}

function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
{
    $file = trim((string) ($target["file"] ?? ""));
    $callback = $target["callback"] ?? null;

    if ($file !== "") {
        if (!is_file($file)) {
            throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
        }
        require_once $file;
    }

    if (!is_callable($callback)) {
        throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
    }

    $result = call_user_func($callback, $archivePath, $metadata, $target);
    if ($result === true) {
        return [];
    }
    if (is_array($result)) {
        return $result;
    }

    throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
}

function backupUploadRemotes(string $archivePath, array $metadata): array
{
    $results = [];

    foreach (backupGetRemoteTargets() as $index => $target) {
        if (!is_array($target)) {
            continue;
        }

        $type = trim((string) ($target["type"] ?? ""));
        $label = backupGetTargetLabel($target, (int) $index);
        $startedAt = date("c");
        $safeTargetContext = backupGetSafeTargetContext($target);

        try {
            if ($type === "s3") {
                $extra = backupUploadToS3($archivePath, $metadata, $target);
            } elseif ($type === "sftp") {
                $extra = backupUploadToSftp($archivePath, $metadata, $target);
            } elseif ($type === "custom") {
                $extra = backupUploadToCustom($archivePath, $metadata, $target);
            } elseif ($type === "managed") {
                $extra = backupUploadToManaged($archivePath, $metadata, $target);
            } else {
                throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
            }

            $result = array_merge(
                [
                    "target" => $label,
                    "type" => $type,
                    "success" => true,
                    "uploaded_at" => date("c"),
                    "started_at" => $startedAt,
                ],
                is_array($extra) ? $extra : [],
            );
            $results[] = $result;

            logAccess("Backup remote upload succeeded", [
                "target" => $label,
                "type" => $type,
                "filename" => $metadata["filename"] ?? basename($archivePath),
                "remote_path" => (string) ($result["remote_path"] ?? ""),
            ]);
        } catch (Throwable $exception) {
            $debugContext = $exception instanceof BackupRemoteUploadException
                ? $exception->getDebugContext()
                : [];
            $result = [
                "target" => $label,
                "type" => $type !== "" ? $type : "unknown",
                "success" => false,
                "started_at" => $startedAt,
                "error" => $exception->getMessage(),
            ];
            if (!empty($debugContext)) {
                $result["debug"] = $debugContext;
            }
            $results[] = $result;

            logError("Backup remote upload failed", [
                "target" => $label,
                "type" => $type !== "" ? $type : "unknown",
                "target_config" => $safeTargetContext,
                "filename" => $metadata["filename"] ?? basename($archivePath),
                "sha256" => $metadata["sha256"] ?? "",
                "error" => $exception->getMessage(),
                "debug" => $debugContext,
            ]);
        }
    }

    return $results;
}

function backupGetLastAutomaticAt(): int
{
    foreach (backupListBackups() as $record) {
        if (($record["trigger"] ?? "") !== "automatic") {
            continue;
        }

        $timestamp = strtotime((string) ($record["created_at"] ?? ""));
        if ($timestamp !== false) {
            return $timestamp;
        }
    }

    return 0;
}

function backupIsAutomaticDue(): bool
{
    $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
    if ($interval < 1) {
        return false;
    }

    return time() - backupGetLastAutomaticAt() >= $interval;
}

function backupCreate(string $trigger = "manual"): array
{
    $trigger = $trigger === "automatic" ? "automatic" : "manual";
    $dir = backupGetDirectory();
    backupEnsureDirectory($dir);

    $lockHandle = fopen(backupGetLockFile(), "c+");
    if ($lockHandle === false) {
        throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
    }

    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle);
        throw new RuntimeException("Es läuft bereits ein Backup.");
    }

    try {
        $baseName = "backup-" . date("Ymd-His");
        $filename = $baseName . ".zip";
        $counter = 2;
        while (file_exists($dir . $filename)) {
            $filename = $baseName . "-" . $counter . ".zip";
            $counter++;
        }

        $tmpFile = $dir . "." . $filename . ".tmp";
        $archivePath = $dir . $filename;
        $createdAt = date("c");
        $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());

        if (!rename($tmpFile, $archivePath)) {
            @unlink($tmpFile);
            throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
        }
        @chmod($archivePath, 0660);

        $record = [
            "filename" => $filename,
            "created_at" => $createdAt,
            "trigger" => $trigger,
            "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
            "file_count" => $zipStats["file_count"],
            "source_bytes" => $zipStats["source_bytes"],
            "sha256" => $zipStats["sha256"],
            "remote_uploads" => backupUploadRemotes($archivePath, [
                "filename" => $filename,
                "created_at" => $createdAt,
                "trigger" => $trigger,
                "sha256" => $zipStats["sha256"],
            ]),
        ];

        $records = backupListBackups();
        array_unshift($records, $record);
        backupWriteIndex($records);
        backupApplyRetention();

        logAccess("Backup created", [
            "filename" => $filename,
            "trigger" => $trigger,
            "file_count" => $record["file_count"],
        ]);

        return $record;
    } catch (Throwable $exception) {
        logError("Backup failed", [
            "trigger" => $trigger,
            "error" => $exception->getMessage(),
        ]);
        throw $exception;
    } finally {
        flock($lockHandle, LOCK_UN);
        fclose($lockHandle);
    }
}

function backupCreateAutomaticIfDue(): ?array
{
    if (!backupIsAutomaticDue()) {
        return null;
    }

    return backupCreate("automatic");
}
