<?php
/**
 * Automated Deployment & Backup Wizard (Terminal CLI Version)
 *
 * Instructions:
 * 1. Upload `update.zip` to your application root folder (the one containing
 *    gws-backup.db). `update.php` is already there from the previous update —
 *    upload it too if this is your first run of a release that ships it.
 * 2. Connect via SSH or cPanel Terminal and navigate to that folder.
 * 3. Run: php update.php
 * 4. Restart your Next.js application in cPanel.
 *
 * `update.zip` is deleted afterwards. `update.php` is KEPT: it is replaced during
 * extraction by the copy shipped with the release, so the next update is always
 * run by a script that matches the payload.
 */

/**
 * Updater version. Printed on every run.
 *
 * This exists because "the updater only backed up the console database" is
 * indistinguishable from "an older updater ran" without it. Bump it whenever the
 * backup or extraction behaviour changes.
 *
 * Defined BEFORE it is used: PHP 8 treats an undefined constant as a fatal error,
 * so a define() sitting below the banner that prints it takes the whole script
 * down on line one.
 */
/**
 * Stamped at pack time by apps/main-app/scripts/stage-installer.mjs, from the
 * single source of truth in lib/branding.ts. Do not edit by hand.
 */
define('GWSB_VERSION', '0.96');
define('GWSB_PLATFORM', 'cpanel');
define('UPDATER_VERSION', GWSB_PLATFORM . ' ' . GWSB_VERSION);

/**
 * Terminal only, and checked before anything else runs.
 *
 * The install belongs outside public_html, so this should never be web-reachable
 * — but it is now left on disk between updates rather than deleted, and the
 * database backup runs BEFORE the payload check. A stray HTTP request would
 * otherwise mint a full backup zip every time it was hit, which is a way to fill
 * a disk from the outside. Cheap to refuse; expensive to leave open.
 */
if (php_sapi_name() !== 'cli') {
    http_response_code(404);
    exit;
}

echo "========================================\n";
echo "         SYSTEM UPDATE WIZARD           \n";
echo "========================================\n";
echo "  updater " . UPDATER_VERSION . "\n";
echo "========================================\n\n";

// 1. Locate the native persistent database
$dbPath = __DIR__ . '/gws-backup.db';
if (!file_exists($dbPath)) {
    $dbPath = __DIR__ . '/dev.db';
}
if (!file_exists($dbPath)) {
    $dbPath = __DIR__ . '/apps/main-app/dev.db';
}
if (!file_exists($dbPath)) {
    $dbPath = __DIR__ . '/apps/main-app/prod.db';
}

// The frozen index archive sits beside the main database and is part of it.
$archivePath = preg_replace('/\.db$/', '-archive.db', $dbPath);

// Since ROADMAP §7 R1 the backup index is one file per client under clients/.
// After the split the console database holds barely any backup data at all, so a
// "database backup" that omits these is a backup of the settings and nothing else.
$clientDbPaths = array();
$clientsDir = dirname($dbPath) . '/clients';
if (is_dir($clientsDir)) {
    foreach (glob($clientsDir . '/*.db') as $clientDb) {
        $clientDbPaths[] = $clientDb;
    }
}

if (file_exists($dbPath)) {
    echo ">> Found existing database at: " . basename($dbPath) . "\n";
    $size = filesize($dbPath);
    if (file_exists($archivePath)) {
        $archiveSize = filesize($archivePath);
        echo ">> Found frozen index archive: " . basename($archivePath) . " (" . number_format($archiveSize / 1048576, 1) . " MB)\n";
        $size += $archiveSize;
    }
    if (count($clientDbPaths) > 0) {
        $clientTotal = 0;
        foreach ($clientDbPaths as $clientDb) { $clientTotal += filesize($clientDb); }
        echo ">> Found " . count($clientDbPaths) . " per-client index database(s) (" . number_format($clientTotal / 1048576, 1) . " MB)\n";
        $size += $clientTotal;
    } else {
        // Silence here was the whole problem: an operator could not tell a correct
        // "this install has not been split yet" from a broken "the updater cannot
        // see the client databases". Say which it is, and where we looked.
        echo ">> No per-client index databases found in: " . $clientsDir . "\n";
        echo "   If this install HAS been split into per-client databases, stop now —\n";
        echo "   the backup below would cover the console database only.\n";
    }
    $free = disk_free_space(__DIR__);
    
    // Safety check: Require at least 2x the DB size for safety, plus 250MB for the unzipped payload
    $requiredSpace = ($size * 2) + 250000000;
    
    if ($free < $requiredSpace) {
        echo "\n[ERROR] Insufficient Storage Quota\n";
        echo "You need at least " . number_format($requiredSpace / 1024 / 1024, 2) . " MB of free disk space to backup the database and extract the update.\n";
        echo "Please delete some old files or upgrade your cPanel storage, then try again.\n";
        die(1);
    }

    $backupDir = __DIR__ . '/db_backups';
    if (!is_dir($backupDir)) {
        mkdir($backupDir, 0755, true);
    }

    // Append precise timestamp to prevent backup collisions
    $timestamp = date('Y-m-d_H-i-s');
    $zipBackupName = $backupDir . '/backup_db_' . $timestamp . '.zip';
    echo ">> Creating compressed database backup (including WAL/SHM files)...\n";
    
    $backupZip = new ZipArchive();
    if ($backupZip->open($zipBackupName, ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) {
        // Every file that makes up the database, not just the main one:
        //  - the -wal holds committed pages not yet checkpointed, so the .db alone
        //    can be minutes out of date;
        //  - the -archive.db holds the FROZEN index of retired destinations, which
        //    includes items that exist nowhere else and are never regenerated.
        // Missing either one means the "backup" cannot restore the install.
        //  - clients/<id>.db is that client's entire backup catalogue (§7 R1).
        foreach (array($dbPath, $archivePath) as $file) {
            if (!file_exists($file)) continue;
            $backupZip->addFile($file, basename($file));
            foreach (array('-wal', '-shm') as $sidecar) {
                if (file_exists($file . $sidecar)) {
                    $backupZip->addFile($file . $sidecar, basename($file) . $sidecar);
                }
            }
        }
        foreach ($clientDbPaths as $file) {
            $backupZip->addFile($file, 'clients/' . basename($file));
            foreach (array('-wal', '-shm') as $sidecar) {
                if (file_exists($file . $sidecar)) {
                    $backupZip->addFile($file . $sidecar, 'clients/' . basename($file) . $sidecar);
                }
            }
        }
        $backupZip->close();
        echo "[OK] Compressed database backup generated at: $zipBackupName\n\n";
    } else {
        echo "\n[ERROR] Database Backup Failed\n";
        echo "The script was unable to create the ZIP archive. Please check directory permissions or PHP ZipArchive extension.\n";
        die(1);
    }
} else {
    echo ">> [WARNING] Existing database not found. Treating as fresh installation or database is stored elsewhere.\n\n";
}

// 2. Locate the delivery payload
//
// Found by pattern rather than a fixed name, because every release ships as
// gwsbackup-<platform>-<version>-app.zip. The version in the filename is not
// decoration: it is checked against this installer's own version below, so a
// payload can never be extracted by the wrong installer.
$candidates = glob(__DIR__ . '/gwsbackup-*-app.zip');
if (!$candidates) {
    // Releases before versioned naming.
    $legacy = __DIR__ . '/update.zip';
    if (file_exists($legacy)) $candidates = array($legacy);
}

if (!$candidates) {
    echo "[ERROR] No release payload found\n";
    echo "Expected a file named gwsbackup-" . GWSB_PLATFORM . "-" . GWSB_VERSION . "-app.zip\n";
    echo "in this folder (" . __DIR__ . "). Upload it and run this again.\n";
    die(1);
}

if (count($candidates) > 1) {
    // Guessing here is how the wrong build gets installed. Stop and let a human
    // decide which one they meant.
    echo "[ERROR] More than one payload is present\n";
    foreach ($candidates as $c) echo "   - " . basename($c) . "\n";
    echo "Delete the ones you do not want, leaving a single .zip, then run this again.\n";
    die(1);
}

$zipFile = $candidates[0];
$payloadName = basename($zipFile);

// Version gate. The whole point of the versioned filenames is that this check
// can exist: an operator with several releases in their app root cannot run an
// old installer against a new payload without being told.
if (preg_match('/^gwsbackup-([a-z0-9]+)-([0-9][0-9.]*)-app\.zip$/i', $payloadName, $m)) {
    if (strcasecmp($m[1], GWSB_PLATFORM) !== 0) {
        echo "[ERROR] Wrong platform\n";
        echo "This installer is for '" . GWSB_PLATFORM . "', but " . $payloadName . " is for '" . $m[1] . "'.\n";
        die(1);
    }
    if ($m[2] !== GWSB_VERSION) {
        echo "[ERROR] Installer and payload do not match\n";
        echo "   payload:   " . $payloadName . " (version " . $m[2] . ")\n";
        echo "   installer: " . basename(__FILE__) . " (version " . GWSB_VERSION . ")\n\n";
        echo "Run the installer that came with that payload:\n";
        echo "   php gwsbackup-" . $m[1] . "-" . $m[2] . "-installer.php\n\n";
        echo "Both files are on the download page, and both are listed with a checksum.\n";
        die(1);
    }
} else {
    echo ">> Payload '" . $payloadName . "' predates versioned naming; extracting without a version check.\n";
}

echo ">> Payload: " . $payloadName . "\n";

$zip = new ZipArchive;
if ($zip->open($zipFile) === TRUE) {
    echo ">> Extracting payload into production scope...\n";
    
    echo ">> Removing stale .next build cache...\n";
    function rmrf($dir) {
        if (!is_dir($dir)) return;
        foreach (scandir($dir) as $item) {
            if ($item == '.' || $item == '..') continue;
            is_dir("$dir/$item") ? rmrf("$dir/$item") : unlink("$dir/$item");
        }
        rmdir($dir);
    }
    rmrf(__DIR__ . '/.next');

    // Extract over current files (the SQLite DB is preserved natively because it does not exist in the .zip)
    if (!$zip->extractTo(__DIR__)) {
        echo "\n[ERROR] Extraction Failed!\n";
        echo "Unable to extract the ZIP. Please check cPanel permissions or manually unzip.\n";
        die(1);
    }
    $zip->close();
    
    echo "\n>> Running database backup inventory...\n";
    $backupPath = __DIR__ . '/db_backups';
    $dbFiles = array_merge((array)glob($backupPath . '/*.db'), (array)glob($backupPath . '/*.zip'));
    $totalDBSize = 0;
    $dbCount = 0;
    if (!empty($dbFiles)) {
        foreach ($dbFiles as $file) {
            if (is_file($file)) {
                $totalDBSize += filesize($file);
                $dbCount++;
            }
        }
    }
    
    if ($dbCount > 0) {
        echo "   [!] Notice: You currently have $dbCount database backup(s) stored in: '$backupPath'\n";
        echo "   [!] Total historical backup footprint: " . number_format($totalDBSize / 1024 / 1024, 2) . " MB.\n";
        echo "   [!] Reminder: These backups consume host server space logarithmically. Once you have verified the system is stable, delete older backups via SSH or FTP.\n\n";
    }

    /**
     * The payload goes; the updater STAYS.
     *
     * It used to delete itself, which meant every update needed a freshly
     * downloaded update.php — and reusing an old copy that was still lying around
     * silently ran old behaviour. That is exactly how an install ended up backing
     * up the console database and none of the per-client ones: the script that ran
     * predated per-client support, and nothing said so.
     *
     * update.php now ships INSIDE update.zip, so the extraction above has just
     * replaced this file with the version that matches the release. Leaving it in
     * place means the next update is always run by a current script. It is inert
     * without an update.zip beside it, and refuses to run over HTTP.
     */
    @unlink($zipFile);
    echo "[OK] Payload '" . $payloadName . "' removed.\n";

    /**
     * Sweep up older installers.
     *
     * Extraction has just written this release's installer beside us. Any OTHER
     * gwsbackup-*-installer.php in this folder is from a previous release, and
     * leaving them is how somebody ends up running last month's script against
     * this month's payload — the version gate would catch it, but only after
     * they had wasted a step. The current one stays; the rest go.
     *
     * Pre-versioned update.php goes too, for the same reason.
     */
    $keep = __DIR__ . '/gwsbackup-' . GWSB_PLATFORM . '-' . GWSB_VERSION . '-installer.php';
    $stale = glob(__DIR__ . '/gwsbackup-*-installer.php');
    if (file_exists(__DIR__ . '/update.php')) $stale[] = __DIR__ . '/update.php';

    $removed = 0;
    foreach ($stale as $old) {
        if (realpath($old) === realpath($keep)) continue;
        if (realpath($old) === realpath(__FILE__)) continue;  // never the running script
        if (@unlink($old)) { $removed++; echo "[OK] Removed superseded installer: " . basename($old) . "\n"; }
    }

    echo "[OK] Kept " . basename($keep) . " for the next update.\n";
    if ($removed === 0) echo "     No superseded installers to clear.\n";
    echo "\n";

    echo "----------------------------------------\n";
    echo "[SUCCESS] Update Pipeline Concluded!\n";
    echo "----------------------------------------\n";
    echo "The application layer has been upgraded. Your native SQLite polyfill will attach during the next boot sequence.\n\n";
    echo "*** ACTION REQUIRED ***\n";
    echo "Please restart the Node.js process from your cPanel App Manager or CLI PM2 instance to load the patched compiler nodes into memory.\n\n";
} else {
    echo "\n[ERROR] Corrupt Zip File\n";
    echo "PHP was unable to read the update archive. Please re-upload it.\n";
    die(1);
}
?>
