<?php

require_once __DIR__ . "/../config.php";
require_once __DIR__ . "/../includes/version.php";

if (empty($_SESSION["admin_logged_in"])) {
    header("Location: login.php");
    exit();
}

if (!defined("UPDATE_MANIFEST_URL")) {
    define("UPDATE_MANIFEST_URL", "");
}
if (!defined("UPDATE_WORK_DIR")) {
    define("UPDATE_WORK_DIR", DATA_DIR . "updates/work/");
}
if (!defined("UPDATE_BACKUP_DIR")) {
    define("UPDATE_BACKUP_DIR", DATA_DIR . "updates/backups/");
}

$appRoot = realpath(__DIR__ . "/..");
$messages = [];
$errors = [];

function updaterEscape($value): string
{
    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
}

function updaterCsrfToken(): string
{
    if (empty($_SESSION["updater_csrf_token"])) {
        $_SESSION["updater_csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["updater_csrf_token"];
}

function updaterValidateCsrfToken(string $token): bool
{
    return !empty($_SESSION["updater_csrf_token"]) &&
        hash_equals($_SESSION["updater_csrf_token"], $token);
}

function updaterVersionToCompare(string $version): string
{
    return ltrim(trim($version), "vV");
}

function updaterIsVersion(string $version): bool
{
    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
}

function updaterEnsureDirectory(string $dir): void
{
    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
        throw new RuntimeException("Directory cannot be created: " . $dir);
    }

    @chmod($dir, 02775);
}

function updaterRemoveDirectory(string $dir): void
{
    if (!is_dir($dir)) {
        return;
    }

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

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

    rmdir($dir);
}

function updaterHttpGet(string $url, int $timeout = 30): string
{
    if (!filter_var($url, FILTER_VALIDATE_URL)) {
        throw new RuntimeException("Invalid URL: " . $url);
    }

    $context = stream_context_create([
        "http" => [
            "method" => "GET",
            "timeout" => $timeout,
            "ignore_errors" => true,
            "header" => "User-Agent: PSA-Orderform-Updater/" . APP_VERSION . "\r\n",
        ],
    ]);

    $body = @file_get_contents($url, false, $context);
    $status = 0;
    $responseHeaders = function_exists("http_get_last_response_headers")
        ? http_get_last_response_headers()
        : [];
    if (is_array($responseHeaders)) {
        foreach ($responseHeaders as $header) {
            if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches)) {
                $status = (int) $matches[1];
            }
        }
    }

    if ($body === false || ($status >= 400 && $status < 600)) {
        throw new RuntimeException(
            "HTTP request failed" . ($status > 0 ? " with status " . $status : "") . ".",
        );
    }

    return $body;
}

function updaterFetchManifest(): array
{
    $url = trim((string) UPDATE_MANIFEST_URL);
    if ($url === "") {
        throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
    }

    $body = updaterHttpGet($url, 15);
    $manifest = json_decode($body, true);
    if (!is_array($manifest)) {
        throw new RuntimeException("Manifest response is not valid JSON.");
    }

    $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
    $packageUrl = trim((string) ($manifest["package_url"] ?? ""));
    $sha256 = strtolower(trim((string) ($manifest["sha256"] ?? "")));
    $size = isset($manifest["size"]) ? (int) $manifest["size"] : 0;
    $publishedAt = trim((string) ($manifest["published_at"] ?? ""));

    if (!updaterIsVersion($version)) {
        throw new RuntimeException("Manifest version is invalid.");
    }
    if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
        throw new RuntimeException("Manifest package URL is invalid.");
    }
    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
        throw new RuntimeException("Manifest checksum is invalid.");
    }

    return [
        "version" => $version,
        "package_url" => $packageUrl,
        "sha256" => $sha256,
        "size" => $size,
        "published_at" => $publishedAt,
    ];
}

function updaterDownloadPackage(array $manifest, string $targetFile): void
{
    updaterEnsureDirectory(dirname($targetFile));

    $data = updaterHttpGet($manifest["package_url"], 120);
    if ($data === "") {
        throw new RuntimeException("Downloaded package is empty.");
    }

    if (file_put_contents($targetFile, $data, LOCK_EX) === false) {
        throw new RuntimeException("Downloaded package cannot be written.");
    }

    if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) {
        unlink($targetFile);
        throw new RuntimeException("Downloaded package size mismatch.");
    }

    $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
    if ($actualHash !== $manifest["sha256"]) {
        unlink($targetFile);
        throw new RuntimeException("Package checksum mismatch.");
    }
}

function updaterValidateZipEntry(string $entry): bool
{
    $entry = str_replace("\\", "/", $entry);
    $normalized = trim($entry, "/");

    if (
        $normalized === "" ||
        str_contains($entry, "\0") ||
        str_starts_with($entry, "/") ||
        preg_match('/^[A-Za-z]:\//', $entry)
    ) {
        return false;
    }

    foreach (explode("/", $normalized) as $segment) {
        if ($segment === "" || $segment === "." || $segment === "..") {
            return false;
        }
    }

    return true;
}

function updaterExtractPackage(string $zipFile, string $stageDir): void
{
    if (!class_exists("ZipArchive")) {
        throw new RuntimeException("PHP ZipArchive extension is not available.");
    }

    updaterRemoveDirectory($stageDir);
    updaterEnsureDirectory($stageDir);

    $zip = new ZipArchive();
    if ($zip->open($zipFile) !== true) {
        throw new RuntimeException("Downloaded package is not a readable ZIP file.");
    }

    $hasAppFile = false;
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = (string) $zip->getNameIndex($i);
        if (!updaterValidateZipEntry($name)) {
            $zip->close();
            throw new RuntimeException("ZIP contains an unsafe path: " . $name);
        }

        if (
            $name === "index.php" ||
            str_starts_with($name, "admin/") ||
            str_starts_with($name, "includes/")
        ) {
            $hasAppFile = true;
        }
    }

    if (!$hasAppFile) {
        $zip->close();
        throw new RuntimeException("ZIP does not look like an app-root release package.");
    }

    if (!$zip->extractTo($stageDir)) {
        $zip->close();
        throw new RuntimeException("ZIP package cannot be extracted.");
    }

    $zip->close();
}

function updaterRelativePath(string $path, string $baseDir): string
{
    return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
}

function updaterShouldSkipPath(string $relativePath): bool
{
    $relativePath = trim(str_replace("\\", "/", $relativePath), "/");

    return $relativePath === "" ||
        $relativePath === "config.php" ||
        $relativePath === "data" ||
        str_starts_with($relativePath, "data/") ||
        $relativePath === ".git" ||
        str_starts_with($relativePath, ".git/");
}

function updaterCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
{
    updaterEnsureDirectory($backupDir);

    $copied = 0;
    $backedUp = 0;
    $skipped = 0;

    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST,
    );

    foreach ($items as $item) {
        $relativePath = updaterRelativePath($item->getPathname(), $stageDir);
        if (updaterShouldSkipPath($relativePath)) {
            $skipped++;
            continue;
        }

        $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;

        if ($item->isDir()) {
            updaterEnsureDirectory($targetPath);
            continue;
        }

        updaterEnsureDirectory(dirname($targetPath));

        if (file_exists($targetPath)) {
            $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
            updaterEnsureDirectory(dirname($backupPath));
            if (!copy($targetPath, $backupPath)) {
                throw new RuntimeException("Cannot back up file: " . $relativePath);
            }
            $backedUp++;
        }

        if (!copy($item->getPathname(), $targetPath)) {
            throw new RuntimeException("Cannot deploy file: " . $relativePath);
        }

        @chmod($targetPath, fileperms($item->getPathname()) & 0777);
        $copied++;
    }

    return [
        "copied" => $copied,
        "backed_up" => $backedUp,
        "skipped" => $skipped,
    ];
}

function updaterCleanupOldBackups(string $keepBackupDir): int
{
    $backupRoot = rtrim((string) UPDATE_BACKUP_DIR, "/\\");
    if (!is_dir($backupRoot)) {
        return 0;
    }

    $keepRealPath = realpath($keepBackupDir);
    $backupRootRealPath = realpath($backupRoot);
    if ($keepRealPath === false || $backupRootRealPath === false) {
        return 0;
    }

    $removed = 0;
    $items = new DirectoryIterator($backupRootRealPath);
    foreach ($items as $item) {
        if ($item->isDot() || !$item->isDir()) {
            continue;
        }

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

        updaterRemoveDirectory($path);
        if (is_dir($path)) {
            throw new RuntimeException("Old backup directory could not be removed: " . $path);
        }
        $removed++;
    }

    return $removed;
}

function updaterDeploy(array $manifest, string $appRoot): array
{
    $runId = date("Ymd-His");
    $workDir = rtrim((string) UPDATE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . $runId;
    $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
    $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
    $backupDir = rtrim((string) UPDATE_BACKUP_DIR, "/\\") .
        DIRECTORY_SEPARATOR .
        $runId .
        "-" .
        $manifest["version"];

    updaterEnsureDirectory($workDir);
    updaterDownloadPackage($manifest, $zipFile);
    updaterExtractPackage($zipFile, $stageDir);
    $result = updaterCopyWithBackup($stageDir, $appRoot, $backupDir);

    updaterRemoveDirectory($workDir);
    $removedBackups = updaterCleanupOldBackups($backupDir);

    return [
        "backup_dir" => $backupDir,
        "copied" => $result["copied"],
        "backed_up" => $result["backed_up"],
        "removed_backups" => $removedBackups,
        "skipped" => $result["skipped"],
    ];
}

$manifest = null;
$updateAvailable = false;

try {
    $manifest = updaterFetchManifest();
    $updateAvailable =
        version_compare(
            updaterVersionToCompare($manifest["version"]),
            updaterVersionToCompare(APP_VERSION),
            ">",
        );
} catch (Throwable $exception) {
    $errors[] = $exception->getMessage();
}

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
        $errors[] = "Invalid token. Please reload the page and try again.";
    } elseif ($appRoot === false) {
        $errors[] = "Application root cannot be resolved.";
    } else {
        try {
            $manifest = updaterFetchManifest();
            $force = !empty($_POST["force_redeploy"]);
            $updateAvailable =
                version_compare(
                    updaterVersionToCompare($manifest["version"]),
                    updaterVersionToCompare(APP_VERSION),
                    ">",
                );

            if (!$updateAvailable && !$force) {
                throw new RuntimeException(
                    "No newer update is available. Enable force redeployment to deploy this package anyway.",
                );
            }

            $result = updaterDeploy($manifest, $appRoot);
            $messages[] = "Deployment finished.";
            $messages[] = "Files copied: " . $result["copied"];
            $messages[] = "Files backed up: " . $result["backed_up"];
            $messages[] = "Old backup directories removed: " . $result["removed_backups"];
            $messages[] = "Skipped preserved paths: " . $result["skipped"];
            $messages[] = "Backup directory: " . $result["backup_dir"];
        } catch (Throwable $exception) {
            $errors[] = $exception->getMessage();
        }
    }
}

?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Updater</title>
</head>
<body>
    <h1>Updater</h1>

    <p><a href="settings.php">Back to settings</a></p>

    <?php foreach ($messages as $message): ?>
        <p><strong><?php echo updaterEscape($message); ?></strong></p>
    <?php endforeach; ?>

    <?php foreach ($errors as $error): ?>
        <p><strong>Error:</strong> <?php echo updaterEscape($error); ?></p>
    <?php endforeach; ?>

    <table border="1" cellpadding="6" cellspacing="0">
        <tbody>
            <tr>
                <th align="left">Installed version</th>
                <td><?php echo updaterEscape(APP_VERSION); ?></td>
            </tr>
            <tr>
                <th align="left">Update target URL</th>
                <td><?php echo updaterEscape(UPDATE_MANIFEST_URL); ?></td>
            </tr>
            <tr>
                <th align="left">Available version</th>
                <td><?php echo updaterEscape($manifest["version"] ?? "Unavailable"); ?></td>
            </tr>
            <tr>
                <th align="left">Package URL</th>
                <td><?php echo updaterEscape($manifest["package_url"] ?? "Unavailable"); ?></td>
            </tr>
            <tr>
                <th align="left">SHA-256</th>
                <td><?php echo updaterEscape($manifest["sha256"] ?? "Unavailable"); ?></td>
            </tr>
            <tr>
                <th align="left">Published at</th>
                <td><?php echo updaterEscape($manifest["published_at"] ?? "Unavailable"); ?></td>
            </tr>
            <tr>
                <th align="left">Update available</th>
                <td><?php echo $updateAvailable ? "Yes" : "No"; ?></td>
            </tr>
        </tbody>
    </table>

    <h2>Manual deployment</h2>
    <form method="POST">
        <input type="hidden" name="csrf_token" value="<?php echo updaterEscape(updaterCsrfToken()); ?>">
        <p>
            <label>
                <input type="checkbox" name="force_redeploy" value="1">
                Force redeployment
            </label>
        </p>
        <button type="submit" name="deploy_update" value="1">Deploy update</button>
    </form>
</body>
</html>
