<?php

declare(strict_types=1);

/**
 * In-forum software updater for the PPPHP Community Portal (offline package model).
 *
 * The community portal is hosted separately from the PPPHP console, so updates are
 * delivered as a SIGNED ZIP the owner downloads from the console and an admin
 * UPLOADS here — no update host, no outbound fetch, nothing served from the app.
 *
 * Trust model (unchanged, just carried in the file instead of over HTTP):
 *   • The console signs a small JSON manifest with an Ed25519 secret key and packs
 *     it — together with the app payload — into one update package:
 *         update-package.zip
 *           ├─ update.json   ← PPCU1.<b64url(manifest)>.<b64url(sig)>  (signed)
 *           └─ bundle.zip    ← the actual forum files to overlay
 *     The manifest carries bundle.zip's sha256, so the signature covers the payload.
 *   • The forum pins the matching PUBLIC key in forum/update-key.php (baked at
 *     download, immutable from the admin UI).
 *   • On upload the forum verifies the signature, checks the payload hash against the
 *     signed manifest, confirms the version is newer, backs up every file it will
 *     replace, then overlays — never touching config/, uploads/, storage/, the
 *     database, or the trust key.
 *
 * Uses ONLY ext-sodium + ext-zip, so it works inside a generated forum build that
 * carries none of the platform's Update/Licensing classes.
 */

const FORUM_UPDATE_PREFIX = 'PPCU1';   // domain-separated signed-manifest token

/** The version this install is running. */
function forum_update_version(): string
{
    $f = __DIR__ . '/version.php';
    return is_file($f) ? trim((string) (@include $f)) : '0.0.0';
}

/** The pinned publisher public key (raw 32 bytes), or '' when none is baked. */
function forum_update_pubkey(): string
{
    $f = __DIR__ . '/update-key.php';
    $b64 = is_file($f) ? trim((string) (@include $f)) : '';
    $raw = $b64 !== '' ? base64_decode($b64, true) : '';
    return (is_string($raw) && strlen($raw) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) ? $raw : '';
}

/** Updates can only run when sodium is present AND a publisher key is pinned. */
function forum_update_enabled(): bool
{
    return function_exists('sodium_crypto_sign_verify_detached') && forum_update_pubkey() !== '';
}

function forum_update_b64url_decode(string $s): string
{
    return (string) base64_decode(strtr($s, '-_', '+/'), true);
}

/** Verify + decode a PPCU1.<b64url(json)>.<b64url(sig)> token → payload or null. */
function forum_update_open(string $token): ?array
{
    $pub = forum_update_pubkey();
    if ($pub === '') return null;
    $parts = explode('.', trim($token));
    if (count($parts) !== 3 || $parts[0] !== FORUM_UPDATE_PREFIX) return null;
    $sig = forum_update_b64url_decode($parts[2]);
    if (strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
    if (!sodium_crypto_sign_verify_detached($sig, $parts[0] . '.' . $parts[1], $pub)) return null;
    $data = json_decode(forum_update_b64url_decode($parts[1]), true);
    return is_array($data) ? $data : null;
}

/** The install root to overlay onto (forum/ lives at <root>/forum). */
function forum_update_root(): string
{
    return dirname(__DIR__);
}

/** Paths an update must NEVER overwrite — all live state + local config. */
function forum_update_is_protected(string $rel): bool
{
    $rel = ltrim(str_replace('\\', '/', $rel), '/');
    foreach (['config', 'uploads', 'storage'] as $p) {
        if ($rel === $p || strpos($rel, $p . '/') === 0) return true;
    }
    $base = basename($rel);
    // update-key.php is the trust anchor — pinned at install, NEVER changed by an
    // update, so a malicious/misbuilt bundle can't swap whose signature is trusted.
    if (in_array($base, ['database.sql', 'installed.lock', 'install.lock', '.htaccess', 'update-key.php'], true)) return true;
    if (preg_match('/\.sqlite\d?$/i', $base)) return true;
    return false;
}

/**
 * Verify + apply an UPLOADED update package. Returns {ok,message,applied,version,backup}.
 *
 * @param array<string,mixed> $file one entry of $_FILES (the uploaded .zip)
 */
function forum_update_apply_upload(array $file): array
{
    if (!function_exists('sodium_crypto_sign_verify_detached')) {
        return ['ok' => false, 'message' => 'This server is missing the Sodium extension, so signed updates cannot be verified.'];
    }
    if (forum_update_pubkey() === '') {
        return ['ok' => false, 'message' => 'Software updates are not enabled for this install (no publisher key is pinned).'];
    }
    if (!class_exists('ZipArchive')) {
        return ['ok' => false, 'message' => 'The PHP Zip extension is required to apply updates.'];
    }
    $err = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
    if ($err === UPLOAD_ERR_INI_SIZE || $err === UPLOAD_ERR_FORM_SIZE) {
        return ['ok' => false, 'message' => 'The update package is larger than this server allows for uploads. Raise upload_max_filesize / post_max_size, or apply it by hand.'];
    }
    if ($err !== UPLOAD_ERR_OK || !is_string($file['tmp_name'] ?? null) || !is_uploaded_file((string) $file['tmp_name'])) {
        return ['ok' => false, 'message' => 'No update package was uploaded, or the upload failed. Pick the .zip you downloaded from the console and try again.'];
    }

    $root = forum_update_root();
    $tmp  = $root . '/storage/update-tmp';
    if (!is_dir($tmp)) @mkdir($tmp, 0775, true);
    if (!is_writable($root) || !is_dir($tmp) || !is_writable($tmp)) {
        return ['ok' => false, 'message' => 'The install directory is not writable by PHP, so an update cannot be applied automatically here.'];
    }

    // 1. Open the container, read the SIGNED manifest, verify it.
    $container = new ZipArchive();
    if ($container->open((string) $file['tmp_name']) !== true) {
        return ['ok' => false, 'message' => 'That file is not a valid update package (could not open the archive).'];
    }
    $manifestJson = $container->getFromName('update.json');
    if ($manifestJson === false) {
        $container->close();
        return ['ok' => false, 'message' => 'That package is missing its signed manifest — it is not a PPPHP community update.'];
    }
    $m = forum_update_open((string) $manifestJson);
    if ($m === null) {
        $container->close();
        return ['ok' => false, 'message' => 'The update package could not be verified against the pinned publisher key, so it was rejected.'];
    }
    $new = (string) ($m['version'] ?? '');
    $cur = forum_update_version();
    if ($new === '') { $container->close(); return ['ok' => false, 'message' => 'The update package has no version.']; }
    if (!version_compare($new, $cur, '>')) {
        $container->close();
        return ['ok' => false, 'message' => 'This package is version ' . $new . '; this install is already on ' . $cur . '.'];
    }
    if (!empty($m['min_php']) && version_compare(PHP_VERSION, (string) $m['min_php'], '<')) {
        $container->close();
        return ['ok' => false, 'message' => 'Update ' . $new . ' needs PHP ' . $m['min_php'] . '; this server runs ' . PHP_VERSION . '.'];
    }

    // 2. Pull the payload and verify its hash against the SIGNED manifest.
    $bundleData = $container->getFromName('bundle.zip');
    $container->close();
    if ($bundleData === false) {
        return ['ok' => false, 'message' => 'The update package is missing its payload (bundle.zip).'];
    }
    if (($m['sha256'] ?? '') !== '' && !hash_equals(strtolower((string) $m['sha256']), hash('sha256', $bundleData))) {
        return ['ok' => false, 'message' => 'The update payload did not match its verified checksum and was discarded.'];
    }
    $bundlePath = $tmp . '/bundle-' . bin2hex(random_bytes(4)) . '.zip';
    if (@file_put_contents($bundlePath, $bundleData) === false) {
        return ['ok' => false, 'message' => 'Could not stage the update payload — is storage/ writable?'];
    }
    unset($bundleData);

    // 3. Extract the payload to staging, rejecting any unsafe entry.
    $stage = $tmp . '/stage-' . bin2hex(random_bytes(4));
    @mkdir($stage, 0775, true);
    $zip = new ZipArchive();
    if ($zip->open($bundlePath) !== true) { @unlink($bundlePath); return ['ok' => false, 'message' => 'The update payload is not a valid archive.']; }
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = (string) $zip->getNameIndex($i);
        if ($name === '' || $name[0] === '/' || strpos($name, '..') !== false || strpos($name, "\0") !== false) {
            $zip->close(); @unlink($bundlePath); forum_update_rmdir($stage);
            return ['ok' => false, 'message' => 'The update payload contained an unsafe path and was rejected.'];
        }
    }
    if (!$zip->extractTo($stage)) { $zip->close(); @unlink($bundlePath); forum_update_rmdir($stage); return ['ok' => false, 'message' => 'Could not extract the update payload.']; }
    $zip->close();
    $src = forum_update_bundle_root($stage);

    // 4. Back up + overlay.
    $res = forum_update_overlay($src, $root, $new);

    // 5. Clean up staging (keep the backup as the restore point).
    @unlink($bundlePath);
    forum_update_rmdir($stage);
    return $res;
}

/** Overlay every file in $src onto $root, backing up the ones it replaces and
 *  skipping protected paths. Returns {ok,message,applied,version,backup}. */
function forum_update_overlay(string $src, string $root, string $version): array
{
    $backup = $root . '/storage/backups/community-' . date('Ymd-His');
    @mkdir($backup, 0775, true);
    $applied = 0; $failed = [];
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );
    foreach ($it as $item) {
        $rel = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($src))), '/');
        if ($rel === '' || forum_update_is_protected($rel)) continue;
        $dest = $root . '/' . $rel;
        if ($item->isDir()) { if (!is_dir($dest)) @mkdir($dest, 0775, true); continue; }
        if (is_file($dest)) {
            $bdir = $backup . '/' . dirname($rel);
            if (!is_dir($bdir)) @mkdir($bdir, 0775, true);
            @copy($dest, $backup . '/' . $rel);
        } elseif (!is_dir(dirname($dest))) {
            @mkdir(dirname($dest), 0775, true);
        }
        if (@copy($item->getPathname(), $dest)) { $applied++; } else { $failed[] = $rel; }
    }
    if ($failed) {
        return ['ok' => false, 'applied' => $applied, 'backup' => basename($backup),
                'message' => 'Update partly applied (' . $applied . ' file(s)); ' . count($failed) . ' could not be written. A restore point is at storage/backups/' . basename($backup) . '. Check file permissions and retry.'];
    }
    return ['ok' => true, 'applied' => $applied, 'version' => $version, 'backup' => basename($backup),
            'message' => 'Updated to version ' . $version . ' — ' . $applied . ' file(s) applied. A restore point was saved to storage/backups/' . basename($backup) . '.'];
}

/** If the payload wraps everything in a single top-level dir, descend into it. */
function forum_update_bundle_root(string $stage): string
{
    $entries = array_values(array_filter((array) @scandir($stage), static fn($e) => $e !== '.' && $e !== '..'));
    if (count($entries) === 1 && is_dir($stage . '/' . $entries[0])) {
        $inner = $stage . '/' . $entries[0];
        if (is_file($inner . '/index.php') || is_dir($inner . '/forum')) return $inner;
    }
    return $stage;
}

/** Recursively remove a staging directory. */
function forum_update_rmdir(string $dir): void
{
    if (!is_dir($dir)) return;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($it as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); }
    @rmdir($dir);
}

/** Human-readable byte size. */
function forum_update_bytes(int $n): string
{
    if ($n <= 0) return '';
    $u = ['B', 'KB', 'MB', 'GB']; $i = (int) floor(log(max(1, $n), 1024));
    return round($n / (1024 ** $i), $i ? 1 : 0) . ' ' . $u[$i];
}
