<?php
/**
 * Pharos web installer — https://pharos.solutionmax.net/pharos-install.php
 *
 * Upload this one file into the folder your (sub)domain points at and open it
 * in a browser. It checks the server, downloads the signed release, verifies
 * the signature and checksum, unpacks the app *outside* the web folder, writes
 * .env, migrates the database, shows the one cron line — and deletes itself.
 *
 * A private setup key protects installation. Panel automation uses a temporary
 * Login Key or API token; application files are installed only after verification.
 */
declare(strict_types=1);

const PHAROS_RELEASES = 'https://github.com/Solutionmax/pharos/releases/download/v0.5.4';
const PHAROS_PUBKEY_HEX = '68d158ba363853e3b64efa7c2082015d198db31a4a039f05a24d3bbd93308ff2';
const PHAROS_MIN_PHP = '8.3.0';
// Leave empty to install the newest release; set "0.5.0" (or open ?version=0.5.0) to pin one.
const PHAROS_PIN_VERSION = '0.5.4';

if (defined('PHAROS_INSTALLER_LIBRARY')) { return; }

error_reporting(E_ALL);
ini_set('display_errors', '0');
@set_time_limit(600);
@ini_set('memory_limit', '256M');

$webRoot = __DIR__;
$appDir = pharos_app_dir($webRoot);
$step = (int) ($_GET['step'] ?? 1);
$action = $_POST['action'] ?? null;
$errors = [];
session_name('pharos_installer');
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Strict', 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'use_strict_mode' => true]);
$identity = hash('sha256', $webRoot);
$keyFile = dirname($appDir).'/.pharos-setup-'.substr($identity, 0, 16).'.key';
try {
    $installKey = pharos_setup_key($keyFile);
} catch (Throwable $e) {
    pharos_page('Installation needs a private writable folder', '<p>'.h($e->getMessage()).'</p>', 1);
    exit;
}
if (isset($_POST['install_key']) && hash_equals($installKey, (string) $_POST['install_key'])) {
    session_regenerate_id(true);
    $_SESSION['pharos_owner'] = hash('sha256', $installKey);
}
if (!hash_equals(hash('sha256', $installKey), (string) ($_SESSION['pharos_owner'] ?? ''))) {
    pharos_page('Unlock your installer', '<p>Open <code>'.h($keyFile).'</code> in your hosting File Manager and paste its key here. Only the hosting account owner can continue.</p><form method="post"><label>Installation key <input type="password" name="install_key" required autocomplete="off"></label><button class="btn">Unlock</button></form>', 1);
    exit;
}
if (!isset($_SESSION['pharos_csrf'])) { $_SESSION['pharos_csrf'] = bin2hex(random_bytes(32)); }
if ($action !== null && !hash_equals($_SESSION['pharos_csrf'], (string) ($_POST['csrf'] ?? ''))) {
    http_response_code(403);
    pharos_page('Request expired', '<p>Reload the installer and try again.</p>', 1);
    exit;
}
$installLock = fopen($keyFile.'.lock', 'c');
if (!$installLock || !flock($installLock, LOCK_EX | LOCK_NB)) {
    http_response_code(409);
    pharos_page('Installation is busy', '<p>Another installation request is running. Wait for it to finish.</p>', 1);
    exit;
}
register_shutdown_function(function () use ($installLock) { flock($installLock, LOCK_UN); fclose($installLock); });

$log = [];

// ---------- guard: never run over a *finished* install (a half-done one may resume) ----------
if (is_file("$appDir/.pharos-done")) {
    pharos_page('Already installed', '<p>Pharos is already installed in <code>'.h($appDir).'</code>. This installer refuses to run over it.</p>
      <p><a class="btn" href="'.h(pharos_base_url()).'/admin">Open the admin</a></p>
      <p class="sub">If you really want a fresh install, remove that folder first. Then delete this file: it has no business staying on a live site.</p>', 5);
    exit;
}

// ---------- actions ----------
try {
    if ($action === 'download') {
        if (is_file("$appDir/artisan") || is_file("$appDir/.env")) { throw new RuntimeException('An application already exists here. Resume configuration; never download over existing data.'); }
        $manifest = pharos_manifest();
        $log[] = "latest.json fetched — Ed25519 signature valid ({$manifest['version']})";
        $tmp = pharos_tmp_dir($appDir);
        $zip = "$tmp/pharos.zip";
        pharos_download($manifest['url'], $zip);
        $log[] = 'pharos-'.$manifest['version'].'.zip downloaded ('.round(filesize($zip) / 1048576, 1).' MB)';
        $got = hash_file('sha256', $zip);
        if (! hash_equals(strtolower($manifest['sha256']), strtolower((string) $got))) {
            throw new RuntimeException('SHA-256 mismatch: the download does not match the signed manifest. Nothing was written.');
        }
        $log[] = 'SHA-256 matches the signed manifest';
        pharos_unpack($zip, $tmp, $appDir);
        $log[] = "unpacked into $appDir";
        pharos_publish_public($appDir, $webRoot);
        $log[] = "public/ copied into $webRoot, index.php points at the app";
        pharos_rmdir($tmp);
        file_put_contents("$appDir/.pharos-installer", json_encode(['version' => $manifest['version'], 'at' => date('c')]));
        $step = 3;
    } elseif ($action === 'configure') {
        if (!is_file("$appDir/.pharos-installer") || is_file("$appDir/.pharos-configured")) { throw new RuntimeException('This installation cannot be reconfigured.'); }
        $url = rtrim(trim((string) ($_POST['app_url'] ?? '')), '/');
        if (! preg_match('~^https?://[^/\s]+$~', $url)) {
            throw new RuntimeException('The site address must look like https://status.example.com');
        }
        $db = ($_POST['db'] ?? 'sqlite') === 'mysql' ? 'mysql' : 'sqlite';
        if (!extension_loaded($db === 'mysql' ? 'pdo_mysql' : 'pdo_sqlite')) { throw new RuntimeException('Enable the PDO extension for your selected database first.'); }
        pharos_write_env($appDir, $url, $db, $_POST, $installKey);
        $log[] = '.env written, APP_KEY generated';
        try {
            pharos_artisan($appDir, 'migrate', ['--force' => true]);
        } catch (Throwable $e) {
            throw new RuntimeException('Migrating the database failed: '.$e->getMessage().' — fix the cause (usually the PHP version or a missing extension), then press "Write settings and migrate" again.');
        }
        $log[] = 'database migrated';
        pharos_link_storage($appDir, $webRoot, $log);
        file_put_contents("$appDir/.pharos-configured", date('c'));
        $step = 4;
    } elseif ($action === 'directadmin_cron') {
        $step = 4;
        if (!is_file("$appDir/.pharos-configured")) { throw new RuntimeException('Configure the app first.'); }
        $cli = pharos_cron_php();
        $selectedPhp = trim((string)($_POST['cron_php'] ?? $cli['path']));
        if (!preg_match('~^/[A-Za-z0-9/._-]+$~', $selectedPhp) || !pharos_php_version_of($selectedPhp) || version_compare(pharos_php_version_of($selectedPhp), pharos_min_mm(), '<')) { throw new RuntimeException('Enter a versioned absolute path to PHP '.pharos_min_mm().' or later.'); }
        $panelType = ($_POST['panel_type'] ?? 'directadmin') === 'cpanel' ? 'cpanel' : 'directadmin';
        $panelRequest = function ($panel, $user, $key, $fields) use ($panelType) { return pharos_da_request($panel, $user, $key, $fields, $panelType); };
        $log[] = pharos_da_cron(trim((string)($_POST['panel_url'] ?? '')), (string)($_POST['panel_user'] ?? ''), (string)($_POST['panel_key'] ?? ''), $appDir, $selectedPhp, $panelRequest);
        file_put_contents("$appDir/.pharos-cron-php", $selectedPhp);
    } elseif ($action === 'finish') {
        if (!is_file("$appDir/.pharos-configured")) { throw new RuntimeException('Finish configuration before closing the installer.'); }
        @file_put_contents("$appDir/.pharos-done", date('c'));
        @unlink(__FILE__);
        header('Location: '.pharos_base_url().'/admin');
        exit;
    }
} catch (Throwable $e) {
    $errors[] = $e->getMessage();
}

// ---------- resume a half-done install: release unpacked, settings not finished ----------
if ($step === 1 && $action === null && is_file("$appDir/artisan") && ! is_file("$appDir/.pharos-done")) {
    $step = is_file("$appDir/.pharos-configured") ? 4 : 3;
    $log[] = 'Found a release already unpacked in '.$appDir.' — continuing where it stopped. Your existing encryption key is preserved.';
}

// ---------- screens ----------
if ($step === 1 && version_compare(PHP_VERSION, PHAROS_MIN_PHP, '<')) {
    // Do not just say "too old": say which newer PHP this panel already has on disk.
    $newer = pharos_php_installs(true);
    $have = '';
    if ($newer) {
        $list = '';
        foreach (array_slice($newer, 0, 6) as $i) {
            $list .= '<div class="chk"><span class="k">PHP '.h($i['version']).'</span><span class="v">'.h($i['path']).'</span><span class="st ok">'.h($i['panel']).'</span></div>';
        }
        $have = '<p>Good news: this server already has a newer PHP installed. Point the domain at one of these and reload.</p><div class="checks">'.$list.'</div>';
    }
    pharos_page('This PHP is too old', '<p>This site runs <b>PHP '.h(PHP_VERSION).'</b>; Pharos needs '.PHAROS_MIN_PHP.' or later.</p>'.$have.'
      <p><b>DirectAdmin</b>: Account Manager → <b>Domain Setup</b> → your domain → <b>PHP Version</b> → pick the 8.3+ entry, Save. Extensions are built server-wide there, so if one is missing below, your host has to enable it in CustomBuild.</p>
      <p><b>cPanel / CloudLinux</b>: <b>Select PHP Version</b> (or <b>MultiPHP Manager</b> for one domain) → choose 8.3, and tick the extensions <code>sodium</code>, <code>zip</code>, <code>pdo_sqlite</code>, <code>mbstring</code>, <code>fileinfo</code>, <code>curl</code>, <code>xml</code>.</p>
      <p><b>Plesk</b>: Websites &amp; Domains → <b>PHP Settings</b> → PHP version.</p>
      <a class="btn" href="?step=1">Check again</a>', 1);
    exit;
}
if ($step === 1) {
    $checks = pharos_checks($appDir, $webRoot);
    // a soft check reports, it does not block: a missing cron binary costs you the scheduler, not the install
    $hard = array_filter($checks, function ($c) { return empty($c['soft']); });
    $allOk = ! in_array(false, array_column($hard, 'ok'), true);
    $rows = '';
    foreach ($checks as $c) {
        $state = $c['ok'] ? 'ok' : (empty($c['soft']) ? 'bad' : 'warn');
        $label = $c['ok'] ? 'ok' : (empty($c['soft']) ? 'fix' : 'check');
        $rows .= '<div class="chk"><span class="k">'.h($c['name']).'</span><span class="v">'.h($c['detail']).'</span><span class="st '.$state.'">'.$label.'</span></div>';
    }
    pharos_page('Can this server run Pharos?', '<p class="sub">Nothing is written yet. Fix what is red, then press Continue. Amber is a warning: the install works, something after it may not.</p>
      <div class="checks">'.$rows.'</div>
      <form method="post" action="?step=2'.(isset($_GET['version']) ? '&version='.h((string) $_GET['version']) : '').'"><input type="hidden" name="action" value="download">
        <a class="btn ghost" href="?step=1">Check again</a>
        <button class="btn" type="submit" '.($allOk ? '' : 'disabled').'>Continue — download the release</button>
      </form>', 1, $errors);
} elseif ($step === 3) {
    $guess = pharos_base_url();
    pharos_page('Configure', '<p class="sub">Where the status page lives, and where it keeps its data. SQLite needs nothing from your host.</p>
      <form method="post" class="form"><input type="hidden" name="action" value="configure">
        <label>Site address <input name="app_url" value="'.h($guess).'" required></label>
        <label>Database
          <select name="db" id="db"><option value="sqlite">SQLite (recommended, zero setup)</option><option value="mysql">MySQL / MariaDB</option></select>
        </label>
        <div id="mysql" class="mysql">
          <label>Host <input name="db_host" value="127.0.0.1"></label>
          <label>Database <input name="db_name"></label>
          <label>User <input name="db_user"></label>
          <label>Password <input name="db_pass" type="password"></label>
        </div>
        <button class="btn" type="submit">Write settings and migrate</button>
      </form>
      <script>var s=document.getElementById("db"),m=document.getElementById("mysql");function t(){m.style.display=s.value==="mysql"?"grid":"none"}s.onchange=t;t();</script>', 3, $errors, $log);
} elseif ($step === 4) {
    $cli = pharos_cron_php();
    $php = is_file("$appDir/.pharos-cron-php") ? trim(file_get_contents("$appDir/.pharos-cron-php")) : $cli['path'];
    $cron = "* * * * * cd ".pharos_shell_arg($appDir)." && ".pharos_shell_arg($php)." artisan schedule:run >> /dev/null 2>&1";
    $note = $cli['note'] === '' ? '' : '<p class="sub warnline">'.h($cli['note']).'</p>';
    pharos_page('One cron line. That is the whole scheduler.', '<p class="sub">Add this to your control panel, every minute. Pharos shows a warning in the admin until it sees the first run.</p>
      <p>Keep your installation key for the administrator form: <code>'.h($installKey).'</code></p>
      <div class="cron"><span class="star">* * * * *</span><span class="cmd">'.h(substr($cron, 10)).'</span><button class="cp" type="button" onclick="navigator.clipboard.writeText('.h(json_encode($cron)).').then(()=>this.textContent=\'Copied\')">Copy</button></div>
      '.$note.'
      <div class="panels">
        <div><b>cPanel</b>Advanced → Cron Jobs → Add. Five stars in the time fields, the rest in "Command".</div>
        <div><b>DirectAdmin</b>Advanced Features → Cron Jobs. Keep the full path: cron gets the server default PHP, not the one you picked in Domain Setup.</div>
        <div><b>Plesk</b>Tools &amp; Settings → Scheduled Tasks → Run a command.</div>
      </div>
      <details><summary>Set up cron through your panel (optional)</summary><p>cPanel uses its API token and port 2083; DirectAdmin uses a restricted Login Key and port 2222. Credentials are used for this request only. Revoke the token after setup. For Plesk or a panel with API access disabled, use Scheduled Tasks or run the CLI command <code>php artisan pharos:cron --install</code> with the detected PHP path.</p>
      <p>Use a temporary Login Key restricted to CMD_API_CRON_JOBS and this server’s IP. The key is used for this request only. Existing tasks are preserved.</p>
      <form method="post" action="?step=4" class="form"><input type="hidden" name="action" value="directadmin_cron">
      <label>Hosting panel <select name="panel_type"><option value="directadmin">DirectAdmin</option><option value="cpanel">cPanel / CloudLinux with cPanel</option></select></label>
      <label>Panel HTTPS address <input type="url" name="panel_url" placeholder="https://panel.example.com:2222" required></label>
      <label>Username <input name="panel_user" required autocomplete="off"></label>
      <label>Login Key or API token <input type="password" name="panel_key" required autocomplete="off"></label>
      <label>CLI PHP path <input name="cron_php" value="'.h($php).'" required></label>
      <button class="btn">Add and verify cron task</button></form></details>
      <form method="post"><input type="hidden" name="action" value="finish">
        <button class="btn" type="submit">Finish and open Pharos</button>
        <span class="sub">This deletes the installer and opens the setup screen (name your page, create your administrator).</span>
      </form>', 4, $errors, $log);
} else {
    header('Location: ?step=1');
}

// ============================================================ helpers
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
/** PHP 7-safe stand-ins, so an old host gets the "switch to PHP 8.3" screen and not a fatal error. */
function pharos_has(string $hay, string $needle): bool { return $needle === '' || strpos($hay, $needle) !== false; }
function pharos_starts(string $hay, string $needle): bool { return substr($hay, 0, strlen($needle)) === $needle; }

function pharos_base_url(): string
{
    $https = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
    $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
    $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/');
    return ($https ? 'https' : 'http').'://'.$host.$dir;
}

/** Outside the web folder if at all possible: the home directory first, a sibling folder second. */
function pharos_app_dir(string $webRoot): string
{
    $home = $_SERVER['HOME'] ?? null;
    $suffix = substr(hash('sha256', realpath($webRoot) ?: $webRoot), 0, 12);
    $parents = [$home, dirname($webRoot)];
    foreach ($parents as $parent) {
        if (!$parent || !@is_dir($parent) || !@is_writable($parent)) { continue; }
        $parent = rtrim(realpath($parent) ?: $parent, '/');
        if ($parent === $webRoot || pharos_starts($parent.'/', rtrim($webRoot, '/').'/')) { continue; }
        $legacy = $parent.'/pharos-app';
        if (@is_file($legacy.'/.pharos-public') && trim((string)file_get_contents($legacy.'/.pharos-public')) === $webRoot) { return $legacy; }
        return $parent.'/pharos-app-'.$suffix;
    }
    return dirname($webRoot).'/pharos-app-'.$suffix;
}

/** Two-part minimum, because version_compare('8.3', '8.3.0', '>=') is false. */
function pharos_min_mm(): string
{
    $p = explode('.', PHAROS_MIN_PHP);
    return $p[0].'.'.(isset($p[1]) ? $p[1] : '0');
}

/** Which panel this is, from what it leaves on disk. Only used to word the advice. */
function pharos_panel(): string
{
    if (@is_dir('/usr/local/directadmin')) { return 'DirectAdmin'; }
    if (@is_dir('/usr/local/cpanel') || @is_dir('/opt/cpanel')) { return 'cPanel'; }
    if (@is_dir('/opt/psa') || @is_dir('/usr/local/psa')) { return 'Plesk'; }
    return '';
}

/**
 * Every PHP this server keeps on disk, newest first. Filesystem only, on purpose:
 * DirectAdmin ships php.conf.d/50-webapps.ini, which disables exec, shell_exec and
 * proc_open, so a binary can be found and pointed at but never run to confirm itself.
 * The version therefore comes from the path and is major.minor — no patch level.
 */
function pharos_php_installs(bool $onlySupported = false): array
{
    static $cache = [];
    $key = $onlySupported ? 1 : 0;
    if (isset($cache[$key])) { return $cache[$key]; }
    $patterns = [
        '/usr/local/php*/bin/php' => 'DirectAdmin',         // CustomBuild: php84, plus leftovers like php74-cli
        '/opt/alt/php*/usr/bin/php' => 'CloudLinux',        // alt-php
        '/opt/cpanel/ea-php*/root/usr/bin/php' => 'cPanel', // EasyApache
        '/opt/plesk/php/*/bin/php' => 'Plesk',
        '/usr/bin/php[78].[0-9]' => 'system',               // Debian, Ubuntu, Remi
        '/usr/local/bin/php[78].[0-9]' => 'system',
    ];
    $found = [];
    foreach ($patterns as $glob => $panel) {
        $hits = @glob($glob);
        if (!$hits) { continue; }
        foreach ($hits as $path) {
            $v = pharos_php_version_of($path);
            // no version in the path (/usr/local/php/bin/php is a moving symlink) — not something to pin a cron line to
            if ($v === '' || isset($found[$path]) || ! @is_executable($path)) { continue; }
            if ($onlySupported && version_compare($v, pharos_min_mm(), '<')) { continue; }
            $found[$path] = ['path' => $path, 'version' => $v, 'panel' => $panel];
        }
    }
    $list = array_values($found);
    // newest first; path as tiebreak, because usort is only stable from PHP 8.0
    usort($list, function ($a, $b) {
        $v = version_compare($b['version'], $a['version']);
        return $v !== 0 ? $v : strcmp($a['path'], $b['path']);
    });
    return $cache[$key] = $list;
}

/** major.minor out of a path: php84 → 8.4, /opt/plesk/php/8.3/ → 8.3, /usr/bin/php8.3 → 8.3, ea-php83 → 8.3. */
function pharos_php_version_of(string $path): string
{
    if (preg_match('~/(?:php|ea-php)/?(\d)\.(\d+)(?:/|$)~', $path, $m)) { return $m[1].'.'.$m[2]; }
    if (preg_match('~/(?:php|ea-php|alt-php)(\d)(\d+)~', $path, $m)) { return $m[1].'.'.$m[2]; }
    return '';
}

/**
 * The binary for the cron line. Cron does not go through the panel's PHP selector:
 * on DirectAdmin the domain answers over /usr/local/php84/bin/lsphp while cron gets
 * /usr/local/bin/php, a symlink the host repoints on every CustomBuild run. So pin a
 * full path, and prefer the major.minor the site itself runs on.
 */
function pharos_cron_php(): array
{
    static $cache = null;
    if ($cache !== null) { return $cache; }
    $web = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION;
    $installs = pharos_php_installs(true);
    // A box can carry the same version twice (cPanel's ea-php next to CloudLinux alt-php).
    // The one to pick is the install this very request is running out of: on DirectAdmin
    // the web SAPI is /usr/local/php84/bin/lsphp, whose neighbour is the CLI php we want.
    $sibling = defined('PHP_BINARY') && PHP_BINARY !== '' ? dirname(PHP_BINARY) : '';
    $exact = null;
    foreach ($installs as $i) {
        if ($i['version'] !== $web) { continue; }
        if ($exact === null) { $exact = $i; }
        if ($sibling !== '' && dirname($i['path']) === $sibling) { $exact = $i; break; }
    }
    if ($exact !== null) {
        return $cache = ['path' => $exact['path'], 'ok' => true, 'note' => 'Detected from the filesystem, not executed. The first successful scheduler run confirms it works.', 'detail' => $exact['path'].' — PHP '.$exact['version'].', same as the site ('.$exact['panel'].')'];
    }
    if ($installs) {
        $i = $installs[0];
        return $cache = ['path' => $i['path'], 'ok' => true,
            'note' => 'The cron line runs PHP '.$i['version'].' while this site runs '.$web.'. This path has not been executed: confirm its extensions and a successful scheduler run before relying on monitoring.',
            'detail' => $i['path'].' — PHP '.$i['version'].' ('.$i['panel'].'), site runs '.$web];
    }
    $self = defined('PHP_BINARY') ? PHP_BINARY : '';
    if ($self !== '' && pharos_php_version_of($self) !== '' && @is_executable($self) && ! pharos_has($self, 'php-fpm') && ! pharos_has($self, 'lsphp') && ! pharos_has($self, 'php-cgi')) {
        return $cache = ['path' => $self, 'ok' => true, 'note' => '', 'detail' => $self.' — the binary running this request'];
    }
    return $cache = ['path' => 'php', 'ok' => false,
        'note' => 'No PHP binary could be found from the web, usually open_basedir. The line below says plain "php", which cron resolves to the server default — check in your panel that it is '.pharos_min_mm().' or later, or replace it with a full path such as /usr/local/php'.str_replace('.', '', $web).'/bin/php.',
        'detail' => 'not found from the web — the cron line falls back to plain "php"'];
}

function pharos_checks(string $appDir, string $webRoot): array
{
    $c = [];
    $c[] = ['name' => 'PHP version (web)', 'ok' => version_compare(PHP_VERSION, PHAROS_MIN_PHP, '>='), 'detail' => PHP_VERSION.' (need '.PHAROS_MIN_PHP.' or later)'];
    $where = pharos_panel() === 'DirectAdmin' ? 'pick a newer PHP in Domain Setup, or ask your host to build it into CustomBuild' : 'enable it under Select PHP Version (cPanel) or PHP Settings';
    foreach ([['pdo', 'PDO'], ['mbstring', 'mbstring'], ['openssl', 'openssl'], ['sodium', 'sodium — verifies the release signature'], ['zip', 'ZipArchive'], ['curl', 'curl'], ['fileinfo', 'fileinfo'], ['tokenizer', 'tokenizer'], ['xml', 'xml'], ['ctype', 'ctype']] as [$ext, $label]) {
        $c[] = ['name' => "Extension $ext", 'ok' => extension_loaded($ext), 'detail' => extension_loaded($ext) ? 'loaded' : "missing ($label) — $where"];
    }
    // The scheduler does not run under the web PHP. On DirectAdmin the domain runs
    // /usr/local/php84/bin/lsphp while cron gets /usr/local/bin/php — a different,
    // often older version. Say now which binary the cron line on step 4 will use.
    $cli = pharos_cron_php();
    $c[] = ['name' => 'PHP for cron', 'ok' => $cli['ok'], 'soft' => true, 'detail' => $cli['detail']];
    $c[] = ['name'=>'Database driver', 'ok'=>extension_loaded('pdo_sqlite') || extension_loaded('pdo_mysql'), 'detail'=>'SQLite or MySQL PDO driver required; select the database during configuration'];
    $parent = is_dir($appDir) ? $appDir : dirname($appDir);
    $c[] = ['name' => 'App folder', 'ok' => is_writable($parent), 'detail' => $appDir.(is_dir($appDir) ? ' (exists)' : ' (will be created)')];
    $c[] = ['name' => 'Web folder writable', 'ok' => is_writable($webRoot), 'detail' => $webRoot];
    try {
        $m = pharos_manifest();
        $c[] = ['name' => 'Outbound HTTPS', 'ok' => true, 'detail' => parse_url(PHAROS_RELEASES, PHP_URL_HOST).' reachable · latest release '.$m['version']];
    } catch (Throwable $e) {
        $c[] = ['name' => 'Outbound HTTPS', 'ok' => false, 'detail' => $e->getMessage()];
    }
    $c[] = ['name' => 'Existing install', 'ok' => ! is_file("$appDir/artisan"), 'detail' => is_file("$appDir/artisan") ? "found at $appDir" : 'none found'];
    return $c;
}

function pharos_http_get(string $url, ?string $toFile = null): string
{
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        $fh = $toFile ? fopen($toFile, 'wb') : null;
        curl_setopt_array($ch, [CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => 300, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_USERAGENT => 'pharos-install/1', CURLOPT_SSL_VERIFYPEER => true] + ($fh ? [CURLOPT_FILE => $fh] : [CURLOPT_RETURNTRANSFER => true]));
        $out = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err = curl_error($ch);
        curl_close($ch);
        if ($fh) { fclose($fh); }
        if ($out === false || $code !== 200) { throw new RuntimeException("Could not fetch $url (".($err ?: "HTTP $code").')'); }
        return $toFile ? '' : (string) $out;
    }
    $ctx = stream_context_create(['http' => ['timeout' => 300, 'user_agent' => 'pharos-install/1']]);
    $data = @file_get_contents($url, false, $ctx);
    if ($data === false) { throw new RuntimeException("Could not fetch $url (allow_url_fopen is off and curl is missing?)"); }
    if ($toFile) { file_put_contents($toFile, $data); return ''; }
    return $data;
}

/** The signed manifest: "<base64url payload>.<base64url signature>", verified with the public key above. */
function pharos_manifest(): array
{
    static $cached = null;
    if ($cached) { return $cached; }
    $pin = PHAROS_PIN_VERSION ?: (preg_match('/^\d+\.\d+\.\d+$/', (string) ($_GET['version'] ?? '')) ? $_GET['version'] : '');
    $raw = trim(pharos_http_get(PHAROS_RELEASES.($pin ? "/pharos-$pin.json" : '/latest.json')));
    if (! pharos_has($raw, '.')) { throw new RuntimeException('latest.json is not a signed manifest'); }
    [$p, $s] = explode('.', $raw, 2);
    $b64 = fn (string $v) => base64_decode(strtr($v, '-_', '+/').str_repeat('=', (4 - strlen($v) % 4) % 4), true);
    $payload = $b64($p); $sig = $b64($s);
    if ($payload === false || $sig === false || strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) { throw new RuntimeException('latest.json is malformed'); }
    if (! sodium_crypto_sign_verify_detached($sig, $payload, sodium_hex2bin(PHAROS_PUBKEY_HEX))) {
        throw new RuntimeException('The release manifest does not carry a valid SolutionMAX signature. Not installing.');
    }
    $m = json_decode($payload, true);
    if (! is_array($m) || ($m['purpose'] ?? null) !== 'pharos-release' || empty($m['version']) || empty($m['url']) || empty($m['sha256'])) {
        throw new RuntimeException('The manifest is not a Pharos release manifest');
    }
    if ($pin && $m['version'] !== $pin) {
        throw new RuntimeException("Asked for $pin but the manifest says {$m['version']} — refusing.");
    }
    return $cached = $m;
}

function pharos_tmp_dir(string $appDir): string
{
    $tmp = dirname($appDir).'/.pharos-install-tmp-'.substr(hash('sha256', $appDir), 0, 16);
    pharos_rmdir($tmp);
    if (! mkdir($tmp, 0755, true) && ! is_dir($tmp)) { throw new RuntimeException("Cannot create $tmp"); }
    return $tmp;
}

function pharos_download(string $url, string $to): void
{
    if (! pharos_starts($url, 'https://')) { throw new RuntimeException('The release URL is not https'); }
    pharos_http_get($url, $to);
    if (! is_file($to) || filesize($to) < 1000000) { throw new RuntimeException('Download is incomplete'); }
}

function pharos_unpack(string $zipPath, string $tmp, string $appDir): void
{
    $zip = new ZipArchive;
    if ($zip->open($zipPath) !== true) { throw new RuntimeException('The archive cannot be opened'); }
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = (string) $zip->getNameIndex($i);
        if (pharos_has($name, '..') || pharos_starts($name, '/')) { throw new RuntimeException("Refusing unsafe archive entry $name"); }
        $zip->getExternalAttributesIndex($i, $opsys, $attr);
        if ($opsys === ZipArchive::OPSYS_UNIX && (($attr >> 16) & 0170000) === 0120000) { throw new RuntimeException("Refusing archive entry $name: symlinks are not allowed in a release"); }
    }
    $x = "$tmp/x"; mkdir($x, 0755, true);
    if (! $zip->extractTo($x)) { throw new RuntimeException('Unpacking failed (disk full?)'); }
    $zip->close();
    $entries = array_values(array_diff(scandir($x) ?: [], ['.', '..']));
    $root = count($entries) === 1 && is_dir("$x/{$entries[0]}") ? "$x/{$entries[0]}" : $x;
    if (! is_file("$root/artisan")) { throw new RuntimeException('That archive does not look like a Pharos release'); }
    if (is_dir($appDir)) { throw new RuntimeException("Refusing to replace existing folder $appDir"); }
    if (! @rename($root, $appDir)) { pharos_copy_tree($root, $appDir); }
    // belt and braces if this folder ever ends up under a document root
    file_put_contents("$appDir/.htaccess", "Require all denied\n");
    foreach (['storage', 'bootstrap/cache', 'database'] as $d) { @chmod("$appDir/$d", 0775); }
}

/** The web folder gets public/ only; index.php is rewritten to find the app where it really is. */
function pharos_publish_public(string $appDir, string $webRoot): void
{
    $pub = "$appDir/public";
    // cPanel keeps its PHP handler and ini blocks in .htaccess: keep them, add Laravel's rules underneath.
    $panel = is_file("$webRoot/.htaccess") ? file_get_contents("$webRoot/.htaccess") : '';
    $ours = is_file("$pub/.htaccess") ? file_get_contents("$pub/.htaccess") : '';
    if ($ours !== '' && strpos($panel, '# BEGIN Pharos') === false) {
        // DirectAdmin ships a placeholder index.html that Apache would serve before index.php
        file_put_contents("$webRoot/.htaccess", (trim($panel) === '' ? '' : rtrim($panel)."\n\n")."# BEGIN Pharos\nDirectoryIndex index.php\n".trim($ours)."\n# END Pharos\n");
    }
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($pub, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $item) {
        $rel = substr($item->getPathname(), strlen($pub) + 1);
        if ($rel === '.htaccess') { continue; }
        $dest = "$webRoot/$rel";
        if ($item->isDir()) { @mkdir($dest, 0755, true); continue; }
        if (! copy($item->getPathname(), $dest)) { throw new RuntimeException("Cannot write $dest"); }
    }
    $index = file_get_contents("$webRoot/index.php");
    $index = str_replace("__DIR__.'/../", "'".addslashes($appDir)."/", $index);
    file_put_contents("$webRoot/index.php", $index);
    // the app must also know where its public folder is, for asset paths and storage:link
    file_put_contents("$appDir/.pharos-public", $webRoot);
}

function pharos_write_env(string $appDir, string $url, string $db, array $in, string $setupKey = ''): void
{
    $existing = is_file("$appDir/.env") ? file_get_contents("$appDir/.env") : '';
    $env = file_get_contents("$appDir/.env.example");
    $set = function (string $k, string $v) use (&$env) {
        $line = $k.'="'.strtr($v, ["\\"=>"\\\\", '"'=>'\\"', '$'=>'\\$', "\n"=>'\\n', "\r"=>'\\r']).'"';
        $env = preg_match("/^$k=.*$/m", $env) ? preg_replace_callback("/^$k=.*$/m", function () use ($line) { return $line; }, $env) : $env."\n$line";
    };
    $set('APP_ENV', 'production'); $set('APP_DEBUG', 'false'); $set('APP_URL', $url);
    // Preserve the literal existing line, including its quoting.
    if (preg_match('/^APP_KEY=(.+)$/m', $existing, $key)) {
        $env = preg_replace_callback('/^APP_KEY=.*$/m', function () use ($key) { return $key[0]; }, $env);
    } else { $set('APP_KEY', 'base64:'.base64_encode(random_bytes(32))); }
    if ($setupKey !== '') { $set('PHAROS_SETUP_KEY', $setupKey); }
    if ($db === 'mysql') {
        $set('DB_CONNECTION', 'mysql'); $set('DB_HOST', (string)($in['db_host'] ?? '127.0.0.1')); $set('DB_PORT', '3306');
        $set('DB_DATABASE', (string)($in['db_name'] ?? '')); $set('DB_USERNAME', (string)($in['db_user'] ?? '')); $set('DB_PASSWORD', (string)($in['db_pass'] ?? ''));
    } else {
        $set('DB_CONNECTION', 'sqlite');
        $env = preg_replace('/^DB_DATABASE=.*$/m', '', $env);
        if (!is_file("$appDir/database/database.sqlite")) { touch("$appDir/database/database.sqlite"); }
    }
    $tmp = "$appDir/.env.".bin2hex(random_bytes(6));
    if (file_put_contents($tmp, $env) === false) { throw new RuntimeException('Cannot write configuration.'); }
    chmod($tmp, 0600);
    if (!rename($tmp, "$appDir/.env")) { throw new RuntimeException('Cannot publish configuration.'); }
}

function pharos_setup_key(string $path): string
{
    $file = @fopen($path, 'c+');
    if (!$file || !flock($file, LOCK_EX)) { throw new RuntimeException('The private installation folder is not writable. Ask your host for a writable folder outside the document root.'); }
    try {
        @chmod($path, 0600);
        $key = trim(stream_get_contents($file));
        if ($key === '') { $key = bin2hex(random_bytes(32)); fwrite($file, $key); fflush($file); }
        return $key;
    } finally { flock($file, LOCK_UN); fclose($file); }
}

function pharos_shell_arg(string $value): string
{
    if (preg_match('/[\r\n%]/', $value)) { throw new RuntimeException('Cron paths cannot contain newlines or percent signs.'); }
    return "'".str_replace("'", "'\\''", $value)."'";
}

/** Runs an artisan command inside this request, without a shell. */
function pharos_artisan(string $appDir, string $command, array $args = []): void
{
    require_once "$appDir/vendor/autoload.php";
    $app = require "$appDir/bootstrap/app.php";
    $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
    $out = new Symfony\Component\Console\Output\BufferedOutput;
    $code = $kernel->call($command, $args, $out);
    if ($code !== 0) { throw new RuntimeException("artisan $command failed: ".trim($out->fetch())); }
}

function pharos_link_storage(string $appDir, string $webRoot, array &$log): void
{
    $target = "$appDir/storage/app/public"; $link = "$webRoot/storage";
    if (is_link($link) || is_dir($link)) { $log[] = 'storage link already present'; return; }
    if (function_exists('symlink') && @symlink($target, $link)) { $log[] = 'storage link created (uploads will show)'; return; }
    $log[] = 'storage symlink unavailable — current Pharos releases serve public uploads through the application';
}

function pharos_copy_tree(string $from, string $to): void
{
    if (!is_dir($to) && !mkdir($to, 0755, true)) { throw new RuntimeException("Cannot create $to"); }
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($from, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $item) {
        $dest = $to.'/'.substr($item->getPathname(), strlen($from) + 1);
        $item->isDir() ? @mkdir($dest, 0755, true) : copy($item->getPathname(), $dest);
    }
}

function pharos_rmdir(string $dir): void
{
    if (! is_dir($dir)) { return; }
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) {
        $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
    }
    @rmdir($dir);
}

/** Only this endpoint is used; panel credentials are neither persisted nor redirected. */
function pharos_da_request(string $panel, string $user, string $key, array $fields, string $panelType = 'directadmin'): array
{
    $parts = parse_url($panel);
    if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || !in_array($parts['path'] ?? '', ['', '/'], true)) {
        throw new RuntimeException('Use your HTTPS panel origin, for example https://panel.example.com:2222 or :2083.');
    }
    if (!preg_match('/^[a-zA-Z0-9_-]+$/', $user) || $key === '' || preg_match('/[\r\n]/', $key)) { throw new RuntimeException('Enter a panel username and Login Key or API token.'); }
    $host = $parts['host'];
    $addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
    if (!$addresses) { throw new RuntimeException('The panel hostname could not be resolved.'); }
    foreach ($addresses as $ip) {
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            throw new RuntimeException('Use a publicly reachable panel HTTPS address.');
        }
    }
    $port = $parts['port'] ?? 443;
    $creating = (bool)$fields;
    $endpoint = '/CMD_API_CRON_JOBS';
    if ($panelType === 'cpanel') {
        $endpoint = '/json-api/cpanel';
        $fields = ['cpanel_jsonapi_user'=>$user, 'cpanel_jsonapi_apiversion'=>2, 'cpanel_jsonapi_module'=>'Cron', 'cpanel_jsonapi_func'=>$creating ? 'add_line' : 'listcron'] + ($creating ? ['minute'=>'*', 'hour'=>'*', 'day'=>'*', 'month'=>'*', 'weekday'=>'*', 'command'=>$fields['command']] : []);
    }
    $ch = curl_init(rtrim($panel, '/').$endpoint);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_FOLLOWLOCATION=>false, CURLOPT_TIMEOUT=>15, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>true, CURLOPT_SSL_VERIFYHOST=>2, CURLOPT_USERPWD=>$user.':'.$key, CURLOPT_RESOLVE=>[$host.':'.$port.':'.$addresses[0]]]);
    if ($panelType === 'cpanel') { curl_setopt($ch, CURLOPT_USERPWD, null); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: cpanel '.$user.':'.$key]); }
    if ($fields) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields)); }
    $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch);
    if (!is_string($raw) || $status !== 200) { throw new RuntimeException('The hosting panel did not accept the request. Check the panel address, certificate and token permissions.'); }
    if ($panelType === 'cpanel') { return pharos_cpanel_result($raw, $creating); }
    parse_str($raw, $result);
    if ((isset($result['error']) && (string)$result['error'] !== '0') || strpos($raw, '<html') !== false || strpos($raw, '<!DOCTYPE') !== false) {
        throw new RuntimeException('DirectAdmin refused cron access. Existing jobs were not changed by Pharos.');
    }
    return $result;
}

/** Convert the documented API2 Cron response, including its final count-only row. */
function pharos_cpanel_result(string $raw, bool $creating): array
{
    $result = json_decode($raw, true)['cpanelresult'] ?? null;
    if (!is_array($result) || (int)($result['event']['result'] ?? 0) !== 1 || !is_array($result['data'] ?? null)) {
        throw new RuntimeException('cPanel cron access failed; use manual setup.');
    }
    if ($creating) {
        if ((int)($result['data'][0]['status'] ?? 0) !== 1) { throw new RuntimeException('cPanel did not save the cron task.'); }
        return ['error'=>'0'];
    }
    $jobs = [];
    foreach ($result['data'] as $row) {
        if (is_array($row) && array_keys($row) === ['count']) { continue; }
        $parts = [];
        foreach (['minute','hour','day','month','weekday','command'] as $field) {
            if (!isset($row[$field]) || !is_scalar($row[$field])) { throw new RuntimeException('Unknown cPanel cron response; use manual setup.'); }
            $parts[] = (string)$row[$field];
        }
        $jobs[] = implode(' ', $parts);
    }
    return $jobs;
}

/** Read before writing, never delete other tasks, and verify the saved task. */
function pharos_da_cron(string $panel, string $user, string $key, string $appDir, string $php, ?callable $request = null): string
{
    if ($php === 'php' || pharos_php_version_of($php) === '') { throw new RuntimeException('Choose a versioned absolute CLI PHP path before adding cron automatically.'); }
    $command = 'cd '.pharos_shell_arg($appDir).' && '.pharos_shell_arg($php).' artisan schedule:run >> /dev/null 2>&1';
    $request = $request ?: 'pharos_da_request';
    $contains = function (array $jobs) use ($command, $appDir): bool {
        foreach ($jobs as $id => $line) {
            if (!ctype_digit((string)$id)) { throw new RuntimeException('Unknown panel cron response; use manual setup.'); }
            if (!is_string($line)) { throw new RuntimeException('Unknown panel cron response; use manual setup.'); }
            $parts = preg_split('/\s+/', trim($line), 6);
            if (count($parts) !== 6) { throw new RuntimeException('Unknown panel cron response; use manual setup.'); }
            if ($parts[5] === $command && array_slice($parts, 0, 5) === ['*','*','*','*','*']) { return true; }
            if (strpos($parts[5], $appDir) !== false && strpos($parts[5], 'artisan schedule:run') !== false) { throw new RuntimeException('A cron task for this installation already exists with different settings. Review it in your panel; Pharos will not add a duplicate.'); }
        }
        return false;
    };
    $jobs = $request($panel, $user, $key, []);
    if ($contains($jobs)) { return 'The Pharos cron task already exists. Waiting for a successful scheduler run.'; }
    $request($panel, $user, $key, ['action'=>'create', 'minute'=>'*', 'hour'=>'*', 'dayofmonth'=>'*', 'month'=>'*', 'dayofweek'=>'*', 'command'=>$command]);
    if (!$contains($request($panel, $user, $key, []))) { throw new RuntimeException('The saved task could not be verified. Check your panel before retrying.'); }
    return 'Cron task saved and verified. Monitoring is confirmed by the first successful scheduler run.';
}

function pharos_page(string $title, string $body, int $step, array $errors = [], array $log = []): void
{
    $steps = ['Check the server', 'Download release', 'Configure', 'Cron', 'Done'];
    $nav = '';
    foreach ($steps as $i => $label) {
        $n = $i + 1;
        $cls = $n < $step ? 'done' : ($n === $step ? 'now' : '');
        $nav .= '<li class="'.$cls.'"><b>'.($n < $step ? '✓' : $n).'</b>'.$label.'</li>';
    }
    $err = $errors ? '<div class="err">'.implode('<br>', array_map('h', $errors)).'</div>' : '';
    $lg = $log ? '<div class="log">'.implode('', array_map(fn ($l) => '<span>✓ '.h($l).'</span>', $log)).'</div>' : '';
    if (isset($_SESSION['pharos_csrf'])) {
        $body = preg_replace_callback('/(<form\b[^>]*>)/i', function ($m) { return $m[1].'<input type="hidden" name="csrf" value="'.h($_SESSION['pharos_csrf']).'">'; }, $body);
    }
    header('Cache-Control: no-store');
    header('Referrer-Policy: no-referrer');
    header('X-Frame-Options: DENY');
    echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'.h($title).' · Pharos installer</title>
<style>
:root{--bg:#fbfcfe;--tint:#f2f6fb;--card:#fff;--line:#e3e9f1;--line2:#cfd9e6;--ink:#0e1726;--ink2:#475467;--ink3:#667085;--brand:#0079d2;--green:#12b76a;--green-ink:#027a48;--green-soft:#e6f7ef;--red:#b42318;--red-soft:#fee4e2;--amber:#b54708;--amber-soft:#fef0e2;--navy:#0a1729}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:900px;margin:40px auto;padding:0 20px}.box{background:var(--card);border:1px solid var(--line2);border-radius:14px;overflow:hidden;display:grid;grid-template-columns:230px 1fr;box-shadow:0 20px 40px -30px #0a172966}
.side{background:var(--tint);border-right:1px solid var(--line);padding:22px 18px}.brand{font-weight:800;letter-spacing:-.02em;margin-bottom:22px;display:flex;align-items:center;gap:8px}.brand i{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px var(--green-soft)}
ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}li{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:8px;font-size:13.5px;color:var(--ink3)}li b{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--line2);font:10px ui-monospace,monospace;display:inline-flex;align-items:center;justify-content:center;flex:none}
li.done{color:var(--ink2)}li.done b{background:var(--green);border-color:var(--green);color:#fff}li.now{background:var(--card);color:var(--ink);font-weight:600;box-shadow:0 1px 2px #10182814}li.now b{border-color:var(--brand);color:var(--brand)}
.main{padding:26px 30px}h1{font-size:20px;font-weight:800;letter-spacing:-.03em;margin:0 0 4px}.sub{color:var(--ink3);font-size:13.5px;margin:0 0 18px;display:block}
.checks{display:flex;flex-direction:column;gap:6px;margin:0 0 18px}.chk{display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;font-size:13.5px}.chk .k{font:12px ui-monospace,monospace;color:var(--ink2);min-width:180px}.chk .v{color:var(--ink3);flex:1}
.st{font:10px ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase;padding:3px 8px;border-radius:999px}.st.ok{background:var(--green-soft);color:var(--green-ink)}.st.bad{background:var(--red-soft);color:var(--red)}.st.warn{background:var(--amber-soft);color:var(--amber)}
.warnline{color:var(--amber);background:var(--amber-soft);border-radius:8px;padding:8px 12px;margin:0 0 14px}
.btn{display:inline-block;background:var(--brand);color:#fff;font-weight:600;font-size:13.5px;padding:10px 18px;border-radius:10px;border:0;cursor:pointer;text-decoration:none}.btn.ghost{background:transparent;color:var(--ink2);border:1px solid var(--line2)}.btn[disabled]{opacity:.45;cursor:not-allowed}
.err{background:var(--red-soft);color:var(--red);border-radius:10px;padding:10px 14px;margin:0 0 14px;font-size:13.5px}.log{font:12px/1.9 ui-monospace,monospace;color:var(--ink2);display:flex;flex-direction:column;margin:0 0 16px}
.form{display:flex;flex-direction:column;gap:12px;max-width:440px}label{display:flex;flex-direction:column;gap:6px;font-size:12.5px;font-weight:600;color:var(--ink2)}input,select{border:1px solid var(--line2);border-radius:10px;padding:9px 12px;font:14px system-ui;color:var(--ink);background:var(--card)}.mysql{display:none;gap:10px;grid-template-columns:1fr 1fr}
.cron{background:var(--navy);color:#e6edf6;font:12.5px ui-monospace,monospace;border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:12px;margin:8px 0 12px;flex-wrap:wrap}.cron .star{color:#2ea3ff;letter-spacing:.12em}.cron .cmd{flex:1;word-break:break-all}.cp{margin-left:auto;font-size:11px;color:#7e9ab5;background:transparent;border:1px solid #1b3350;border-radius:6px;padding:3px 8px;cursor:pointer}
.panels{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:14px}.panels div{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12.5px;color:var(--ink2)}.panels div b{display:block;color:var(--ink);font-size:13px;margin-bottom:2px}
code{font:.9em ui-monospace,monospace;background:var(--tint);border:1px solid var(--line);border-radius:6px;padding:.08em .4em}
@media(max-width:700px){.box{grid-template-columns:1fr}.side{border-right:0;border-bottom:1px solid var(--line)}.panels{grid-template-columns:1fr}.mysql{grid-template-columns:1fr}}
</style></head><body><div class="wrap"><div class="box"><aside class="side"><div class="brand"><i></i>Pharos installer</div><ol>'.$nav.'</ol></aside><main class="main"><h1>'.h($title).'</h1>'.$err.$lg.$body.'</main></div></div></body></html>';
}
