<?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.
 *
 * It never asks for your control-panel password and writes nothing before the
 * signature and checksum are right. Read it: it is plain PHP.
 */
declare(strict_types=1);

const PHAROS_RELEASES = 'https://pharos.solutionmax.net/releases';
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.0';

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 = [];
$log = [];

// ---------- guard: never run over a *finished* install (a half-done one may resume) ----------
if (is_file("$appDir/.pharos-done") && $action !== 'finish' && $step < 4) {
    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') {
        $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') {
        $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';
        pharos_write_env($appDir, $url, $db, $_POST);
        $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);
        $step = 4;
    } elseif ($action === 'finish') {
        @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 = 3;
    $log[] = 'Found a release already unpacked in '.$appDir.' — continuing where it stopped. Writing the settings again is safe.';
}

// ---------- screens ----------
if ($step === 1 && version_compare(PHP_VERSION, PHAROS_MIN_PHP, '<')) {
    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>
      <p>On cPanel / CloudLinux: <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>. Then reload this page.</p>
      <a class="btn" href="?step=1">Check again</a>', 1);
    exit;
}
if ($step === 1) {
    $checks = pharos_checks($appDir, $webRoot);
    $allOk = ! in_array(false, array_column($checks, 'ok'), true);
    $rows = '';
    foreach ($checks as $c) {
        $rows .= '<div class="chk"><span class="k">'.h($c['name']).'</span><span class="v">'.h($c['detail']).'</span><span class="st '.($c['ok'] ? 'ok' : 'bad').'">'.($c['ok'] ? 'ok' : 'fix').'</span></div>';
    }
    pharos_page('Can this server run Pharos?', '<p class="sub">Nothing is written yet. Fix what is red, then press Continue.</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) {
    // cron runs outside the web PHP selector: prefer a binary pinned to this PHP version
    $v = PHP_MAJOR_VERSION.PHP_MINOR_VERSION;
    $php = 'php';
    foreach (["/opt/alt/php$v/usr/bin/php", "/usr/local/php$v/bin/php", "/usr/local/bin/ea-php$v", "/opt/cpanel/ea-php$v/root/usr/bin/php", "/opt/plesk/php/".PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION."/bin/php", "/usr/bin/php".PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION] as $c) {
        if (is_executable($c)) { $php = $c; break; }
    }
    if ($php === 'php' && PHP_BINARY && ! pharos_has(PHP_BINARY, 'php-fpm') && ! pharos_has(PHP_BINARY, 'lsphp')) { $php = PHP_BINARY; }
    $cron = "* * * * * cd $appDir && $php artisan schedule:run >> /dev/null 2>&1";
    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>
      <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>
      <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.</div>
        <div><b>Plesk</b>Tools &amp; Settings → Scheduled Tasks → Run a command.</div>
      </div>
      <form method="post"><input type="hidden" name="action" value="finish">
        <button class="btn" type="submit">I added it — 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'] ?? (function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['dir'] ?? null) : null);
    $candidates = [];
    if ($home && is_dir($home) && is_writable($home)) { $candidates[] = rtrim($home, '/').'/pharos-app'; }
    $candidates[] = dirname($webRoot).'/pharos-app';
    foreach ($candidates as $c) {
        if (is_dir($c) || is_writable(dirname($c))) { return $c; }
    }
    return $candidates[0];
}

function pharos_checks(string $appDir, string $webRoot): array
{
    $c = [];
    $c[] = ['name' => 'PHP version', 'ok' => version_compare(PHP_VERSION, PHAROS_MIN_PHP, '>='), 'detail' => PHP_VERSION.' (need '.PHAROS_MIN_PHP.' or later)'];
    foreach ([['pdo_sqlite', 'SQLite'], ['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 — enable $label under Select PHP Version (cPanel) or PHP Settings"];
    }
    $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' => 'pharos.solutionmax.net 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';
    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"); }
    }
    $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)) { pharos_rmdir($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): void
{
    $env = file_get_contents("$appDir/.env.example");
    $set = function (string $k, string $v) use (&$env) {
        $line = "$k=".(preg_match('/[\s#"]/', $v) ? '"'.addslashes($v).'"' : $v);
        $env = preg_match("/^$k=.*$/m", $env) ? preg_replace("/^$k=.*$/m", $line, $env) : $env."\n$line";
    };
    $set('APP_ENV', 'production'); $set('APP_DEBUG', 'false'); $set('APP_URL', $url);
    $set('APP_KEY', 'base64:'.base64_encode(random_bytes(32)));
    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);
        touch("$appDir/database/database.sqlite");
    }
    file_put_contents("$appDir/.env", $env);
    @chmod("$appDir/.env", 0600);
}

/** 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 (@symlink($target, $link)) { $log[] = 'storage link created (uploads will show)'; return; }
    $log[] = 'could not create the storage symlink — logo uploads will not show until your host allows symlinks';
}

function pharos_copy_tree(string $from, string $to): void
{
    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);
}

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>' : '';
    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;--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)}
.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>';
}
