<!DOCTYPE html>
<?php
/* Owner-only Update Engine console.
   Vars: $baseUrl, $assetsUrl, $user, $host (forDisplay), $hasKey, $channel,
         $releases, $codeVersion, $defaultPorts */
if (!isset($baseUrl)) $baseUrl = '';
if (!isset($assetsUrl)) $assetsUrl = rtrim($baseUrl, '/') . '/assets';
$b = rtrim($baseUrl, '/');
$token = \PpPhp\Core\Security::CSRF()->Token();
$h = static fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES);
$parts = array_map('intval', explode('.', (string) $codeVersion . '.0.0'));
$nextVer = $parts[0] . '.' . $parts[1] . '.' . ($parts[2] + 1);
$human = static function (int $bytes): string {
    if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
    if ($bytes >= 1024) return round($bytes / 1024) . ' KB';
    return $bytes . ' B';
};
$latest = $releases[0] ?? null;
?>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> · App Bundle</title>
    <?php include constant('BASE_PATH') . '/core/views/favicon.php'; ?>
    <link href="<?= $assetsUrl ?>/bootstrap/css/bootstrap.min.css" rel="stylesheet">
    <link href="<?= $assetsUrl ?>/icons/bootstrap-icons.css" rel="stylesheet">
    <script src="<?= $assetsUrl ?>/bootstrap/js/bootstrap.bundle.min.js" defer></script>
    <?php include constant('BASE_PATH') . '/core/views/theme-head.php'; ?>
    <link href="<?= $assetsUrl ?>/sweetalert2/sweetalert2.min.css" rel="stylesheet">
    <script src="<?= $assetsUrl ?>/sweetalert2/sweetalert2.min.js"></script>
    <style>
        :root { --ue-accent: var(--pp-accent, #4f46e5);
            /* Semantic status colours — mid-tones for light; lightened for dark so
               they stay legible on a dark surface (there are no --pp-success/etc.). */
            --ue-ok: #16a34a; --ue-warn: #b45309; --ue-fail: #b91c1c; --ue-sky: #0284c7;
            --ue-ok-solid: #16a34a; --ue-warn-solid: #d97706; --ue-fail-solid: #dc2626; --ue-sky-solid: #0ea5e9; }
        :root:is([data-pp-theme="dark"]) {
            --ue-ok: #4ade80; --ue-warn: #fbbf24; --ue-fail: #f87171; --ue-sky: #38bdf8;
            --ue-ok-solid: #22c55e; --ue-warn-solid: #f59e0b; --ue-fail-solid: #ef4444; --ue-sky-solid: #38bdf8; }
        body { background: var(--pp-app-bg, #f5f6fa); color: var(--pp-text, #172033); padding-top: 56px; }
        .ue-top { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: .75rem;
                  padding: .6rem 1rem; background: var(--pp-surface, #fff); border-bottom: 1px solid var(--pp-border, #e6e9f0); }
        .ue-top .ue-brand { display: flex; align-items: center; gap: .5rem; font-weight: 800; }
        .ue-top .ue-brand .ue-logo { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center;
                  background: linear-gradient(135deg, var(--ue-accent), color-mix(in srgb, var(--ue-accent) 55%, #8b5cf6)); color: #fff; }
        .ue-pill { font-size: .68rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase;
                  padding: .16rem .5rem; border-radius: 999px; background: color-mix(in srgb, var(--ue-accent) 14%, transparent); color: var(--ue-accent); }

        .ue-shell { display: flex; gap: 1.25rem; max-width: 1180px; margin: 1.5rem auto; padding: 0 1rem; align-items: flex-start; }
        /* ── sidebar nav ── */
        .ue-nav { flex: 0 0 246px; position: sticky; top: 74px; display: flex; flex-direction: column; gap: .25rem; }
        .ue-nav-title { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
                  color: var(--pp-muted, #94a3b8); padding: .2rem .7rem .35rem; }
        .ue-nav-item { display: flex; align-items: center; gap: .7rem; width: 100%; text-align: left; cursor: pointer;
                  border: 1px solid transparent; background: transparent; color: var(--pp-text, #334155);
                  font-size: .92rem; font-weight: 600; padding: .62rem .8rem; border-radius: 11px; transition: background .14s, color .14s, border-color .14s; }
        .ue-nav-item i { font-size: 1.05rem; width: 20px; text-align: center; opacity: .85; }
        .ue-nav-item:hover { background: color-mix(in srgb, var(--ue-accent) 7%, transparent); color: var(--ue-accent); }
        .ue-nav-item.active { background: var(--pp-surface, #fff); color: var(--ue-accent);
                  border-color: var(--pp-border, #e6e9f0); box-shadow: 0 6px 18px color-mix(in srgb, var(--ue-accent) 12%, transparent); }
        .ue-nav-item .ue-dot { margin-left: auto; width: 8px; height: 8px; border-radius: 50%; }
        .ue-dot.ok { background: var(--ue-ok-solid); } .ue-dot.warn { background: var(--ue-warn-solid); }
        .ue-nav-foot { margin-top: .6rem; padding: .2rem .7rem; }

        /* ── canvas ── */
        .ue-canvas { flex: 1 1 auto; min-width: 0; }
        .ue-sec { display: none; animation: uein .18s ease; }
        .ue-sec.active { display: block; }
        @keyframes uein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
        .ue-h { display: flex; align-items: center; gap: .6rem; margin: 0 0 .25rem; }
        .ue-h h2 { font-size: 1.15rem; font-weight: 800; margin: 0; }
        .ue-h .bi { color: var(--ue-accent); }
        .ue-lead { color: var(--pp-text-muted, #64748b); font-size: .9rem; margin: 0 0 1.1rem; max-width: 62ch; }
        .ue-card { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0);
                  border-radius: 16px; padding: 1.25rem 1.35rem; margin-bottom: 1.1rem; box-shadow: 0 1px 2px rgba(15,23,42,.03); }
        .ue-card h3 { font-size: .96rem; font-weight: 800; margin: 0 0 .9rem; display: flex; align-items: center; gap: .5rem; }

        /* status tiles */
        .ue-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .9rem; margin-bottom: 1.1rem; }
        .ue-stat { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 14px; padding: 1rem 1.1rem; }
        .ue-stat .k { font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--pp-muted, #94a3b8); }
        .ue-stat .v { font-size: 1.15rem; font-weight: 800; margin-top: .25rem; display: flex; align-items: center; gap: .4rem; }
        .ue-stat .s { font-size: .78rem; color: var(--pp-text-muted, #64748b); margin-top: .15rem; }
        .ue-ok { color: var(--ue-ok); } .ue-warn { color: var(--ue-warn); } .ue-muted { color: var(--pp-muted, #94a3b8); }

        /* forms */
        .ue-lbl { font-size: .8rem; font-weight: 700; margin-bottom: .25rem; }
        .ue-hint { color: var(--pp-text-muted, #64748b); font-size: .77rem; }
        .form-control, .form-select { background: var(--pp-input-bg, #fff); color: var(--pp-text, #172033); border-color: var(--pp-border, #d7dce6); }
        .form-control:focus, .form-select:focus { border-color: var(--ue-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ue-accent) 18%, transparent); }
        /* Drive Bootstrap's OWN button variables so .btn always paints the accent
           (fighting .btn's background-color with a shorthand was fragile — an
           invalid var() silently dropped it and left a transparent white box). */
        .btn.btn-accent {
            --bs-btn-color: #fff;
            --bs-btn-bg: var(--pp-accent, #4f46e5);
            --bs-btn-border-color: var(--pp-accent, #4f46e5);
            --bs-btn-hover-color: #fff;
            --bs-btn-hover-bg: color-mix(in srgb, var(--pp-accent, #4f46e5) 88%, #000);
            --bs-btn-hover-border-color: color-mix(in srgb, var(--pp-accent, #4f46e5) 88%, #000);
            --bs-btn-active-color: #fff;
            --bs-btn-active-bg: color-mix(in srgb, var(--pp-accent, #4f46e5) 80%, #000);
            --bs-btn-active-border-color: color-mix(in srgb, var(--pp-accent, #4f46e5) 80%, #000);
            --bs-btn-disabled-color: #fff;
            --bs-btn-disabled-bg: color-mix(in srgb, var(--pp-accent, #4f46e5) 55%, var(--pp-surface, #fff));
            --bs-btn-disabled-border-color: color-mix(in srgb, var(--pp-accent, #4f46e5) 55%, var(--pp-surface, #fff));
            box-shadow: 0 2px 8px color-mix(in srgb, var(--pp-accent, #4f46e5) 30%, transparent);
        }

        .ue-stage { display: flex; align-items: center; gap: .5rem; font-size: .84rem; padding: .2rem 0; }
        .ue-stage .bi-check-circle-fill { color: var(--ue-ok); } .ue-stage .bi-x-circle-fill { color: var(--ue-fail); }
        .ue-stage small { color: var(--pp-text-muted, #64748b); margin-left: auto; }
        .ue-log { font: .78rem/1.55 ui-monospace, Menlo, monospace; background: color-mix(in srgb, var(--pp-text,#0f172a) 5%, transparent);
                  border-radius: 12px; padding: .75rem .95rem; margin-top: .85rem; max-height: 260px; overflow: auto; }
        .ue-code-url { font: .8rem/1.5 ui-monospace, Menlo, monospace; background: color-mix(in srgb, var(--pp-accent,#2563eb) 10%, transparent);
                  color: var(--pp-accent, #2563eb); border-radius: 8px; padding: .35rem .6rem; word-break: break-all; }
        .ue-rel { display: flex; align-items: center; gap: .6rem; font-size: .85rem; padding: .6rem 0; border-top: 1px solid var(--pp-border, #eef1f6); }
        .ue-rel:first-of-type { border-top: 0; }
        .ue-rel code { font-size: .8rem; }
        .ue-badge { font-size: .66rem; font-weight: 700; padding: .12rem .5rem; border-radius: 999px;
                  background: color-mix(in srgb, var(--ue-accent) 14%, transparent); color: var(--ue-accent); }
        .ue-empty { text-align: center; color: var(--pp-text-muted, #64748b); padding: 1.8rem 1rem; }
        .ue-empty i { font-size: 2rem; opacity: .4; display: block; margin-bottom: .5rem; }
        .ue-empty-note { display: flex; align-items: flex-start; gap: .6rem; font-size: .86rem; padding: .8rem 1rem;
            background: color-mix(in srgb, var(--pp-accent,#2563eb) 7%, transparent); border: 1px solid color-mix(in srgb, var(--pp-accent,#2563eb) 20%, transparent);
            border-radius: 10px; color: var(--pp-text, #1e293b); }
        .ue-empty-note i { color: var(--pp-accent, #2563eb); margin-top: .1rem; }
        .ue-empty-note a { color: var(--pp-accent, #2563eb); font-weight: 600; }
        .ue-details { border: 1px solid var(--pp-border, #e6e9f0); border-radius: 10px; padding: .2rem .8rem; background: var(--pp-surface-soft, #f8fafc); }
        .ue-details > summary { cursor: pointer; font-weight: 600; font-size: .88rem; padding: .55rem .1rem; list-style: none; display: flex; align-items: center; gap: .5rem; }
        .ue-details > summary::-webkit-details-marker { display: none; }
        .ue-details[open] > summary { border-bottom: 1px solid var(--pp-border, #e6e9f0); margin-bottom: .5rem; }
        .ue-steps { margin: .3rem 0 .6rem; padding-left: 1.25rem; font-size: .86rem; line-height: 1.65; color: var(--pp-text, #1e293b); }
        .ue-steps li { margin-bottom: .5rem; }
        .ue-steps code, .ue-details code { font-size: .78rem; }

        /* ── Collapsible host panels ── */
        .ue-host { border: 1px solid var(--pp-border, #e6e9f0); border-radius: 14px; background: var(--pp-surface, #fff);
            margin-bottom: 1rem; overflow: hidden; transition: box-shadow .18s ease, border-color .18s ease; }
        .ue-host:hover { box-shadow: 0 4px 18px rgba(15,23,42,.06); }
        .ue-host.open { border-color: color-mix(in srgb, var(--ue-accent) 40%, var(--pp-border, #e6e9f0)); }
        /* Primary reads as the hero: accent left rail + tinted header. */
        .ue-host.primary { border-color: color-mix(in srgb, var(--ue-accent) 32%, var(--pp-border, #e6e9f0)); }
        .ue-host.primary .ue-host-head { background: linear-gradient(90deg, color-mix(in srgb, var(--ue-accent) 12%, transparent), transparent 70%);
            border-left: 4px solid var(--ue-accent); }
        .ue-host-head { display: flex; align-items: center; gap: .7rem; padding: .95rem 1.1rem; cursor: pointer; user-select: none;
            border-left: 4px solid transparent; }
        .ue-host-head:hover { background: color-mix(in srgb, var(--ue-accent) 5%, transparent); }
        .ue-host-ico { width: 2.1rem; height: 2.1rem; flex: none; display: grid; place-items: center; border-radius: 10px; font-size: 1.05rem;
            background: color-mix(in srgb, var(--ue-accent) 12%, transparent); color: var(--ue-accent); }
        .ue-host.primary .ue-host-ico { background: var(--ue-accent); color: #fff; }
        .ue-host-headmain { min-width: 0; flex: 1; }
        .ue-host-title { display: flex; align-items: center; gap: .5rem; font-weight: 700; font-size: .98rem; color: var(--pp-text, #0f172a); }
        .ue-host-tag { font-size: .6rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: .1rem .45rem;
            border-radius: 999px; background: color-mix(in srgb, var(--pp-text-muted, #64748b) 16%, transparent); color: var(--pp-text-muted, #475569); }
        .ue-host.primary .ue-host-tag { background: var(--ue-accent); color: #fff; }
        .ue-host-summary { font-size: .8rem; color: var(--pp-text-muted, #64748b); margin-top: .12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .ue-host-summary code { font-size: .76rem; }
        .ue-chev { flex: none; color: var(--pp-text-muted, #94a3b8); transition: transform .2s ease; font-size: 1.1rem; }
        .ue-host.open .ue-chev { transform: rotate(180deg); }
        .ue-host-body { display: none; padding: 0 1.1rem 1.1rem; border-top: 1px solid var(--pp-border, #eef1f6); }
        .ue-host.open .ue-host-body { display: block; animation: ueFold .22s ease; }
        @keyframes ueFold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
        .ue-host-body > form { padding-top: 1rem; }
        /* Locked overlay affordance on a read-only form. */
        .ue-hostform.locked { position: relative; opacity: .92; }
        .ue-hostform.locked::after { content: ""; position: absolute; inset: -.4rem; border-radius: 12px;
            background: repeating-linear-gradient(45deg, transparent, transparent 10px, color-mix(in srgb, var(--pp-text-muted,#94a3b8) 5%, transparent) 10px, color-mix(in srgb, var(--pp-text-muted,#94a3b8) 5%, transparent) 20px);
            pointer-events: none; }
        .ue-locknote { display: inline-flex; align-items: center; gap: .35rem; font-size: .78rem; color: var(--pp-text-muted, #64748b); }
        .ue-locknote .bi { color: var(--pp-text-muted, #94a3b8); }
        .ue-lockbar { display: flex; align-items: center; gap: .6rem; padding: .55rem .8rem; margin-bottom: .3rem;
            border: 1px dashed var(--pp-border, #cbd5e1); border-radius: 10px; background: color-mix(in srgb, var(--pp-text-muted,#94a3b8) 6%, transparent); }
        .ue-lockbar .btn { margin-left: auto; }

        /* ── Publish target chips ── */
        .ue-targets { display: flex; flex-wrap: wrap; gap: .6rem; }
        .ue-target { display: flex; align-items: center; gap: .55rem; padding: .55rem .7rem; min-width: 190px; flex: 1 1 190px;
            border: 1.5px solid var(--pp-border, #e6e9f0); border-radius: 12px; cursor: pointer; transition: border-color .15s, background .15s; }
        .ue-target:has(input:checked) { border-color: var(--ue-accent); background: color-mix(in srgb, var(--ue-accent) 7%, transparent); }
        .ue-target.disabled { opacity: .5; cursor: not-allowed; }
        .ue-target input { accent-color: var(--ue-accent); }
        .ue-target-ic { width: 1.9rem; height: 1.9rem; flex: none; display: grid; place-items: center; border-radius: 8px;
            background: color-mix(in srgb, var(--ue-accent) 12%, transparent); color: var(--ue-accent); }
        .ue-target span strong { display: block; font-size: .86rem; line-height: 1.1; }
        .ue-target span small { color: var(--pp-text-muted, #64748b); font-size: .72rem; }
        .ue-target-tag { margin-left: auto; font-size: .58rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
            padding: .1rem .4rem; border-radius: 999px; background: color-mix(in srgb, var(--pp-text-muted,#64748b) 15%, transparent); color: var(--pp-text-muted,#475569); }

        /* ── Real-time publish progress ── */
        .ue-pubprog { margin-top: 1rem; border: 1px solid var(--pp-border, #e6e9f0); border-radius: 14px; overflow: hidden; }
        .ue-pubprog-head { display: flex; align-items: center; gap: .6rem; padding: .8rem 1rem; font-size: .95rem;
            background: var(--pp-surface-soft, #f8fafc); border-bottom: 1px solid var(--pp-border, #eef1f6); }
        .ue-pubprog-log { max-height: 340px; overflow: auto; padding: .5rem .3rem; }
        .ue-prow { display: flex; align-items: flex-start; gap: .55rem; padding: .32rem .8rem; font-size: .85rem; line-height: 1.4; }
        .ue-prow .ue-pico { flex: none; width: 1.15rem; text-align: center; margin-top: .06rem; }
        .ue-prow.run   .ue-pico { color: var(--ue-accent); }
        .ue-prow.ok    .ue-pico { color: var(--ue-ok); }
        .ue-prow.warn  .ue-pico { color: var(--ue-warn); }
        .ue-prow.fail  .ue-pico { color: var(--ue-fail); }
        .ue-prow.run   { color: var(--pp-text, #0f172a); }
        .ue-prow.warn  { color: var(--ue-warn); }
        .ue-prow.fail  { color: var(--ue-fail); font-weight: 600; }
        .edtag { display: inline-block; font-size: .6rem; font-weight: 800; text-transform: uppercase; letter-spacing: .03em; margin-right: .4rem;
            padding: .05rem .4rem; border-radius: 999px; background: color-mix(in srgb, var(--ue-accent) 14%, transparent); color: var(--ue-accent);
            vertical-align: middle; }
        .ue-pubprog-foot { padding: .7rem 1rem; border-top: 1px solid var(--pp-border, #eef1f6); font-size: .9rem; }
        .ue-spin { width: 1.05rem; height: 1.05rem; flex: none; border: 2.5px solid color-mix(in srgb, var(--ue-accent) 30%, transparent);
            border-top-color: var(--ue-accent); border-radius: 50%; animation: ueSpin .7s linear infinite; }
        @keyframes ueSpin { to { transform: rotate(360deg); } }

        /* ── Remote directory browser ── */
        .ue-browser { margin-top: .9rem; border: 1px solid var(--pp-border, #e6e9f0); border-radius: 12px; overflow: hidden; }
        .ue-browser-bar { display: flex; align-items: center; gap: .5rem; padding: .5rem .7rem;
            background: var(--pp-surface-soft, #f8fafc); border-bottom: 1px solid var(--pp-border, #e6e9f0); }
        .ue-browser-bar .bi { color: var(--ue-accent); }
        .ue-browser-path { font-size: .82rem; background: transparent; padding: 0; color: var(--pp-text); overflow: auto; white-space: nowrap; max-width: 44%; }
        .ue-browser-list { max-height: 260px; overflow: auto; }
        .ue-fsrow { display: flex; align-items: center; gap: .6rem; padding: .45rem .75rem; font-size: .85rem;
            border-bottom: 1px solid var(--pp-border, #eef1f6); }
        .ue-fsrow:last-child { border-bottom: 0; }
        .ue-fsrow .bi { color: var(--ue-accent); }
        .ue-fsrow.dir { cursor: pointer; }
        .ue-fsrow.dir:hover { background: color-mix(in srgb, var(--ue-accent) 8%, transparent); }
        .ue-fsrow .fsmeta { margin-left: auto; color: var(--pp-text-muted, #64748b); font-size: .74rem; }
        .ue-fsrow .fsdel { border: 0; background: transparent; color: var(--pp-text-muted, #94a3b8); cursor: pointer; padding: .1rem .3rem; border-radius: 6px; }
        .ue-fsrow .fsdel:hover { color: var(--ue-fail); background: color-mix(in srgb, var(--ue-fail-solid) 12%, transparent); }
        .ue-fsempty { padding: 1rem; text-align: center; color: var(--pp-text-muted, #64748b); font-size: .82rem; }

        /* ── Publish: segmented + switch ── */
        .ue-seg { display: inline-flex; gap: .25rem; padding: .2rem; border: 1px solid var(--pp-border, #e6e9f0); border-radius: 11px; background: var(--pp-surface-soft, #f8fafc); flex-wrap: wrap; }
        .ue-seg label { position: relative; display: inline-flex; align-items: center; gap: .35rem; margin: 0; padding: .34rem .7rem; border-radius: 8px; font-size: .84rem; font-weight: 650; cursor: pointer; color: var(--pp-text-muted, #64748b); }
        .ue-seg input { position: absolute; opacity: 0; width: 0; height: 0; }
        .ue-seg label:has(input:checked) { background: var(--pp-surface, #fff); color: var(--ue-accent); box-shadow: 0 1px 3px var(--pp-shadow-color, rgba(15,23,42,.08)); }
        .ue-switch { display: inline-flex; align-items: center; gap: .5rem; font-size: .86rem; font-weight: 600; cursor: pointer; }
        .ue-switch input { width: 17px; height: 17px; accent-color: var(--ue-accent); }

        /* ── Releases rows ── */
        .ue-relrow { padding: .7rem 0; border-top: 1px solid var(--pp-border, #eef1f6); }
        .ue-relrow:first-of-type { border-top: 0; }
        .ue-relhead { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
        .ue-ed { background: color-mix(in srgb, var(--pp-text, #64748b) 12%, transparent); color: var(--pp-text-muted, #64748b); text-transform: capitalize; }
        .ue-live { background: color-mix(in srgb, var(--ue-ok-solid) 16%, transparent); color: var(--ue-ok); }
        .ue-staged { background: color-mix(in srgb, var(--ue-warn-solid) 18%, transparent); color: var(--ue-warn); }
        .ue-deltatoggle { border: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface, #fff); color: var(--pp-text-muted, #64748b);
            font-size: .72rem; font-weight: 700; padding: .12rem .5rem; border-radius: 999px; cursor: pointer; }
        .ue-deltatoggle:hover { color: var(--ue-accent); border-color: var(--ue-accent); }
        .ue-relnote { font-size: .82rem; color: var(--pp-text-muted, #64748b); margin: .35rem 0 0; }
        .ue-deltalist { margin: .45rem 0 0; padding: .4rem .6rem; background: var(--pp-surface-soft, #f8fafc); border-radius: 9px; }
        .ue-deltarow { font-size: .78rem; color: var(--pp-text-muted, #64748b); padding: .1rem 0; }
        .ue-relfiles { margin: .5rem 0 0; padding: .5rem .7rem; background: var(--pp-surface-soft, #f8fafc); border: 1px solid var(--pp-border, #eef1f6); border-radius: 10px; }
        .ue-relfiles-h { font-size: .74rem; font-weight: 700; color: var(--pp-text-muted, #64748b); text-transform: uppercase; letter-spacing: .03em; margin-bottom: .35rem; display: flex; align-items: center; gap: .4rem; }
        .ue-relfiles-h .ue-hint { text-transform: none; font-weight: 400; letter-spacing: 0; }
        .ue-fileitem { display: flex; align-items: center; gap: .5rem; padding: .18rem 0; font-size: .82rem; }
        .ue-fileitem code { font-size: .76rem; word-break: break-all; }
        .ue-fileitem .bi-file-earmark-zip { color: var(--pp-text-muted, #94a3b8); flex: none; }
        .ue-fmeta { margin-left: auto; color: var(--pp-text-muted, #94a3b8); font-size: .72rem; flex: none; }
        .ue-hbadge { font-size: .58rem; font-weight: 800; letter-spacing: .04em; padding: .06rem .34rem; border-radius: 5px; flex: none; }
        .ue-hbadge.pri { background: color-mix(in srgb, var(--ue-accent) 18%, transparent); color: var(--ue-accent); }
        .ue-hbadge.sec { background: color-mix(in srgb, var(--ue-sky-solid) 20%, transparent); color: var(--ue-sky); }
        .ue-relact { display: flex; gap: .5rem; margin-top: .55rem; }

        /* responsive: sidebar → horizontal scroller above the canvas */
        @media (max-width: 900px) {
            .ue-shell { flex-direction: column; gap: .9rem; margin-top: 1rem; }
            .ue-nav { position: static; flex: none; width: 100%; flex-direction: row; overflow-x: auto;
                      gap: .4rem; padding-bottom: .3rem; -webkit-overflow-scrolling: touch; }
            .ue-nav-title, .ue-nav-foot { display: none; }
            .ue-nav-item { width: auto; flex: 0 0 auto; white-space: nowrap; }
            .ue-nav-item .ue-dot { display: none; }
        }
        /* Phone: the header packs 6+ controls in one nowrap row — let it wrap and
           drop the username label so nothing runs off the right edge. */
        @media (max-width: 767.98px) {
            html, body { overflow-x: hidden; }
            .ue-top { flex-wrap: wrap; gap: .4rem .45rem; padding-left: .6rem; padding-right: .6rem; }
            .ue-top .btn { font-size: .8rem; }
            .ue-top .ms-auto { margin-left: auto; }
            .ue-top .small.text-muted { display: none; }
        }
        /* ── Blocking progress overlay (build/upload/download) ── */
        .ue-overlay { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center;
                      background: color-mix(in srgb, var(--pp-app-bg, #0b1220) 74%, transparent); backdrop-filter: blur(2px); }
        .ue-overlay.on { display: flex; }
        .ue-ov-card { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0);
                      border-radius: 16px; padding: 1.7rem 2rem; max-width: 460px; width: 90%; text-align: center;
                      box-shadow: 0 24px 70px rgba(0,0,0,.4); }
        .ue-spin { width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 1rem;
                   border: 4px solid color-mix(in srgb, var(--ue-accent) 22%, transparent); border-top-color: var(--ue-accent);
                   animation: ue-spin .8s linear infinite; }
        @keyframes ue-spin { to { transform: rotate(360deg); } }
        .ue-ov-title { font-weight: 800; font-size: 1.06rem; margin-bottom: .3rem; color: var(--pp-text, #172033); }
        .ue-ov-msg { font-size: .86rem; color: var(--pp-text-muted, #64748b); min-height: 1.2em; }
        .ue-ov-sub { font-size: .74rem; color: var(--pp-text-muted, #64748b); margin-top: .8rem; opacity: .9; }
        .ue-locked { opacity: .55; pointer-events: none; }

        /* ── Locked publish modal (OTA): blocks the whole site while publishing ── */
        body.ue-noscroll { overflow: hidden; }
        .ue-pubmodal { position: fixed; inset: 0; z-index: 10000; display: none; align-items: center; justify-content: center; padding: 1.2rem;
            background: color-mix(in srgb, var(--pp-app-bg, #0b1220) 80%, transparent); backdrop-filter: blur(3px); animation: uePmFade .2s ease; }
        .ue-pubmodal.on { display: flex; }
        @keyframes uePmFade { from { opacity: 0; } to { opacity: 1; } }
        .ue-pubmodal-card { position: relative; background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0);
            border-radius: 18px; width: 660px; max-width: 100%; max-height: 88vh; display: flex; flex-direction: column; overflow: hidden;
            box-shadow: 0 30px 90px rgba(0,0,0,.45); animation: uePmPop .24s cubic-bezier(.4,0,.2,1); }
        @keyframes uePmPop { from { transform: translateY(14px) scale(.975); opacity: 0; } to { transform: none; opacity: 1; } }
        .ue-pubmodal-bar { height: 3px; flex: none; overflow: hidden; background: color-mix(in srgb, var(--ue-accent) 16%, transparent); }
        .ue-pubmodal-bar > span { display: block; height: 100%; width: 38%; border-radius: 99px;
            background: linear-gradient(90deg, transparent, var(--ue-accent), transparent); animation: uePmBar 1.15s ease-in-out infinite; }
        .ue-pubmodal-bar.done > span { animation: none; width: 100%; background: var(--ue-ok); }
        .ue-pubmodal-bar.fail > span { animation: none; width: 100%; background: var(--ue-fail); }
        @keyframes uePmBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(360%); } }
        .ue-pubmodal-head { display: flex; align-items: center; gap: .75rem; padding: 1rem 1.15rem; flex: none;
            border-bottom: 1px solid var(--pp-border, #eef1f6); }
        .ue-pubmodal-head .tt { display: flex; flex-direction: column; gap: .08rem; min-width: 0; }
        .ue-pubmodal-head strong { font-size: 1.02rem; font-weight: 800; line-height: 1.25; }
        .ue-pubspin { width: 26px; height: 26px; flex: none; border: 3px solid color-mix(in srgb, var(--ue-accent) 26%, transparent);
            border-top-color: var(--ue-accent); border-radius: 50%; animation: ue-spin .75s linear infinite; }
        .ue-pubmodal-badge { margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: .32rem; font-size: .68rem; font-weight: 800;
            text-transform: uppercase; letter-spacing: .04em; padding: .24rem .58rem; border-radius: 99px;
            background: color-mix(in srgb, var(--ue-warn-solid, #d97706) 16%, transparent); color: var(--ue-warn); }
        .ue-pubmodal-badge.ok { background: color-mix(in srgb, var(--ue-ok-solid) 16%, transparent); color: var(--ue-ok); }
        .ue-pubmodal-badge.fail { background: color-mix(in srgb, var(--ue-fail-solid) 16%, transparent); color: var(--ue-fail); }
        .ue-pubmodal .ue-pubprog-log { flex: 1 1 auto; max-height: none; min-height: 190px; padding: .6rem .35rem; }
        .ue-pubmodal .ue-pubprog-foot { flex: none; }
        .ue-pubmodal-note { padding: .7rem 1.15rem; flex: none; font-size: .78rem; color: var(--pp-text-muted, #64748b);
            background: var(--pp-surface-soft, #f8fafc); border-top: 1px solid var(--pp-border, #eef1f6); display: flex; align-items: center; gap: .45rem; }
        .ue-pubmodal-note .bi { color: var(--ue-warn); flex: none; }
    </style>
</head>
<body>
<div class="ue-overlay" id="ueOverlay" role="alertdialog" aria-live="assertive" aria-label="Working">
    <div class="ue-ov-card">
        <div class="ue-spin"></div>
        <div class="ue-ov-title" id="ueOvTitle">Working…</div>
        <div class="ue-ov-msg" id="ueOvMsg">Please wait.</div>
        <div class="ue-ov-sub"><i class="bi bi-exclamation-circle"></i> Keep this tab open — don’t navigate away or close it until this finishes.</div>
    </div>
</div>
<!-- Locked publish (OTA) modal — streams live progress and blocks the whole site until done -->
<div class="ue-pubmodal" id="pubModal" role="alertdialog" aria-modal="true" aria-live="assertive" aria-label="Publishing release">
    <div class="ue-pubmodal-card">
        <div class="ue-pubmodal-bar" id="pubModalBar"><span></span></div>
        <div class="ue-pubmodal-head">
            <span id="pubProgSpin" class="ue-pubspin"></span>
            <div class="tt">
                <strong id="pubProgTitle">Publishing…</strong>
                <span class="ue-hint" id="pubProgSub"></span>
            </div>
            <span class="ue-pubmodal-badge" id="pubModalBadge"><i class="bi bi-lock-fill"></i> Locked</span>
        </div>
        <div class="ue-pubprog-log" id="pubProgLog"></div>
        <div class="ue-pubprog-foot" id="pubProgFoot" style="display:none"></div>
        <div class="ue-pubmodal-note" id="pubModalNote"><i class="bi bi-exclamation-circle"></i>
            The site is locked while publishing. Keep this tab open — don’t click away, navigate, or close it until every step finishes.</div>
    </div>
</div>
<?php
    $navBrandLabel = 'Update host';
    $navBrandIcon  = 'cloud-arrow-up';
    $navBack = ['url' => $baseUrl . '/projects', 'label' => 'Projects', 'icon' => 'arrow-left'];
    $navExtra = '<span class="ue-pill">owner</span>'
        . '<a href="' . $b . '/licensing/server" class="btn btn-sm btn-outline-secondary"><i class="bi bi-key"></i> Licence server</a>'
        . '<a href="' . $b . '/help/updates" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary"><i class="bi bi-question-circle"></i> Guide</a>'
        . '<a href="' . $b . '/licensing/advisories" class="btn btn-sm btn-outline-secondary" data-tip="Push a released version to clients as a critical security patch, or send them a notice"><i class="bi bi-broadcast"></i> Push to clients</a>';
    include constant('BASE_PATH') . '/core/views/console-navbar.php';
?>

<div class="ue-shell">
    <!-- ── sidebar nav ── -->
    <aside class="ue-nav" role="tablist" aria-label="App Bundle sections">
        <div class="ue-nav-title">App Bundle</div>
        <button class="ue-nav-item active" data-sec="overview" role="tab" data-tip="At-a-glance status: signing key, update host, latest published release and channel"><i class="bi bi-grid-1x2"></i> Overview</button>
        <button class="ue-nav-item" data-sec="host" role="tab" data-tip="The FTP server that hosts your update files, and the public URL clients download from"><i class="bi bi-hdd-network"></i> Update host
            <span class="ue-dot <?= $host['configured'] ? 'ok' : 'warn' ?>" data-tip="<?= $host['configured'] ? 'Configured' : 'Not configured' ?>"></span></button>
        <button class="ue-nav-item" data-sec="publish" role="tab" data-tip="Build a release and push it straight to the update host over the air — licensed apps pick it up automatically"><i class="bi bi-cloud-arrow-up"></i> Publish (OTA)</button>
        <button class="ue-nav-item" data-sec="download" role="tab" data-tip="Build a full installer or a delta patch and download the file to your computer (no upload)"><i class="bi bi-download"></i> Download</button>
        <button class="ue-nav-item" data-sec="releases" role="tab" data-tip="Everything currently published, with per-release deltas — flip live/staged or delete"><i class="bi bi-clock-history"></i> Releases
            <?php if ($releases): ?><span class="ue-dot ok" data-tip="<?= count($releases) ?> published"></span><?php endif; ?></button>
        <div class="ue-nav-foot">
            <div class="ue-hint">Installed <code><?= $h($codeVersion) ?></code></div>
        </div>
    </aside>

    <!-- ── canvas ── -->
    <main class="ue-canvas">

        <?php if (!$hasKey): ?>
        <div class="ue-card" style="border-color:var(--ue-warn-solid)">
            <h3><i class="bi bi-exclamation-triangle text-warning"></i> No signing key yet</h3>
            <p class="ue-lead mb-0">The update feed is signed with the same Ed25519 key as the licence server.
                Create it on the <a href="<?= $b ?>/licensing/server">Licence server</a> page first — until then, published
                releases cannot be served to clients.</p>
        </div>
        <?php endif; ?>

        <!-- Overview -->
        <section class="ue-sec active" id="sec-overview" role="tabpanel">
            <div class="ue-h"><i class="bi bi-grid-1x2"></i><h2>Overview</h2></div>
            <p class="ue-lead">Publish updates over the air. Clients check a signed feed, then download only what changed.</p>
            <div class="ue-stats">
                <div class="ue-stat">
                    <div class="k">Signing key</div>
                    <div class="v"><?php if ($hasKey): ?><i class="bi bi-check-circle-fill ue-ok"></i> Ready<?php else: ?><i class="bi bi-x-circle-fill ue-warn"></i> Missing<?php endif; ?></div>
                    <div class="s">Signs the update feed</div>
                </div>
                <div class="ue-stat">
                    <div class="k">Update host</div>
                    <div class="v"><?php if ($host['configured']): ?><i class="bi bi-check-circle-fill ue-ok"></i> Configured<?php else: ?><i class="bi bi-dash-circle ue-muted"></i> Not set<?php endif; ?></div>
                    <div class="s"><?= $host['configured'] ? $h(strtoupper($host['protocol']) . ' · ' . $host['host']) : 'Where files are published' ?></div>
                </div>
                <div class="ue-stat">
                    <div class="k">Latest published</div>
                    <div class="v"><code><?= $latest ? $h($latest['version']) : '—' ?></code></div>
                    <div class="s">
                        <?php if ($latest): ?>
                            <?= $h($latest['channel']) . ' · ' . $human((int) $latest['full_size']) ?>
                            <?php if (version_compare($codeVersion, (string) $latest['version'], '>')): ?>
                                <br><span class="ue-warn">Installed <?= $h($codeVersion) ?> is newer — not published yet.
                                <button class="btn btn-sm btn-link p-0 align-baseline ue-go" data-go="publish">Publish it</button></span>
                            <?php endif; ?>
                        <?php else: ?>Nothing published yet<?php endif; ?>
                    </div>
                </div>
                <div class="ue-stat">
                    <div class="k">Channel</div>
                    <div class="v" style="text-transform:capitalize"><?= $h($channel) ?></div>
                    <div class="s">Active publish channel</div>
                </div>
            </div>
            <div class="ue-card">
                <h3><i class="bi bi-list-check"></i> Getting set up</h3>
                <ol class="mb-0" style="font-size:.9rem; line-height:1.9; padding-left:1.1rem;">
                    <li><?= $hasKey ? '<span class="ue-ok">✓</span> Signing key created' : 'Create the signing key on the Licence server' ?></li>
                    <li><?= $host['configured'] ? '<span class="ue-ok">✓</span> Update host configured' : 'Configure the update host — <button class="btn btn-sm btn-link p-0 align-baseline ue-go" data-go="host">open Update host</button>' ?></li>
                    <li>Publish a release — <button class="btn btn-sm btn-link p-0 align-baseline ue-go" data-go="publish">open Publish</button></li>
                </ol>
            </div>

            <div class="ue-card">
                <div class="d-flex align-items-center gap-2 flex-wrap">
                    <h3 class="mb-0"><i class="bi bi-arrow-repeat"></i> Automatic updates</h3>
                    <span class="ue-badge <?= $auto['enabled'] ? 'live' : '' ?>" id="autoBadge"><?= $auto['enabled'] ? 'ON' : 'OFF' ?></span>
                    <div class="form-check form-switch ms-auto m-0">
                        <input class="form-check-input" type="checkbox" role="switch" id="autoToggle" <?= $auto['enabled'] ? 'checked' : '' ?>>
                    </div>
                </div>
                <p class="ue-lead mt-2 mb-2">When on, <strong>this</strong> server keeps itself current: on a schedule it reads the
                    signed index and applies any newer release for its edition, with a restore point. Turn it on for your
                    <em>live</em> servers so they self-apply what you publish from dev — leave it off here on dev.</p>
                <div class="ue-hint mb-2">
                    Last run: <?= $auto['lastRun'] ? $h(date('Y-m-d H:i', (int) $auto['lastRun'])) . ' — ' . $h($auto['lastResult']) : 'never' ?>
                    · Installed <code><?= $h($auto['installed']) ?></code>
                    <button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="autoRunNow"><i class="bi bi-play-circle"></i> Run now</button>
                </div>
                <details class="ue-details">
                    <summary>How to schedule it</summary>
                    <div class="ue-lbl mt-2">Option A — shell cron (every 15 min)</div>
                    <code class="ue-code-url"><?= $h('*/15 * * * * ' . $auto['cliCommand']) ?></code>
                    <div class="ue-lbl mt-2">Option B — web cron (hosts without shell). Point a cron service at:</div>
                    <code class="ue-code-url"><?= $h($autoRunUrl) ?></code>
                    <div class="ue-hint mt-1">This URL carries a per-install secret — keep it private; anyone with it can trigger an update on this server.</div>
                </details>
                <div id="autoResult" class="ue-log mt-2" style="display:none"></div>
            </div>
        </section>

        <!-- Update host -->
        <section class="ue-sec" id="sec-host" role="tabpanel">
            <div class="ue-h"><i class="bi bi-hdd-network"></i><h2>Update host</h2></div>
            <p class="ue-lead">Where release files are uploaded, and the public URL clients download them from.
                Only <code>admin-ppradhan</code> can change this.</p>

            <!-- Update mirrors: the ordered list of read URLs the whole estate tries -->
            <div class="ue-host primary" id="mirrorPanel" style="margin-bottom:16px">
                <div class="ue-host-head">
                    <span class="ue-host-ico"><i class="bi bi-diagram-3-fill"></i></span>
                    <div class="ue-host-headmain">
                        <div class="ue-host-title">Update mirrors <span class="ue-host-tag">read-any</span></div>
                        <div class="ue-host-summary">Every install — servers <em>and</em> clients — reads the signed <code>index.json</code> from these in order. If one host is down, the next is used, so no single outage stops updates.</div>
                    </div>
                </div>
                <div style="padding:14px 16px">
                    <label class="form-label" for="updateDisplayName">Update service display name
                        <span class="ue-hint" tabindex="0" data-tip="A short, friendly name customers see in a failure message instead of your real host URLs (e.g. &quot;PPPHP Updates&quot;). It rides to clients inside the signed lease. Blank = clients show a neutral generic term. Your own server always keeps the full technical detail for debugging.">?</span>
                    </label>
                    <input class="form-control mb-3" id="updateDisplayName" maxlength="40"
                           placeholder="e.g. PPPHP Updates" value="<?= $h(\PpPhp\Core\Update\UpdateHostConfig::displayName()) ?>">

                    <label class="form-label" for="mirrorList">Your update hosts <span class="text-muted">(one https URL per line — add more as you stand up hosts)</span></label>
                    <textarea class="form-control" id="mirrorList" rows="4" spellcheck="false" placeholder="https://updates.example.com&#10;https://mirror2.example.net"><?= $h(implode("\n", $mirrors['owner'])) ?></textarea>
                    <div class="mt-2" style="font-size:12.5px">
                        <div class="text-muted mb-1">Always tried after yours (built into every bundle):</div>
                        <?php foreach (($mirrors['defaults'] ?? []) as $d): ?><code class="me-2"><?= $h($d) ?></code><?php endforeach; ?>
                    </div>
                    <div class="mt-2" style="font-size:12.5px">
                        <div class="text-muted mb-1">Effective order the estate will try:</div>
                        <span id="mirrorEffective">
                            <?php foreach (($mirrors['effective'] ?? []) as $i => $e): ?><span class="ue-badge"><?= (int) $i + 1 ?>. <?= $h($e) ?></span> <?php endforeach; ?>
                        </span>
                    </div>
                    <div class="mt-3">
                        <button type="button" class="btn btn-accent btn-sm" id="btnSaveMirrors"><i class="bi bi-save"></i> Save mirrors</button>
                    </div>
                </div>
            </div>

            <div class="ue-host primary" id="hostPanel" data-host="primary">
                <div class="ue-host-head" data-toggle="hostPanel">
                    <span class="ue-host-ico"><i class="bi bi-hdd-network-fill"></i></span>
                    <div class="ue-host-headmain">
                        <div class="ue-host-title">Primary host <span class="ue-host-tag">Primary</span>
                            <span class="ue-badge <?= $host['configured'] ? 'live' : '' ?>" id="hostStatus" data-tip="Whether the primary update host is configured">
                                <?php if ($host['configured']): ?><i class="bi bi-check-circle-fill"></i> Connected<?php else: ?><i class="bi bi-dash-circle"></i> Not set up<?php endif; ?>
                            </span>
                        </div>
                        <div class="ue-host-summary" id="hostSummary">
                            <?= $host['configured']
                                ? $h(strtoupper($host['protocol']) . ' · ' . $host['host']) . ' &rarr; <code>' . $h($host['publicUrl']) . '</code>'
                                : 'Not set up yet — clients have nowhere to download from. Click to configure.' ?>
                        </div>
                    </div>
                    <i class="bi bi-chevron-down ue-chev"></i>
                </div>
                <div class="ue-host-body">
                <p class="ue-lead mb-3"><i class="bi bi-star-fill" style="color:var(--ue-accent)"></i>
                    The <strong>main</strong> host every client checks first. Uploads land in the <em>Upload directory</em>;
                    clients download from the matching <em>Public URL</em>. Tip: the two must point at the <strong>same folder</strong> —
                    the #1 cause of “404 / update not found”.</p>
                <div class="ue-lockbar" id="lockbar">
                    <span class="ue-locknote"><i class="bi bi-lock-fill"></i> Locked to prevent accidental changes.</span>
                    <button type="button" class="btn btn-accent btn-sm" id="btnEditHost"><i class="bi bi-pencil"></i> Edit connection</button>
                </div>
                <form id="hostForm" class="ue-hostform row g-3">
                    <input type="hidden" name="slot" value="primary">
                    <div class="col-sm-4">
                        <div class="ue-lbl">Protocol</div>
                        <select class="form-select" name="protocol" id="protocol">
                            <?php foreach (['ftp' => 'FTP', 'ftps' => 'FTPS (FTP over TLS)', 'sftp' => 'SFTP (SSH)'] as $k => $lbl): ?>
                                <option value="<?= $k ?>" <?= $host['protocol'] === $k ? 'selected' : '' ?>><?= $lbl ?></option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Host</div>
                        <input class="form-control" name="host" id="host" placeholder="ftp.example.com" value="<?= $h($host['host']) ?>">
                    </div>
                    <div class="col-sm-2">
                        <div class="ue-lbl">Port</div>
                        <input class="form-control" name="port" id="port" type="number" min="0" max="65535"
                               placeholder="<?= (int) ($defaultPorts[$host['protocol']] ?? 21) ?>"
                               value="<?= $host['port'] > 0 ? (int) $host['port'] : '' ?>">
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Username</div>
                        <input class="form-control" name="user" autocomplete="off" value="<?= $h($host['user']) ?>">
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Password <?php if ($host['hasPass']): ?><span class="ue-hint">(saved — leave blank to keep)</span><?php endif; ?></div>
                        <input class="form-control" name="pass" type="password" autocomplete="new-password"
                               placeholder="<?= $host['hasPass'] ? '••••••••' : '' ?>">
                    </div>
                    <div class="col-12 ppu-sftp" style="<?= $host['protocol'] === 'sftp' ? '' : 'display:none' ?>">
                        <div class="ue-lbl">SFTP private key file <span class="ue-hint">(optional — absolute path on this server)</span></div>
                        <input class="form-control" name="privateKeyFile" value="<?= $h($host['privateKeyFile']) ?>" placeholder="/home/user/.ssh/id_rsa">
                    </div>
                    <div class="col-sm-7">
                        <div class="ue-lbl">Upload directory</div>
                        <input class="form-control" name="remoteDir" id="remoteDir" placeholder="/public_html/updates" value="<?= $h($host['remoteDir']) ?>">
                        <div class="ue-hint">Absolute path on the host where bundles are written.</div>
                    </div>
                    <div class="col-sm-5">
                        <div class="ue-lbl">Channel</div>
                        <select class="form-select" name="channel">
                            <?php foreach (['stable' => 'Stable', 'beta' => 'Beta'] as $k => $lbl): ?>
                                <option value="<?= $k ?>" <?= $host['channel'] === $k ? 'selected' : '' ?>><?= $lbl ?></option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <div class="col-12">
                        <div class="ue-lbl">Public download URL</div>
                        <input class="form-control" name="publicUrl" placeholder="https://downloads.example.com/updates" value="<?= $h($host['publicUrl']) ?>">
                        <div class="ue-hint">The https URL that serves files from the upload directory — clients download from here (they can't FTP).</div>
                    </div>
                    <div class="col-12 ppu-ftp" style="<?= $host['protocol'] === 'sftp' ? 'display:none' : '' ?>">
                        <label class="ue-lbl d-flex align-items-center gap-2" style="cursor:pointer">
                            <input type="checkbox" name="passive" <?= $host['passive'] ? 'checked' : '' ?>> Passive mode (recommended for FTP/FTPS)
                        </label>
                    </div>
                    <div class="col-12 d-flex gap-2 pt-1">
                        <button type="button" class="btn btn-accent btn-sm" id="btnSave" data-tip="Save the update-host FTP connection settings"><i class="bi bi-save"></i> Save settings</button>
                        <button type="button" class="btn btn-outline-secondary btn-sm ue-keepon" id="btnTest" data-tip="Connect to the host with these details and list the remote folders — safe, changes nothing"><i class="bi bi-plug"></i> Test connection</button>
                        <button type="button" class="btn btn-outline-secondary btn-sm ue-keepon" id="btnVerify" data-tip="Write a tiny test file over FTP and fetch it via the Public URL — proves the two point at the same folder"><i class="bi bi-link-45deg"></i> Verify URL ↔ folder</button>
                    </div>
                </form>
                <div id="testResult" class="ue-log" style="display:none"></div>

                <!-- Remote directory browser: appears after a successful Test connection -->
                <div class="ue-browser" id="hostBrowser" style="display:none">
                    <div class="ue-browser-bar">
                        <i class="bi bi-folder2-open"></i>
                        <code class="ue-browser-path" id="browsePath">/</code>
                        <span class="ms-auto"></span>
                        <button type="button" class="btn btn-outline-secondary btn-sm" id="browseUp" data-tip="Up one level"><i class="bi bi-arrow-90deg-up"></i></button>
                        <button type="button" class="btn btn-outline-secondary btn-sm" id="browseNew"><i class="bi bi-folder-plus"></i> New folder</button>
                        <button type="button" class="btn btn-accent btn-sm" id="browseUse"><i class="bi bi-check2-circle"></i> Use as upload directory</button>
                    </div>
                    <div class="ue-browser-list" id="browseList"></div>
                    <div class="ue-hint mt-1">Pick the folder to publish into — the engine creates <code>full/</code> and <code>delta/</code> inside it automatically.</div>
                </div>
                </div><!-- /.ue-host-body -->
            </div><!-- /.ue-host primary -->

            <!-- Secondary (backup) FTP host -->
            <div class="ue-host" id="hostPanel2" data-host="secondary">
                <div class="ue-host-head" data-toggle="hostPanel2">
                    <span class="ue-host-ico"><i class="bi bi-hdd-network"></i></span>
                    <div class="ue-host-headmain">
                        <div class="ue-host-title">Secondary host <span class="ue-host-tag">Backup</span>
                            <span class="ue-badge <?= $secondary['configured'] ? 'live' : '' ?>" id="host2Status">
                                <?php if ($secondary['configured']): ?><i class="bi bi-check-circle-fill"></i> Active<?php else: ?><i class="bi bi-dash-circle"></i> Off<?php endif; ?>
                            </span>
                        </div>
                        <div class="ue-host-summary" id="host2Summary">
                            <?= $secondary['configured']
                                ? $h(strtoupper($secondary['protocol']) . ' · ' . $secondary['host']) . ' &rarr; <code>' . $h($secondary['publicUrl']) . '</code>'
                                : 'Off — no backup host. If the primary ever fails, clients would be stuck.' ?>
                        </div>
                    </div>
                    <i class="bi bi-chevron-down ue-chev"></i>
                </div>
                <div class="ue-host-body">
                <p class="ue-lead mb-3"><i class="bi bi-shield-check" style="color:var(--ue-accent)"></i>
                    A second, independent FTP host. Every publish mirrors the bundle, deltas and signed index here too —
                    so if the primary is ever down (or disabled), clients <strong>automatically fall back</strong> and keep updating.
                    Use a <strong>different provider</strong> than your primary for real redundancy. Clear the host to turn it off.</p>
                <div class="ue-lockbar" id="lockbar2">
                    <span class="ue-locknote"><i class="bi bi-lock-fill"></i> Locked to prevent accidental changes.</span>
                    <button type="button" class="btn btn-outline-secondary btn-sm" id="btnEditHost2"><i class="bi bi-pencil"></i> Edit connection</button>
                </div>
                <form id="hostForm2" class="ue-hostform row g-3">
                    <input type="hidden" name="slot" value="secondary">
                    <div class="col-sm-4">
                        <div class="ue-lbl">Protocol</div>
                        <select class="form-select" name="protocol" id="protocol2">
                            <?php foreach (['ftp' => 'FTP', 'ftps' => 'FTPS (FTP over TLS)', 'sftp' => 'SFTP (SSH)'] as $k => $lbl): ?>
                                <option value="<?= $k ?>" <?= $secondary['protocol'] === $k ? 'selected' : '' ?>><?= $lbl ?></option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Host</div>
                        <input class="form-control" name="host" id="host2" placeholder="ftp.backup-provider.com" value="<?= $h($secondary['host']) ?>">
                    </div>
                    <div class="col-sm-2">
                        <div class="ue-lbl">Port</div>
                        <input class="form-control" name="port" type="number" min="0" max="65535"
                               placeholder="<?= (int) ($defaultPorts[$secondary['protocol']] ?? 21) ?>"
                               value="<?= $secondary['port'] > 0 ? (int) $secondary['port'] : '' ?>">
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Username</div>
                        <input class="form-control" name="user" autocomplete="off" value="<?= $h($secondary['user']) ?>">
                    </div>
                    <div class="col-sm-6">
                        <div class="ue-lbl">Password <?php if ($secondary['hasPass']): ?><span class="ue-hint">(saved — leave blank to keep)</span><?php endif; ?></div>
                        <input class="form-control" name="pass" type="password" autocomplete="new-password"
                               placeholder="<?= $secondary['hasPass'] ? '••••••••' : '' ?>">
                    </div>
                    <div class="col-12 ppu2-sftp" style="<?= $secondary['protocol'] === 'sftp' ? '' : 'display:none' ?>">
                        <div class="ue-lbl">SFTP private key file <span class="ue-hint">(optional — absolute path on this server)</span></div>
                        <input class="form-control" name="privateKeyFile" value="<?= $h($secondary['privateKeyFile']) ?>" placeholder="/home/user/.ssh/id_rsa">
                    </div>
                    <div class="col-sm-7">
                        <div class="ue-lbl">Upload directory</div>
                        <input class="form-control" name="remoteDir" placeholder="/public_html/updates" value="<?= $h($secondary['remoteDir']) ?>">
                        <div class="ue-hint">The engine creates <code>full/</code> and <code>delta/</code> inside it, same as the primary.</div>
                    </div>
                    <div class="col-sm-5 ppu2-ftp" style="<?= $secondary['protocol'] === 'sftp' ? 'display:none' : '' ?>">
                        <div class="ue-lbl">&nbsp;</div>
                        <label class="d-flex align-items-center gap-2" style="cursor:pointer;padding-top:.4rem">
                            <input type="checkbox" name="passive" <?= $secondary['passive'] ? 'checked' : '' ?>> Passive mode (recommended)
                        </label>
                    </div>
                    <div class="col-12">
                        <div class="ue-lbl">Public download URL</div>
                        <input class="form-control" name="publicUrl" placeholder="https://backup.example.com/updates" value="<?= $h($secondary['publicUrl']) ?>">
                        <div class="ue-hint">The https URL that serves the secondary upload directory — must resolve to the same folder you upload to.</div>
                    </div>
                    <div class="col-12 d-flex gap-2 pt-1">
                        <button type="button" class="btn btn-accent btn-sm" id="btnSave2"><i class="bi bi-save"></i> Save secondary</button>
                        <button type="button" class="btn btn-outline-secondary btn-sm ue-keepon" id="btnTest2"><i class="bi bi-plug"></i> Test connection</button>
                        <button type="button" class="btn btn-outline-secondary btn-sm ue-keepon" id="btnVerify2" data-tip="Write a tiny test file over FTP and fetch it via the Public URL — proves the two point at the same folder"><i class="bi bi-link-45deg"></i> Verify URL ↔ folder</button>
                    </div>
                </form>
                <div id="testResult2" class="ue-log" style="display:none"></div>

                <!-- Remote directory browser for the secondary: appears after Test connection -->
                <div class="ue-browser" id="hostBrowser2" style="display:none">
                    <div class="ue-browser-bar">
                        <i class="bi bi-folder2-open"></i>
                        <code class="ue-browser-path" id="browsePath2">/</code>
                        <span class="ms-auto"></span>
                        <button type="button" class="btn btn-outline-secondary btn-sm" id="browseUp2" data-tip="Up one level"><i class="bi bi-arrow-90deg-up"></i></button>
                        <button type="button" class="btn btn-outline-secondary btn-sm" id="browseNew2"><i class="bi bi-folder-plus"></i> New folder</button>
                        <button type="button" class="btn btn-accent btn-sm" id="browseUse2"><i class="bi bi-check2-circle"></i> Use as upload directory</button>
                    </div>
                    <div class="ue-browser-list" id="browseList2"></div>
                    <div class="ue-hint mt-1">Pick the folder the <strong>secondary public URL</strong> serves — the engine creates <code>full/</code> and <code>delta/</code> inside it. If files land but the public URL 404s, the folder here doesn’t match the URL.</div>
                </div>
                </div><!-- /.ue-host-body -->
            </div><!-- /.ue-host secondary -->

            <!-- Google Drive off-site backup -->
            <?php
                $gDriveLabel = '';
                foreach ($driveConns as $c) { if ($c['id'] === $gdrive['connection']) { $gDriveLabel = (string) ($c['label'] ?: $c['id']); break; } }
            ?>
            <div class="ue-host" id="hostPanelG" data-host="gdrive">
                <div class="ue-host-head" data-toggle="hostPanelG">
                    <span class="ue-host-ico"><i class="bi bi-google"></i></span>
                    <div class="ue-host-headmain">
                        <div class="ue-host-title">Google Drive backup <span class="ue-host-tag">Off-site</span>
                            <span class="ue-badge <?= $gdrive['configured'] ? 'live' : '' ?>" id="gdriveStatus">
                                <?php if ($gdrive['configured']): ?><i class="bi bi-check-circle-fill"></i> On<?php else: ?><i class="bi bi-dash-circle"></i> Off<?php endif; ?>
                            </span>
                        </div>
                        <div class="ue-host-summary" id="gdriveSummary">
                            <?php if ($gdrive['configured']): ?>On &middot; <?= $h($gDriveLabel) ?>
                            <?php elseif (empty($driveConns)): ?>No Drive connection yet — see the setup guide inside.
                            <?php else: ?>Off — a last-resort copy if both FTP hosts fail.<?php endif; ?>
                        </div>
                    </div>
                    <i class="bi bi-chevron-down ue-chev"></i>
                </div>
                <div class="ue-host-body">
                <p class="ue-lead mb-3"><i class="bi bi-cloud-arrow-up" style="color:var(--ue-accent)"></i>
                    A best-effort off-site copy of the signed index and the full bundle on Google Drive —
                    the <strong>last-resort</strong> fallback if <em>both</em> FTP hosts are unreachable. Uploads never block or fail a publish;
                    if Drive is slow or offline, the publish still succeeds.</p>
                <?php if (!empty($driveConns)): ?>
                <div class="ue-lockbar" id="lockbarG">
                    <span class="ue-locknote"><i class="bi bi-lock-fill"></i> Locked to prevent accidental changes.</span>
                    <button type="button" class="btn btn-outline-secondary btn-sm" id="btnEditGdrive"><i class="bi bi-pencil"></i> Edit backup</button>
                </div>
                <?php endif; ?>

                <?php if (empty($driveConns)): ?>
                    <div class="ue-empty-note">
                        <i class="bi bi-info-circle"></i>
                        No Google Drive connection yet. Connect one under
                        <a href="<?= $b ?>/settings/storage" target="_blank" rel="noopener">Settings → Storage connections</a>,
                        then return here to pick it. See <strong>How to set up Google Drive</strong> below.
                    </div>
                <?php else: ?>
                    <form id="gdriveForm" class="row g-3">
                        <div class="col-12">
                            <label class="d-flex align-items-center gap-2" style="cursor:pointer">
                                <input type="checkbox" name="enabled" id="gdriveEnabled" <?= $gdrive['enabled'] ? 'checked' : '' ?>>
                                <span class="ue-lbl mb-0">Enable the Google Drive backup</span>
                            </label>
                        </div>
                        <div class="col-sm-8">
                            <div class="ue-lbl">Drive connection</div>
                            <select class="form-select" name="connection" id="gdriveConn">
                                <?php foreach ($driveConns as $c): ?>
                                    <option value="<?= $h($c['id']) ?>" <?= $gdrive['connection'] === $c['id'] ? 'selected' : '' ?>><?= $h($c['label'] ?: $c['id']) ?></option>
                                <?php endforeach; ?>
                            </select>
                            <div class="ue-hint">Managed in <a href="<?= $b ?>/settings/storage" target="_blank" rel="noopener">Settings → Storage connections</a>.</div>
                        </div>
                        <div class="col-12 d-flex gap-2 pt-1">
                            <button type="button" class="btn btn-accent btn-sm" id="btnSaveGdrive"><i class="bi bi-save"></i> Save backup</button>
                            <button type="button" class="btn btn-outline-secondary btn-sm" id="btnTestGdrive"><i class="bi bi-plug"></i> Test Drive</button>
                        </div>
                    </form>
                    <div id="gdriveResult" class="ue-log" style="display:none"></div>
                    <?php if ($gdrive['indexUrl'] !== ''): ?>
                        <div class="ue-hint mt-2">Last backed-up index: <code class="ue-code-url"><?= $h($gdrive['indexUrl']) ?></code></div>
                    <?php endif; ?>
                    <?php if (($gdrive['bundleCount'] ?? 0) > 0): ?>
                        <div class="d-flex align-items-center gap-2 mt-3 pt-2" style="border-top:1px solid var(--pp-border,#eef1f6)">
                            <div class="ue-hint mb-0" style="flex:1">
                                <i class="bi bi-hdd-stack"></i> <strong><?= (int) $gdrive['bundleCount'] ?></strong> bundle backup<?= $gdrive['bundleCount'] === 1 ? '' : 's' ?> stored on Drive.
                                Each publish leaves a copy; flush them to reclaim space (the tiny signed index is kept as a fallback).
                            </div>
                            <button type="button" class="btn btn-outline-secondary btn-sm" id="btnFlushGdrive" data-tip="Delete the accumulated full-bundle backups from Google Drive (keeps index.json)"><i class="bi bi-trash3"></i> Flush backups</button>
                        </div>
                    <?php endif; ?>
                <?php endif; ?>

                <details class="ue-details mt-3">
                    <summary><i class="bi bi-question-circle"></i> How to set up Google Drive as a backup host</summary>
                    <ol class="ue-steps">
                        <li><strong>Create a Google Cloud project.</strong> Go to
                            <a href="https://console.cloud.google.com/" target="_blank" rel="noopener">console.cloud.google.com</a> → create (or pick) a project.</li>
                        <li><strong>Enable the Drive API.</strong> APIs &amp; Services → Library → search “Google Drive API” → Enable.</li>
                        <li><strong>Configure the OAuth consent screen.</strong> APIs &amp; Services → OAuth consent screen →
                            User type <em>External</em> → add your Google account as a <em>Test user</em> (so it works without Google verification).</li>
                        <li><strong>Create an OAuth client.</strong> Credentials → Create credentials → <em>OAuth client ID</em> →
                            Application type <em>Web application</em>. Under <em>Authorized redirect URIs</em> add exactly:
                            <code class="ue-code-url"><?= $h(rtrim((string) ($autoOrigin ?? ''), '/')) ?>/settings/storage/oauth/gdrive/callback</code>
                            Copy the <strong>Client ID</strong> and <strong>Client secret</strong>.</li>
                        <li><strong>Add the connection in PPPHP.</strong> Open
                            <a href="<?= $b ?>/settings/storage" target="_blank" rel="noopener">Settings → Storage connections</a> →
                            Add connection → <em>Google Drive</em> → paste the Client ID/secret →
                            (optional) set a <em>Folder ID</em> to keep backups tidy (copy it from the folder’s URL in Drive) →
                            click <strong>Connect</strong> and approve access.</li>
                        <li><strong>Return here</strong>, pick the connection above, tick <em>Enable</em>, and Save. Your next publish
                            mirrors the index and full bundle to Drive.</li>
                    </ol>
                    <div class="ue-hint">Note: Drive is a <em>backup of last resort</em>. The two FTP hosts are the primary way clients update;
                        Drive links can show a confirmation page for very large files, so keep bundles reasonable.</div>
                </details>
                </div><!-- /.ue-host-body -->
            </div><!-- /.ue-host gdrive -->

            <div class="ue-card">
                <h3><i class="bi bi-file-earmark-lock"></i> Signed update index</h3>
                <p class="ue-lead mb-2">This one signed file is what every install reads to learn “what’s the newest release.”
                    It is refreshed automatically on each publish. Because the <em>host</em> — not any one database — is the source
                    of truth, an update published from anywhere (a developer laptop included) reaches the whole estate.</p>
                <div class="ue-lbl">Index URL</div>
                <div class="d-flex align-items-center gap-2 flex-wrap">
                    <code class="ue-code-url" id="indexUrlCode"><?= $indexUrl !== '' ? $h($indexUrl) : '— not set —' ?></code>
                    <button type="button" class="btn btn-accent btn-sm" id="btnReindex" <?= $host['configured'] ? '' : 'disabled' ?>
                        data-tip="Rebuild, re-sign and re-upload index.json to your active hosts — safe to run any time"><i class="bi bi-arrow-repeat"></i> Republish index now</button>
                </div>
                <div class="ue-hint mt-1" id="indexHint">
                    <?php if ($host['configured']): ?>
                        Rebuilds and re-signs <code>index.json</code>, then uploads it to the <strong>primary</strong><?php
                            $mirrors = [];
                            if ($secondary['configured']) $mirrors[] = 'secondary';
                            if ($gdrive['configured']) $mirrors[] = 'Google Drive';
                            if ($mirrors) echo ' + ' . implode(' + ', $mirrors);
                        ?>. Run it after changing hosts, or if an install can’t see the newest release.
                    <?php else: ?>
                        Configure and save the <strong>Primary host</strong> above — then you can (re)publish the signed index here.
                    <?php endif; ?>
                </div>
                <div id="reindexResult" class="ue-log mt-2" style="display:none"></div>
            </div>
        </section>

        <!-- Publish -->
        <section class="ue-sec" id="sec-publish" role="tabpanel">
            <div class="ue-h"><i class="bi bi-cloud-arrow-up"></i><h2>Publish a release</h2></div>
            <p class="ue-lead">Builds the bundle, generates delta patches against recent versions, uploads them to the host,
                and lists them in the feed. Clients pick up the newest on their next <em>Check for Updates</em>.</p>
            <div class="ue-card">
                <form id="publishForm" class="row g-3">
                    <div class="col-sm-4">
                        <div class="ue-lbl">Version</div>
                        <input class="form-control" name="version" value="<?= $h($nextVer) ?>">
                        <div class="ue-hint">Installed: <?= $h($codeVersion) ?></div>
                    </div>
                    <div class="col-sm-4">
                        <div class="ue-lbl">Edition</div>
                        <select class="form-select" name="variant" data-tip="Which build(s) to publish: server (your live servers), client (customers), or both">
                            <option value="both" selected>Both — server + client (recommended)</option>
                            <option value="client">Client only (customer installs)</option>
                            <option value="server">Server only (your live servers)</option>
                        </select>
                        <div class="ue-hint">“Both” ships the server build for your live servers and the client build for customers in one go.</div>
                    </div>
                    <div class="col-sm-4">
                        <div class="ue-lbl">Channel</div>
                        <select class="form-select" name="channel">
                            <option value="stable" <?= $channel === 'stable' ? 'selected' : '' ?>>Stable</option>
                            <option value="beta" <?= $channel === 'beta' ? 'selected' : '' ?>>Beta</option>
                        </select>
                    </div>
                    <div class="col-sm-7">
                        <div class="ue-lbl">What to publish</div>
                        <div class="ue-seg" id="kindSeg" data-tip="Full = whole bundle for fresh installs; Deltas = tiny changed-files-only patches for recent versions">
                            <label><input type="radio" name="kind" value="both" checked> <span>Full + deltas</span></label>
                            <label><input type="radio" name="kind" value="full"> <span>Full only</span></label>
                            <label><input type="radio" name="kind" value="delta"> <span>Deltas only</span></label>
                        </div>
                        <div class="ue-hint">Full = fresh installs &amp; far-behind clients; deltas = tiny patches for recent versions. <em>Deltas only</em> needs the full of this version already published.</div>
                    </div>
                    <div class="col-sm-5">
                        <div class="ue-lbl">Availability</div>
                        <label class="ue-switch" data-tip="On = clients are offered it now; Off = staged (uploaded but held back until you flip it live)"><input type="checkbox" name="ota" checked> <span>Make available over-the-air (OTA)</span></label>
                        <div class="ue-hint">Off = <strong>staged</strong> (uploaded &amp; recorded, but not offered to clients until you flip it live).</div>
                    </div>
                    <div class="col-12">
                        <div class="ue-lbl">Upload to <span class="ue-hint">— the primary is always used; pick which backups to mirror this release to</span></div>
                        <div class="ue-targets">
                            <label class="ue-target on" data-tip="The main host every client reads first — always uploaded to">
                                <input type="checkbox" checked disabled>
                                <span class="ue-target-ic" style="background:var(--ue-accent);color:#fff"><i class="bi bi-hdd-network-fill"></i></span>
                                <span><strong>Primary</strong><small><?= $host['configured'] ? $h($host['host']) : 'not set' ?></small></span>
                                <span class="ue-target-tag">Always</span>
                            </label>
                            <label class="ue-target <?= $secondary['configured'] ? '' : 'disabled' ?>" data-tip="<?= $secondary['configured'] ? 'Mirror this release to the secondary FTP host' : 'Configure a secondary host to enable' ?>">
                                <input type="checkbox" name="toSecondary" <?= $secondary['configured'] ? 'checked' : 'disabled' ?>>
                                <span class="ue-target-ic"><i class="bi bi-hdd-network"></i></span>
                                <span><strong>Secondary</strong><small><?= $secondary['configured'] ? $h($secondary['host']) : 'off' ?></small></span>
                                <span class="ue-target-tag">Backup</span>
                            </label>
                            <label class="ue-target <?= $gdrive['configured'] ? '' : 'disabled' ?>" data-tip="<?= $gdrive['configured'] ? 'Also back this release up to Google Drive' : 'Enable a Google Drive backup to use' ?>">
                                <input type="checkbox" name="toGdrive" <?= $gdrive['configured'] ? 'checked' : 'disabled' ?>>
                                <span class="ue-target-ic"><i class="bi bi-google"></i></span>
                                <span><strong>Google Drive</strong><small><?= $gdrive['configured'] ? 'on' : 'off' ?></small></span>
                                <span class="ue-target-tag">Off-site</span>
                            </label>
                        </div>
                    </div>
                    <div class="col-12">
                        <div class="ue-lbl">Release notes <span class="ue-hint">(optional)</span></div>
                        <textarea class="form-control" name="notes" rows="2" placeholder="What changed in this release"></textarea>
                    </div>
                    <input type="hidden" name="allowFlagged" id="allowFlagged" value="">
                    <div class="col-12 pt-1">
                        <button type="button" class="btn btn-accent btn-sm" id="btnPublish" data-tip="Build the selected editions, generate deltas, upload to the host, and refresh the signed index" <?= (!$hasKey || !$host['configured']) ? 'disabled' : '' ?>>
                            <i class="bi bi-upload"></i> Build &amp; publish
                        </button>
                        <?php if (!$host['configured']): ?><span class="ue-hint ms-2">Save a valid host first.</span><?php endif; ?>
                    </div>
                </form>

                <!-- Live publish progress streams into the locked #pubModal (top of page). -->
                <div id="publishResult" class="ue-log" style="display:none"></div>
            </div>
        </section>

        <!-- Download -->
        <section class="ue-sec" id="sec-download" role="tabpanel">
            <div class="ue-h"><i class="bi bi-download"></i><h2>Generate &amp; download</h2></div>
            <p class="ue-lead">Build a package and download it here — a <strong>Full installer</strong> for a fresh install, or a
                <strong>Delta patch</strong> carrying only what changed since a chosen version. Built on demand, streamed to you,
                then deleted from this server. (Use <em>Publish (OTA)</em> instead to push straight to the update host.)</p>
            <div class="ue-card">
                <form id="dlForm" class="row g-3">
                    <div class="col-sm-4">
                        <div class="ue-lbl">Edition</div>
                        <select class="form-select" name="variant" id="dlVariant">
                            <option value="client">Client (customer installs)</option>
                            <option value="server">Server / authority</option>
                        </select>
                    </div>
                    <div class="col-sm-4" id="dlRoleWrap" style="display:none">
                        <div class="ue-lbl">Server role</div>
                        <select class="form-select" name="role" id="dlRole">
                            <option value="main">Main licence server</option>
                            <option value="backup">Backup (pairs to a main)</option>
                        </select>
                    </div>
                    <div class="col-sm-4">
                        <div class="ue-lbl">Version <span class="ue-hint">(optional)</span></div>
                        <input class="form-control" name="relver" id="dlRelver" placeholder="<?= $h($nextVer) ?>">
                    </div>
                    <div class="col-12" id="dlMainWrap" style="display:none">
                        <div class="ue-lbl">Main server the backup pairs to <span class="ue-hint">(optional — can also pair from its console)</span></div>
                        <input class="form-control" name="main" id="dlMain" placeholder="https://ppphp.ppradhan.com">
                    </div>
                    <div class="col-sm-7">
                        <div class="ue-lbl">Package type</div>
                        <div class="ue-seg" id="dlTypeSeg">
                            <label><input type="radio" name="type" value="full" checked> <span>Full installer</span></label>
                            <label><input type="radio" name="type" value="updates"> <span>Delta patch</span></label>
                        </div>
                        <div class="ue-hint">Full = fresh installs; Delta = a small patch for an existing install of a prior version.</div>
                    </div>
                    <div class="col-sm-5" id="dlFromWrap" style="display:none">
                        <div class="ue-lbl">Patch from version</div>
                        <select class="form-select" name="from" id="dlFrom"></select>
                        <div class="ue-hint" id="dlFromEmpty" style="display:none">No baselines for this edition yet — build a Full installer of an earlier version first.</div>
                    </div>
                    <div class="col-12 pt-1">
                        <button type="button" class="btn btn-accent btn-sm" id="btnDownload" data-tip="Build the package and download the file to your computer — nothing is uploaded"><i class="bi bi-download"></i> Generate &amp; download</button>
                    </div>
                </form>
                <div id="dlResult" class="ue-log" style="display:none"></div>
            </div>
        </section>
        <script>
            window.PP_BASELINES = <?= json_encode($baselines ?? ['client' => [], 'server' => []], JSON_UNESCAPED_SLASHES) ?>;
            window.PP_DL_URL = <?= json_encode($downloadUrl ?? (rtrim((string) $b, '/') . '/platform/app-bundle/download'), JSON_UNESCAPED_SLASHES) ?>;
        </script>

        <!-- Releases -->
        <section class="ue-sec" id="sec-releases" role="tabpanel">
            <div class="ue-h"><i class="bi bi-clock-history"></i><h2>Published releases</h2></div>
            <p class="ue-lead">Everything currently offered through the feed, newest first.</p>
            <?php if ($releases):
                $hostBytes = 0; $hostDeltas = 0;
                foreach ($releases as $rr) {
                    $hostBytes += (int) ($rr['full_size'] ?? 0);
                    foreach (($rr['deltas'] ?? []) as $dd) { $hostBytes += (int) ($dd['size'] ?? 0); $hostDeltas++; }
                }
            ?>
            <p class="ue-hint" style="margin-top:-.35rem">
                <i class="bi bi-hdd-stack"></i>
                <strong><?= count($releases) ?></strong> full bundle<?= count($releases) === 1 ? '' : 's' ?>
                · <strong><?= $hostDeltas ?></strong> delta<?= $hostDeltas === 1 ? '' : 's' ?>
                · <strong><?= $human($hostBytes) ?></strong> stored on the update host.
                Delete anything old below to reclaim space.
            </p>
            <?php endif; ?>
            <div class="ue-card">
                <?php if (!$releases): ?>
                    <div class="ue-empty"><i class="bi bi-inbox"></i>Nothing published yet.</div>
                <?php else: ?>
                    <?php foreach ($releases as $r): ?>
                        <?php $live = ($r['status'] ?? 'live') !== 'staged'; $nd = count($r['deltas'] ?? []); ?>
                        <div class="ue-relrow" data-version="<?= $h($r['version']) ?>" data-channel="<?= $h($r['channel']) ?>" data-edition="<?= $h($r['edition'] ?? 'client') ?>">
                            <div class="ue-relhead">
                                <span class="ue-badge"><?= $h($r['channel']) ?></span>
                                <code><?= $h($r['version']) ?></code>
                                <span class="ue-badge ue-ed"><?= $h($r['edition'] ?? 'client') ?></span>
                                <span class="ue-badge <?= $live ? 'ue-live' : 'ue-staged' ?>"><i class="bi <?= $live ? 'bi-broadcast' : 'bi-pause-circle' ?>"></i> <?= $live ? 'Live' : 'Staged' ?></span>
                                <?php if ($nd): ?><button type="button" class="ue-deltatoggle" data-t="d-<?= $h($r['version']) ?>"><i class="bi bi-diagram-3"></i> <?= $nd ?> delta<?= $nd === 1 ? '' : 's' ?></button><?php endif; ?>
                                <span class="ms-auto text-muted small"><?= $human((int) $r['full_size']) ?> · <?= $h($r['published_at']) ?></span>
                            </div>
                            <?php if (!empty($r['codename']) || !empty($r['notes'])): ?>
                                <div class="ue-relnote"><?php if (!empty($r['codename'])): ?><strong><?= $h($r['codename']) ?></strong> <?php endif; ?><?= $h($r['notes'] ?? '') ?></div>
                            <?php endif; ?>
                            <?php if ($nd): ?>
                                <div class="ue-deltalist" id="d-<?= $h($r['version']) ?>" style="display:none">
                                    <?php foreach ($r['deltas'] as $d): ?>
                                        <div class="ue-deltarow"><i class="bi bi-arrow-right-short"></i> from <code><?= $h($d['from_version']) ?></code> · <?= $human((int) $d['size']) ?></div>
                                    <?php endforeach; ?>
                                </div>
                            <?php endif; ?>
                            <?php
                                $artFiles = [];
                                if (!empty($r['full_url'])) $artFiles[] = ['n' => basename((string) $r['full_url']), 'k' => 'full', 's' => (int) ($r['full_size'] ?? 0)];
                                foreach (($r['deltas'] ?? []) as $d) { if (!empty($d['url'])) $artFiles[] = ['n' => basename((string) $d['url']), 'k' => 'delta', 's' => (int) ($d['size'] ?? 0)]; }
                                $secOn = !empty($secondary['configured']);
                            ?>
                            <?php if ($artFiles): ?>
                                <div class="ue-relfiles">
                                    <div class="ue-relfiles-h"><i class="bi bi-files"></i> Files on host<?= $secOn ? 's' : '' ?> <span class="ue-hint">(deleting removes them everywhere)</span></div>
                                    <?php foreach ($artFiles as $af): ?>
                                        <div class="ue-fileitem">
                                            <i class="bi bi-file-earmark-zip"></i>
                                            <code><?= $h($af['k']) ?>/<?= $h($af['n']) ?></code>
                                            <span class="ue-hbadge pri" data-tip="Stored on the primary host">PRI</span>
                                            <?php if ($secOn): ?><span class="ue-hbadge sec" data-tip="Mirrored to the secondary host">SEC</span><?php endif; ?>
                                            <span class="ue-fmeta"><?= $human($af['s']) ?></span>
                                        </div>
                                    <?php endforeach; ?>
                                </div>
                            <?php endif; ?>
                            <div class="ue-relact">
                                <button type="button" class="btn btn-outline-secondary btn-sm ue-rel-status" data-to="<?= $live ? 'staged' : 'live' ?>">
                                    <i class="bi <?= $live ? 'bi-pause' : 'bi-broadcast' ?>"></i> <?= $live ? 'Stage (hold back)' : 'Make live' ?>
                                </button>
                                <button type="button" class="btn btn-outline-secondary btn-sm ue-rel-del"><i class="bi bi-trash3"></i> Delete</button>
                            </div>
                        </div>
                    <?php endforeach; ?>
                <?php endif; ?>
            </div>
        </section>

    </main>
</div>

<script>
(function () {
    var B = <?= json_encode($b) ?>, TOKEN = <?= json_encode($token) ?>;
    var $ = function (id) { return document.getElementById(id); };

    // ── tab switching ──
    var navItems = document.querySelectorAll('.ue-nav-item');
    var sections = document.querySelectorAll('.ue-sec');
    function show(sec) {
        navItems.forEach(function (n) { n.classList.toggle('active', n.dataset.sec === sec); });
        sections.forEach(function (s) { s.classList.toggle('active', s.id === 'sec-' + sec); });
        try { history.replaceState(null, '', '#' + sec); } catch (e) {}
    }
    navItems.forEach(function (n) { n.addEventListener('click', function () { show(n.dataset.sec); }); });
    document.querySelectorAll('.ue-go').forEach(function (g) { g.addEventListener('click', function () { show(g.dataset.go); }); });
    var start = (location.hash || '').replace('#', '');
    if (start && document.getElementById('sec-' + start)) show(start);

    function formData(form) {
        var o = { _token: TOKEN };
        Array.prototype.forEach.call(form.elements, function (el) {
            if (!el.name) return;
            if (el.type === 'radio') { if (el.checked) o[el.name] = el.value; return; }
            o[el.name] = el.type === 'checkbox' ? el.checked : el.value;
        });
        return o;
    }
    function post(path, body) {
        return fetch(B + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) { return r.json(); });
    }
    function esc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }

    // ── Blocking overlay: lock the whole page during build/upload/download ──
    var LOCKED = false;
    function lockUI(title, msg) {
        LOCKED = true;
        $('ueOvTitle').textContent = title || 'Working…';
        $('ueOvMsg').innerHTML = msg || 'Please wait…';
        $('ueOverlay').classList.add('on');
    }
    function ovMsg(msg) { $('ueOvMsg').innerHTML = msg; }
    function unlockUI() { LOCKED = false; $('ueOverlay').classList.remove('on'); }
    // Warn before leaving while a build/upload is running.
    window.addEventListener('beforeunload', function (e) { if (LOCKED) { e.preventDefault(); e.returnValue = ''; return ''; } });

    // protocol → show/hide FTP passive vs SFTP key
    $('protocol').addEventListener('change', function () {
        var sftp = this.value === 'sftp';
        document.querySelectorAll('.ppu-sftp').forEach(function (e) { e.style.display = sftp ? '' : 'none'; });
        document.querySelectorAll('.ppu-ftp').forEach(function (e) { e.style.display = sftp ? 'none' : ''; });
    });
    if ($('protocol2')) $('protocol2').addEventListener('change', function () {
        var sftp = this.value === 'sftp';
        document.querySelectorAll('.ppu2-sftp').forEach(function (e) { e.style.display = sftp ? '' : 'none'; });
        document.querySelectorAll('.ppu2-ftp').forEach(function (e) { e.style.display = sftp ? 'none' : ''; });
    });

    // ── Collapsible host panels: folded by default; click the header to expand. ──
    function openPanel(panelId, open) { var p = $(panelId); if (p) p.classList.toggle('open', open !== false); }
    document.querySelectorAll('.ue-host-head').forEach(function (head) {
        head.addEventListener('click', function (e) {
            if (e.target.closest('button')) return;          // buttons manage themselves
            head.parentElement.classList.toggle('open');
        });
    });

    // ── Host config lock: a host's fields stay read-only until the owner clicks
    //    Edit, so credentials can't be changed by accident. Applies to all hosts. ──
    function lockForm(formId, locked) {
        var f = $(formId); if (!f) return;
        f.classList.toggle('locked', locked);
        f.querySelectorAll('input, select, textarea, button').forEach(function (el) {
            // Read-only diagnostics (Test / Verify) stay clickable even when locked —
            // they check the SAVED config and change nothing.
            if (el.classList.contains('ue-keepon')) { el.disabled = false; return; }
            el.disabled = locked;
        });
    }
    // After the primary is saved, light up the signed-index controls without a reload.
    function revealIndexControls() {
        var pub = (document.querySelector('#hostForm [name="publicUrl"]') || {}).value || '';
        if (pub && $('indexUrlCode')) $('indexUrlCode').textContent = pub.replace(/\/+$/, '') + '/index.json';
        if ($('btnReindex')) $('btnReindex').disabled = false;
        if ($('indexHint')) $('indexHint').innerHTML = 'Rebuilds and re-signs <code>index.json</code> and uploads it to your active hosts. Run it after changing hosts, or if an install can’t see the newest release.';
    }
    function summaryFromForm(formId) {
        var f = $(formId); if (!f) return '';
        var proto = (f.querySelector('[name="protocol"]') || {}).value || '';
        var host = (f.querySelector('[name="host"]') || {}).value || '';
        var pub = (f.querySelector('[name="publicUrl"]') || {}).value || '';
        if (!host) return '';
        return (proto ? proto.toUpperCase() + ' · ' : '') + esc(host) + (pub ? ' &rarr; <code>' + esc(pub) + '</code>' : '');
    }
    // Wire Edit buttons + initial lock/fold state for every host panel. The Edit
    // control lives INSIDE the card (a lock bar), not in the header, so a click on
    // the header only folds/unfolds and can't accidentally start an edit.
    [
        { form: 'hostForm',   edit: 'btnEditHost',   bar: 'lockbar',  panel: 'hostPanel',   configured: <?= $host['configured'] ? 'true' : 'false' ?> },
        { form: 'hostForm2',  edit: 'btnEditHost2',  bar: 'lockbar2', panel: 'hostPanel2',  configured: <?= $secondary['configured'] ? 'true' : 'false' ?> },
        { form: 'gdriveForm', edit: 'btnEditGdrive', bar: 'lockbarG', panel: 'hostPanelG',  configured: <?= $gdrive['configured'] ? 'true' : 'false' ?> }
    ].forEach(function (hcfg) {
        if (!$(hcfg.form)) return;
        lockForm(hcfg.form, hcfg.configured);                       // locked when already set up
        if ($(hcfg.bar)) $(hcfg.bar).style.display = hcfg.configured ? '' : 'none';   // bar shows only while locked
        var eb = $(hcfg.edit);
        if (eb) {
            eb.addEventListener('click', function (e) {
                e.stopPropagation();
                openPanel(hcfg.panel, true);
                lockForm(hcfg.form, false);
                if ($(hcfg.bar)) $(hcfg.bar).style.display = 'none';
            });
        }
    });

    $('btnSave').addEventListener('click', function () {
        var btn = this; btn.disabled = true;
        post('/update/server/save', formData($('hostForm'))).then(function (r) {
            btn.disabled = false;
            Swal.fire({ icon: r.success ? 'success' : 'error', title: r.success ? 'Saved' : 'Could not save', text: r.message, timer: r.success ? 1600 : undefined });
            if (r.success) {
                lockForm('hostForm', true);
                if ($('lockbar')) $('lockbar').style.display = '';
                var s = summaryFromForm('hostForm'); if (s && $('hostSummary')) $('hostSummary').innerHTML = s;
                if (r.configured) revealIndexControls();
                if (r.configured) { var b = $('hostStatus'); if (b) { b.classList.add('live'); b.innerHTML = '<i class="bi bi-check-circle-fill"></i> Connected'; } }
            }
        }).catch(function () { btn.disabled = false; });
    });

    // ── Secondary host: save + test (reuses the primary /test probe) ──
    if ($('btnSave2')) $('btnSave2').addEventListener('click', function () {
        var btn = this; btn.disabled = true;
        post('/update/server/save-secondary', formData($('hostForm2'))).then(function (r) {
            btn.disabled = false;
            Swal.fire({ icon: r.success ? 'success' : 'error', title: r.success ? 'Saved' : 'Could not save', text: r.message, timer: r.success ? 1600 : undefined });
            if (r.success) {
                lockForm('hostForm2', true);
                if ($('lockbar2')) $('lockbar2').style.display = '';
                var sm = summaryFromForm('hostForm2');
                if ($('host2Summary')) $('host2Summary').innerHTML = sm || 'Off — no backup host. If the primary ever fails, clients would be stuck.';
                var s = $('host2Status'); if (s) { s.classList.toggle('live', !!r.configured); s.innerHTML = r.configured ? '<i class="bi bi-check-circle-fill"></i> Active' : '<i class="bi bi-dash-circle"></i> Off'; }
            }
        }).catch(function () { btn.disabled = false; });
    });
    // (Secondary "Test connection" + its folder browser are wired by initBrowser below.)

    // ── Update mirrors: save the owner's ordered read-URL list ──
    if ($('btnSaveMirrors')) $('btnSaveMirrors').addEventListener('click', function () {
        var btn = this; btn.disabled = true;
        post('/update/server/save-mirrors', { _token: TOKEN, mirrors: $('mirrorList').value, displayName: ($('updateDisplayName') || {}).value || '' }).then(function (r) {
            btn.disabled = false;
            Swal.fire({ icon: r.success ? 'success' : 'error', title: r.success ? 'Mirrors saved' : 'Could not save', text: r.message, timer: r.success ? 1600 : undefined });
            if (r && r.success && r.effective && $('mirrorEffective')) {
                $('mirrorEffective').innerHTML = r.effective.map(function (u, i) { return '<span class="ue-badge">' + (i + 1) + '. ' + esc(u) + '</span> '; }).join('');
            }
        }).catch(function () { btn.disabled = false; });
    });

    // ── Google Drive backup: save + test ──
    if ($('btnSaveGdrive')) $('btnSaveGdrive').addEventListener('click', function () {
        var btn = this; btn.disabled = true;
        post('/update/server/save-gdrive', formData($('gdriveForm'))).then(function (r) {
            btn.disabled = false;
            Swal.fire({ icon: r.success ? 'success' : 'error', title: r.success ? 'Saved' : 'Could not save', text: r.message, timer: r.success ? 1600 : undefined });
            if (r.success) {
                lockForm('gdriveForm', true);
                if ($('lockbarG')) $('lockbarG').style.display = '';
                var sel = $('gdriveConn'), on = $('gdriveEnabled') && $('gdriveEnabled').checked;
                var label = sel && sel.selectedOptions[0] ? sel.selectedOptions[0].textContent : '';
                if ($('gdriveSummary')) $('gdriveSummary').innerHTML = (on && r.configured) ? 'On &middot; ' + esc(label) : 'Off — a last-resort copy if both FTP hosts fail.';
                var s = $('gdriveStatus'); if (s) { s.classList.toggle('live', !!r.configured); s.innerHTML = r.configured ? '<i class="bi bi-check-circle-fill"></i> On' : '<i class="bi bi-dash-circle"></i> Off'; }
            }
        }).catch(function () { btn.disabled = false; });
    });
    if ($('btnTestGdrive')) $('btnTestGdrive').addEventListener('click', function () {
        var btn = this, box = $('gdriveResult'), sel = $('gdriveConn');
        btn.disabled = true; box.style.display = 'block'; box.innerHTML = 'Checking the Drive connection…';
        post('/update/server/test-gdrive', { _token: TOKEN, connection: sel ? sel.value : '' }).then(function (r) {
            btn.disabled = false;
            box.innerHTML = '<div><strong>' + (r.success ? '✅ ' : '❌ ') + esc(r.message || '') + '</strong></div>';
        }).catch(function () { btn.disabled = false; box.innerHTML = 'Test failed to run.'; });
    });

    if ($('btnReindex')) $('btnReindex').addEventListener('click', function () {
        var btn = this, box = $('reindexResult');
        btn.disabled = true; box.style.display = 'block'; box.innerHTML = 'Publishing signed index…';
        post('/update/server/reindex', { _token: TOKEN }).then(function (r) {
            btn.disabled = false;
            box.innerHTML = '<div><strong>' + (r.success ? '✅ ' : '❌ ') + esc(r.message || '') + '</strong></div>';
        }).catch(function () { btn.disabled = false; box.innerHTML = 'Could not publish the index.'; });
    });

    if ($('autoToggle')) $('autoToggle').addEventListener('change', function () {
        var on = this.checked, badge = $('autoBadge');
        post('/platform/updates/auto-toggle', { _token: TOKEN, enabled: on }).then(function (r) {
            if (r.success) { badge.textContent = on ? 'ON' : 'OFF'; badge.classList.toggle('live', on); }
            else { Swal.fire({ icon: 'error', title: 'Could not change', text: r.message || '' }); }
        }).catch(function () {});
    });
    if ($('autoRunNow')) $('autoRunNow').addEventListener('click', function () {
        var btn = this, box = $('autoResult');
        btn.disabled = true; box.style.display = 'block'; box.innerHTML = 'Checking for updates…';
        post('/platform/updates/auto-run-now', { _token: TOKEN }).then(function (r) {
            btn.disabled = false;
            box.innerHTML = '<div><strong>' + (r.applied ? '✅ ' : 'ℹ️ ') + esc(r.message || '') + '</strong></div>';
            if (r.applied) setTimeout(function () { location.reload(); }, 1800);
        }).catch(function () { btn.disabled = false; box.innerHTML = 'Run failed.'; });
    });

    // ── Remote directory browser — one factory, instantiated for each FTP host so
    //    BOTH primary and secondary get Test → browse → "Use as upload directory". ──
    var human = function (b) { b = +b || 0; return b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : b >= 1024 ? Math.round(b / 1024) + ' KB' : b + ' B'; };
    function initBrowser(cfg) {
        var browsePath = '/';
        function render(listing) {
            browsePath = listing.path || '/';
            $(cfg.browser).style.display = 'block';
            $(cfg.path).textContent = browsePath;
            var list = $(cfg.list);
            var rows = (listing.entries || []).map(function (e) {
                if (e.type === 'dir') {
                    return '<div class="ue-fsrow dir" data-open="' + esc(e.name) + '"><i class="bi bi-folder-fill"></i>' + esc(e.name) +
                        '<button type="button" class="fsdel" data-del="' + esc(e.name) + '" data-tip="Delete empty folder"><i class="bi bi-trash3"></i></button></div>';
                }
                return '<div class="ue-fsrow"><i class="bi bi-file-earmark"></i>' + esc(e.name) + '<span class="fsmeta">' + human(e.size) + '</span>' +
                    '<button type="button" class="fsdel" data-delfile="' + esc(e.name) + '" data-tip="Delete this file from the host"><i class="bi bi-trash3"></i></button></div>';
            }).join('');
            list.innerHTML = rows || '<div class="ue-fsempty">This folder is empty.</div>';
            list.querySelectorAll('[data-open]').forEach(function (el) {
                el.addEventListener('click', function (ev) {
                    if (ev.target.closest('.fsdel')) return;
                    go((browsePath === '/' ? '' : browsePath) + '/' + el.dataset.open);
                });
            });
            list.querySelectorAll('[data-del]').forEach(function (el) {
                el.addEventListener('click', function (ev) {
                    ev.stopPropagation();
                    var name = el.dataset.del;
                    Swal.fire({ icon: 'warning', title: 'Delete “' + name + '”?', text: 'Only empty folders can be deleted.', showCancelButton: true, confirmButtonText: 'Delete' })
                        .then(function (res) {
                            if (!res.isConfirmed) return;
                            var body = formData($(cfg.form)); body.path = browsePath; body.target = (browsePath === '/' ? '' : browsePath) + '/' + name;
                            post('/update/server/rmdir', body).then(function (r) {
                                if (r.success && r.entries) render(r);
                                else Swal.fire({ icon: 'error', title: 'Could not delete', text: r.message });
                            });
                        });
                });
            });
            list.querySelectorAll('[data-delfile]').forEach(function (el) {
                el.addEventListener('click', function (ev) {
                    ev.stopPropagation();
                    var name = el.dataset.delfile;
                    Swal.fire({ icon: 'warning', title: 'Delete file “' + name + '”?', text: 'Permanently removes this file from the host.', showCancelButton: true, confirmButtonText: 'Delete', confirmButtonColor: '#dc2626' })
                        .then(function (res) {
                            if (!res.isConfirmed) return;
                            var body = formData($(cfg.form)); body.path = browsePath; body.target = (browsePath === '/' ? '' : browsePath) + '/' + name;
                            post('/update/server/delete-file', body).then(function (r) {
                                if (r.entries) render(r);
                                if (!r.success) Swal.fire({ icon: 'error', title: 'Could not delete', text: r.message });
                            });
                        });
                });
            });
        }
        function go(path) {
            var body = formData($(cfg.form)); body.path = path;
            $(cfg.list).innerHTML = '<div class="ue-fsempty">Loading…</div>';
            post('/update/server/browse', body).then(function (r) {
                if (r.success && r.entries) render(r);
                else Swal.fire({ icon: 'error', title: 'Could not read that folder', text: r.message });
            });
        }
        if ($(cfg.up)) $(cfg.up).addEventListener('click', function () {
            if (browsePath === '/' || browsePath === '') return;
            go(browsePath.replace(/\/[^/]*$/, '') || '/');
        });
        if ($(cfg.use)) $(cfg.use).addEventListener('click', function () {
            var f = document.querySelector('#' + cfg.form + ' [name="remoteDir"]');
            if (f) { f.value = browsePath; Swal.fire({ icon: 'success', title: 'Upload directory set', text: browsePath + ' — remember to Save.', timer: 2200, showConfirmButton: false }); }
        });
        if ($(cfg.newBtn)) $(cfg.newBtn).addEventListener('click', function () {
            Swal.fire({ title: 'New folder', input: 'text', inputPlaceholder: 'folder name', showCancelButton: true, confirmButtonText: 'Create' })
                .then(function (res) {
                    if (!res.isConfirmed || !res.value) return;
                    var body = formData($(cfg.form)); body.path = browsePath; body.name = res.value;
                    post('/update/server/mkdir', body).then(function (r) {
                        if (r.success && r.entries) render(r);
                        else Swal.fire({ icon: 'error', title: 'Could not create', text: r.message });
                    });
                });
        });
        if ($(cfg.testBtn)) $(cfg.testBtn).addEventListener('click', function () {
            var btn = this, box = $(cfg.testResult);
            btn.disabled = true; box.style.display = 'block'; box.innerHTML = 'Connecting…';
            post('/update/server/test', formData($(cfg.form))).then(function (r) {
                btn.disabled = false;
                var html = '<div><strong>' + (r.success ? '✅ ' : '❌ ') + esc(r.summary || r.message || '') + '</strong></div>';
                (r.stages || []).forEach(function (s) {
                    html += '<div class="ue-stage"><i class="bi ' + (s.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill') + '"></i>' +
                        esc(s.stage || '') + '<small>' + esc(s.detail || '') + '</small></div>';
                });
                box.innerHTML = html;
                if (r.listing) render(r.listing);
            }).catch(function () { btn.disabled = false; box.innerHTML = 'Test failed to run.'; });
        });
        if ($(cfg.verifyBtn)) $(cfg.verifyBtn).addEventListener('click', function () {
            var btn = this, box = $(cfg.testResult);
            btn.disabled = true; box.style.display = 'block'; box.innerHTML = 'Writing a test file and fetching it back over HTTPS…';
            post('/update/server/verify-url', formData($(cfg.form))).then(function (r) {
                btn.disabled = false;
                box.innerHTML = '<div><strong>' + (r.success ? '✅ ' : '⚠️ ') + esc(r.message || '') + '</strong></div>' +
                    (r.url ? '<div class="ue-hint mt-1">Probe: <code class="ue-code-url">' + esc(r.url) + '</code></div>' : '');
            }).catch(function () { btn.disabled = false; box.innerHTML = 'Verify failed to run.'; });
        });
    }
    initBrowser({ form: 'hostForm',  testBtn: 'btnTest',  verifyBtn: 'btnVerify',  testResult: 'testResult',  browser: 'hostBrowser',  path: 'browsePath',  list: 'browseList',  up: 'browseUp',  newBtn: 'browseNew',  use: 'browseUse' });
    initBrowser({ form: 'hostForm2', testBtn: 'btnTest2', verifyBtn: 'btnVerify2', testResult: 'testResult2', browser: 'hostBrowser2', path: 'browsePath2', list: 'browseList2', up: 'browseUp2', newBtn: 'browseNew2', use: 'browseUse2' });

    // ── Releases: delta toggle, status flip, delete ──
    document.querySelectorAll('.ue-deltatoggle').forEach(function (t) {
        t.addEventListener('click', function () { var d = $(t.dataset.t); if (d) d.style.display = d.style.display === 'none' ? 'block' : 'none'; });
    });
    document.querySelectorAll('.ue-rel-status').forEach(function (btn) {
        btn.addEventListener('click', function () {
            var row = btn.closest('.ue-relrow'), to = btn.dataset.to;
            post('/update/server/release-status', { _token: TOKEN, version: row.dataset.version, channel: row.dataset.channel, edition: row.dataset.edition, status: to })
                .then(function (r) { if (r.success) location.reload(); else Swal.fire({ icon: 'error', title: 'Failed', text: r.message }); });
        });
    });
    var DICON = { run: '<span class="ue-spin" style="display:inline-block;vertical-align:-2px"></span>', ok: '<i class="bi bi-check-circle-fill" style="color:var(--ue-ok)"></i>', warn: '<i class="bi bi-exclamation-triangle-fill" style="color:var(--ue-warn)"></i>', fail: '<i class="bi bi-x-circle-fill" style="color:var(--ue-fail)"></i>' };
    function streamTag(ed) {
        var m = { PRI: ['var(--ue-accent)', 18], SEC: ['var(--ue-sky)', 20], DRIVE: ['#0f9d58', 18] };
        if (!m[ed]) return '';
        var c = ed === 'SEC' ? 'var(--ue-sky)' : (ed === 'DRIVE' ? '#0f9d58' : 'var(--ue-accent)');
        return '<span style="font-size:.6rem;font-weight:800;background:color-mix(in srgb,' + c + ' ' + m[ed][1] + '%,transparent);color:' + m[ed][0] + ';padding:0 .3rem;border-radius:4px;margin-right:.35rem">' + ed + '</span>';
    }
    // Shared: stream an NDJSON action into a live Swal log, then offer Reload.
    // A 'run' event updates the CURRENT-STEP line (one spinner, top of the modal);
    // only completed steps (ok/warn/fail) become permanent rows — so no row ever
    // keeps spinning forever.
    function streamToSwalLog(path, body, workingTitle, doneTitle) {
        var rows = [], current = '';
        var render = function () {
            var h = '<div style="text-align:left;max-height:320px;overflow:auto;font-size:.85rem;line-height:1.5">';
            h += rows.map(function (l) { return '<div style="display:flex;gap:.5rem;padding:.15rem 0"><span style="flex:none;width:1.1rem;text-align:center">' + (DICON[l.icon] || '') + '</span><span>' + streamTag(l.ed) + esc(l.text) + '</span></div>'; }).join('');
            if (current) h += '<div style="display:flex;gap:.5rem;padding:.2rem 0;opacity:.9;font-style:italic"><span style="flex:none;width:1.1rem;text-align:center">' + DICON.run + '</span><span>' + esc(current) + '</span></div>';
            h += '</div>';
            return h;
        };
        Swal.fire({ title: workingTitle, html: render(), allowOutsideClick: false, allowEscapeKey: false, showConfirmButton: false });
        var repaint = function () { if (Swal.getHtmlContainer()) Swal.getHtmlContainer().innerHTML = render(); };
        var done = null, sawError = null;
        fetch(B + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ _token: TOKEN }, body)) })
            .then(function (resp) {
                if (!resp.body || !resp.body.getReader) { return resp.text().then(function (t) { t.split('\n').forEach(handle); finish(); }); }
                var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
                function pump() {
                    return reader.read().then(function (r) {
                        if (r.done) { if (buf.trim()) handle(buf); finish(); return; }
                        buf += dec.decode(r.value, { stream: true });
                        var lines = buf.split('\n'); buf = lines.pop();
                        lines.forEach(handle);
                        return pump();
                    });
                }
                return pump();
            }).catch(function (e) { current = ''; rows.push({ icon: 'fail', text: 'Connection lost: ' + (e && e.message ? e.message : 'error') }); repaint(); finish(); });
        function handle(line) {
            line = (line || '').trim(); if (!line) return;
            var ev; try { ev = JSON.parse(line); } catch (e) { return; }
            if (ev.type === 'progress') {
                if (ev.icon === 'run') { current = ev.text || ''; }               // transient current step
                else { rows.push({ icon: ev.icon || 'ok', text: ev.text || '', ed: ev.edition || '' }); }
                repaint();
            } else if (ev.type === 'error') { sawError = ev.message || 'Action failed.'; rows.push({ icon: 'fail', text: sawError }); repaint(); }
            else if (ev.type === 'done') { done = ev; }
        }
        function finish() {
            current = '';                                                          // stop the spinner line
            var okAll = done && done.success && !sawError;
            Swal.update({
                icon: okAll ? 'success' : 'warning',
                title: okAll ? doneTitle : 'Finished with issues',
                html: render() + '<div style="margin-top:.6rem"><button id="swReload" class="swal2-confirm swal2-styled">Reload</button></div>',
                showConfirmButton: false
            });
            var rb = document.getElementById('swReload');
            if (rb) rb.addEventListener('click', function () { location.reload(); });
        }
    }
    document.querySelectorAll('.ue-rel-del').forEach(function (btn) {
        btn.addEventListener('click', function () {
            var row = btn.closest('.ue-relrow');
            var ver = row.dataset.version, ch = row.dataset.channel, ed = row.dataset.edition;
            Swal.fire({
                icon: 'warning', title: 'Delete release ' + ver + ' (' + ed + ')?',
                html: 'Deletes its full bundle + deltas from <strong>every active host</strong> (primary, secondary), removes it from the catalogue, and rebuilds the signed index so it is no longer offered.',
                showCancelButton: true, confirmButtonText: 'Delete everywhere', confirmButtonColor: '#dc2626'
            }).then(function (res) {
                if (!res.isConfirmed) return;
                streamToSwalLog('/update/server/release-delete-stream', { version: ver, channel: ch, edition: ed }, 'Deleting ' + ver + '…', 'Deleted ' + ver);
            });
        });
    });
    if ($('btnFlushGdrive')) $('btnFlushGdrive').addEventListener('click', function () {
        Swal.fire({
            icon: 'warning', title: 'Flush Google Drive backups?',
            html: 'Deletes the accumulated full-bundle copies from Google Drive to reclaim space. The tiny signed <code>index.json</code> is kept as a last-resort fallback. Your FTP hosts and the release catalogue are untouched.',
            showCancelButton: true, confirmButtonText: 'Flush backups', confirmButtonColor: '#dc2626'
        }).then(function (res) {
            if (!res.isConfirmed) return;
            streamToSwalLog('/update/server/flush-gdrive-stream', {}, 'Flushing Drive backups…', 'Drive backups flushed');
        });
    });

    // ── Publish (OTA) — streams real-time progress via newline-delimited JSON ──
    var pub = $('btnPublish');
    // Completed steps only get a small static icon; the single spinner lives in the
    // header and tracks the CURRENT step (a "run" event), so there is never a wall
    // of spinning circles.
    var SICON = { ok: '<i class="bi bi-check-circle-fill"></i>', warn: '<i class="bi bi-exclamation-triangle-fill"></i>', fail: '<i class="bi bi-x-circle-fill"></i>' };
    function setCurrentStep(text, edition) {
        var t = $('pubProgTitle'); if (!t) return;
        t.innerHTML = (edition ? '<span class="edtag">' + esc(edition) + '</span>' : '') + esc(text || 'Working…');
    }
    function logRow(icon, text, edition) {
        var log = $('pubProgLog'); if (!log) return;
        var row = document.createElement('div');
        row.className = 'ue-prow ' + icon;
        var ed = edition ? '<span class="edtag">' + esc(edition) + '</span>' : '';
        row.innerHTML = '<span class="ue-pico">' + (SICON[icon] || '') + '</span><span>' + ed + esc(text || '') + '</span>';
        log.appendChild(row);
        log.scrollTop = log.scrollHeight;
    }
    // ── Site lock: while publishing, block the whole page (modal overlay) and warn
    //    the browser against accidental reload / tab-close / navigation. ──
    var pubBeforeUnload = null;
    function lockSite() {
        document.body.classList.add('ue-noscroll');
        $('pubModal').classList.add('on');
        if (!pubBeforeUnload) {
            pubBeforeUnload = function (e) { e.preventDefault(); e.returnValue = ''; return ''; };
            window.addEventListener('beforeunload', pubBeforeUnload);
        }
    }
    function releaseLock() {   // publishing finished — allow navigation again (overlay stays until closed)
        if (pubBeforeUnload) { window.removeEventListener('beforeunload', pubBeforeUnload); pubBeforeUnload = null; }
    }
    function closePubModal() {
        releaseLock();
        $('pubModal').classList.remove('on');
        document.body.classList.remove('ue-noscroll');
    }
    function runPublish(allowFlagged) {
        var box = $('publishResult'), log = $('pubProgLog');
        if ($('allowFlagged')) $('allowFlagged').value = allowFlagged ? '1' : '';
        box.style.display = 'none'; box.innerHTML = '';
        log.innerHTML = '';
        $('pubProgSpin').style.display = ''; $('pubProgTitle').textContent = 'Starting…';
        $('pubProgSub').textContent = ''; $('pubProgFoot').style.display = 'none'; $('pubProgFoot').innerHTML = '';
        var bar = $('pubModalBar'); if (bar) bar.className = 'ue-pubmodal-bar';
        var badge = $('pubModalBadge'); if (badge) { badge.className = 'ue-pubmodal-badge'; badge.innerHTML = '<i class="bi bi-lock-fill"></i> Locked'; }
        var note = $('pubModalNote'); if (note) note.style.display = '';
        pub.disabled = true;
        lockSite();   // open the locked modal + guard against navigation
        var body = formData($('publishForm'));
        var done = false, sawError = null, warnCount = 0;
        fetch(B + '/update/server/publish-stream', {
            method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
        }).then(function (resp) {
            if (!resp.body || !resp.body.getReader) {   // no streaming support → read whole then split
                return resp.text().then(function (t) { t.split('\n').forEach(handleLine); finish(); });
            }
            var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
            function pump() {
                return reader.read().then(function (r) {
                    if (r.done) { if (buf.trim()) handleLine(buf); finish(); return; }
                    buf += dec.decode(r.value, { stream: true });
                    var lines = buf.split('\n'); buf = lines.pop();
                    lines.forEach(handleLine);
                    return pump();
                });
            }
            return pump();
        }).catch(function (e) {
            logRow('fail', 'Connection lost: ' + (e && e.message ? e.message : 'stream error'));
            finish();
        });

        function handleLine(line) {
            line = (line || '').trim(); if (!line) return;
            var ev; try { ev = JSON.parse(line); } catch (e) { return; }
            if (ev.type === 'progress') {
                if (ev.icon === 'run') setCurrentStep(ev.text, ev.edition);   // header spinner tracks it
                else { if (ev.icon === 'warn') warnCount++; logRow(ev.icon || 'ok', ev.text, ev.edition); }  // completed → static row
            }
            else if (ev.type === 'error') { sawError = ev.message || 'Publish failed.'; logRow('fail', sawError); }
            else if (ev.type === 'done') { done = ev; }
        }
        function reloadBtn(label) {
            return '<button type="button" class="btn btn-accent btn-sm mt-2" onclick="location.reload()"><i class="bi bi-arrow-repeat"></i> ' + label + '</button>';
        }
        function closeBtn(label) {
            return '<button type="button" class="btn btn-outline-secondary btn-sm mt-2 ms-2" onclick="ppClosePubModal()"><i class="bi bi-x-lg"></i> ' + (label || 'Close') + '</button>';
        }
        function setState(kind) {   // finalise the modal chrome: 'done' | 'fail'
            var bar = $('pubModalBar'); if (bar) bar.className = 'ue-pubmodal-bar ' + kind;
            var note = $('pubModalNote'); if (note) note.style.display = 'none';
            var badge = $('pubModalBadge'); if (!badge) return;
            if (kind === 'done') { badge.className = 'ue-pubmodal-badge ok'; badge.innerHTML = '<i class="bi bi-check-circle-fill"></i> Done'; }
            else { badge.className = 'ue-pubmodal-badge fail'; badge.innerHTML = '<i class="bi bi-x-circle-fill"></i> Stopped'; }
        }
        function finish() {
            pub.disabled = false;
            releaseLock();   // publishing is over — stop guarding navigation (modal stays until closed)
            $('pubProgSpin').style.display = 'none';
            var foot = $('pubProgFoot'); foot.style.display = 'block';
            if (done && done.success) {
                setState('done');
                // Success WITH warnings (e.g. a backup host mirrored but its public URL
                // 404s): do NOT auto-reload — the owner needs to read the warnings. Only
                // a fully-clean publish reloads on its own, and even then not instantly.
                if (warnCount > 0) {
                    $('pubProgTitle').textContent = 'Published ' + (done.version || '') + ' — with ' + warnCount + ' warning' + (warnCount > 1 ? 's' : '');
                    $('pubProgSub').textContent = '';
                    foot.innerHTML = '<strong style="color:var(--ue-warn)"><i class="bi bi-exclamation-triangle-fill"></i> ' + esc(done.message || 'Done.') + '</strong>' +
                        '<div class="ue-hint mt-1">The release is published, but ' + warnCount + ' step(s) reported a warning above (usually a backup host whose upload directory doesn’t match its public URL). Fix those in <strong>Update host</strong>, then re-publish.</div>' +
                        reloadBtn('Reload (refresh Releases)') + closeBtn();
                    return;
                }
                $('pubProgTitle').textContent = 'Published ' + (done.version || '');
                $('pubProgSub').textContent = 'v' + (done.version || '');
                foot.innerHTML = '<strong style="color:var(--ue-ok)"><i class="bi bi-check-circle-fill"></i> ' + esc(done.message || 'Done.') + '</strong>' +
                    '<div class="ue-hint mt-1">All steps clean. Reloading to refresh the Releases list in a few seconds…</div>' +
                    reloadBtn('Reload now') + closeBtn();
                setTimeout(function () { location.reload(); }, 6000);
                return;
            }
            setState('fail');
            $('pubProgTitle').textContent = 'Publish did not complete';
            // A malware-scan block is recoverable: offer an explicit "review & publish anyway".
            if (sawError && /Publish blocked/i.test(sawError)) {
                foot.innerHTML = '<strong style="color:var(--ue-fail)"><i class="bi bi-shield-exclamation"></i> Blocked by the safety scan.</strong>' +
                    '<div class="ue-hint mt-1">Review the flagged patterns above. If you are certain they are safe, you can publish anyway.</div>' +
                    '<button type="button" class="btn btn-outline-secondary btn-sm mt-2" id="btnPublishAnyway"><i class="bi bi-exclamation-octagon"></i> I reviewed these — publish anyway</button>' + closeBtn();
                var ba = $('btnPublishAnyway');
                if (ba) ba.addEventListener('click', function () {
                    Swal.fire({ icon: 'warning', title: 'Publish flagged bundle?', text: 'Only do this if you have reviewed every flagged pattern and know they are safe.', showCancelButton: true, confirmButtonText: 'Publish anyway', confirmButtonColor: '#dc2626' })
                        .then(function (r) { if (r.isConfirmed) runPublish(true); });
                });
            } else {
                foot.innerHTML = '<strong style="color:var(--ue-fail)"><i class="bi bi-x-circle-fill"></i> ' + esc(sawError || 'The publish stopped before finishing. See the steps above.') + '</strong>' + closeBtn();
            }
        }
    }
    window.ppClosePubModal = closePubModal;   // referenced by the modal's Close buttons
    if (pub) pub.addEventListener('click', function () {
        var tgt = [];
        if ($('publishForm').querySelector('[name="toSecondary"]:checked')) tgt.push('secondary host');
        if ($('publishForm').querySelector('[name="toGdrive"]:checked')) tgt.push('Google Drive');
        Swal.fire({
            icon: 'question', title: 'Build & publish?',
            html: 'Builds the selected editions, uploads to the <strong>primary</strong>' +
                (tgt.length ? ' + ' + tgt.join(' + ') : '') + ', and refreshes the signed index.<br>' +
                '<span style="font-size:.85em;color:#94a3b8">Live progress opens in a locked window — the site stays locked until every step finishes.</span>',
            showCancelButton: true, confirmButtonText: 'Publish'
        }).then(function (res) { if (res.isConfirmed) runPublish(false); });
    });

    // ── Download (full installer / delta) — builds + streams, no upload ──
    if ($('btnDownload')) (function () {
        var vSel = $('dlVariant'), roleWrap = $('dlRoleWrap'), roleSel = $('dlRole'),
            mainWrap = $('dlMainWrap'), fromWrap = $('dlFromWrap'), fromSel = $('dlFrom'),
            fromEmpty = $('dlFromEmpty'), btn = $('btnDownload'), res = $('dlResult');
        function dlType() { var r = document.querySelector('#dlForm input[name="type"]:checked'); return r ? r.value : 'full'; }
        function refresh() {
            var isServer = vSel.value === 'server', isUpd = dlType() === 'updates';
            roleWrap.style.display = isServer ? '' : 'none';
            mainWrap.style.display = (isServer && roleSel.value === 'backup') ? '' : 'none';
            fromWrap.style.display = isUpd ? '' : 'none';
            if (isUpd) {
                var list = (window.PP_BASELINES || {})[vSel.value] || [];
                fromSel.innerHTML = list.map(function (b) { return '<option value="' + esc(b.version) + '">' + esc(b.version) + ' · ' + esc(b.source || '') + '</option>'; }).join('');
                var none = list.length === 0;
                fromSel.style.display = none ? 'none' : '';
                fromEmpty.style.display = none ? '' : 'none';
            }
        }
        vSel.addEventListener('change', refresh);
        roleSel.addEventListener('change', refresh);
        document.querySelectorAll('#dlForm input[name="type"]').forEach(function (r) { r.addEventListener('change', refresh); });
        refresh();

        btn.addEventListener('click', function () {
            var t = dlType();
            if (t === 'updates' && !fromSel.value) {
                Swal.fire({ icon: 'info', title: 'Pick a baseline', text: 'Build a Full installer of an earlier version first, then choose it here as the version to patch from.' });
                return;
            }
            var p = new URLSearchParams();
            p.set('variant', vSel.value);
            p.set('type', t);
            if (t === 'updates') p.set('from', fromSel.value);
            var relver = (($('dlRelver') || {}).value || '').trim();
            if (relver) p.set('relver', relver);
            if (vSel.value === 'server') {
                p.set('role', roleSel.value);
                if (roleSel.value === 'backup' && ($('dlMain').value || '').trim()) p.set('main', $('dlMain').value.trim());
            }
            var token = 't' + Date.now();
            p.set('token', token);
            res.style.display = 'block';
            res.innerHTML = 'Building your package…';
            lockUI('Building your package…', 'Packaging the ' + (t === 'updates' ? 'delta patch' : 'full installer') + '. Your download will start automatically — keep this tab open.');
            var f = document.createElement('iframe'); f.style.display = 'none'; f.src = window.PP_DL_URL + '?' + p.toString();
            document.body.appendChild(f);
            // The server sets pp_bundle_ready=<token> the moment streaming starts.
            var tries = 0, iv = setInterval(function () {
                tries++;
                if (document.cookie.indexOf('pp_bundle_ready=' + token) !== -1) {
                    clearInterval(iv);
                    document.cookie = 'pp_bundle_ready=; Max-Age=0; path=/';
                    ovMsg('✅ Your download has started. The file was removed from the server automatically.');
                    res.innerHTML = 'Download started. If nothing downloaded, check your browser’s pop-up/download settings.';
                    setTimeout(unlockUI, 1500);
                } else if (tries > 180) {   // ~3 min safety net
                    clearInterval(iv); unlockUI();
                    res.innerHTML = 'If nothing downloaded, check your browser’s pop-up/download settings, or try again.';
                }
            }, 1000);
        });
    })();
})();
</script>
<?php /* Instant [data-tip] tooltips for every control on this owner page. */ ?>
<?php include constant('BASE_PATH') . '/core/views/owner-ux.php'; ?>
</body>
</html>
