<?php

declare(strict_types=1);

/**
 * Themes (SPEC §11.6, reworked for 1.1.0). Shipped themes under themes/
 * are read-only and `default` is the board's permanent fallback -- never
 * editable, not even its art. The console duplicates a theme into
 * data/themes/<name> and edits the copy: strings (grouped INI with live
 * pipe-code preview) and art bindings -- each built-in screen (connect,
 * logon, goodbye) points at a piece from the board-wide art gallery
 * (data/art, the ANSI Editor page), or at nothing for the generated
 * look. Callers pick up data/themes overrides automatically (Theme).
 */

require __DIR__ . '/_lib.php';
admin_require();
$db = atdt_db();

use Atdt\Art;
use Atdt\Cp437;
use Atdt\Pipe;
use Atdt\Theme;

const NAME_RE = '/^[a-z0-9_-]{1,32}$/';

/**
 * Theme roots: shipped and editable. The default theme is never editable
 * (1.1.0) -- it is the fallback the board can always stand on, even when
 * a stray data/themes/default/ directory exists.
 *
 * @return array<string, array{dir: string, editable: bool}>
 */
function theme_list(): array
{
    $out = [];
    foreach (glob(ATDT_ROOT . '/themes/*', GLOB_ONLYDIR) ?: [] as $dir) {
        $out[basename($dir)] = ['dir' => $dir, 'editable' => false];
    }
    foreach (glob(ATDT_ROOT . '/data/themes/*', GLOB_ONLYDIR) ?: [] as $dir) {
        $name = basename($dir);
        $out[$name] = ['dir' => $dir, 'editable' => $name !== 'default'];
    }
    ksort($out);
    return $out;
}

/** Where a theme's file lives, honoring the data/themes override order. */
function theme_file(string $theme, string $rel): ?string
{
    foreach ([ATDT_ROOT . '/data/themes/', ATDT_ROOT . '/themes/'] as $root) {
        if (is_file($root . $theme . '/' . $rel)) {
            return $root . $theme . '/' . $rel;
        }
    }
    return null;
}

/** Pipe codes → HTML spans on the VGA palette (strings preview). */
function pipe_html(string $s): string
{
    static $vga = [
        '#000000', '#0000aa', '#00aa00', '#00aaaa', '#aa0000', '#aa00aa', '#aa5500', '#aaaaaa',
        '#555555', '#5555ff', '#55ff55', '#55ffff', '#ff5555', '#ff55ff', '#ffff55', '#ffffff',
    ];
    $out = '';
    $fg = 7;
    $bg = null;
    foreach (preg_split('/(\|\|)|(\|[0-9]{2})/', $s, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [] as $tok) {
        if ($tok === '||') {
            $out .= '|';
            continue;
        }
        if (preg_match('/^\|([0-9]{2})$/', $tok, $m)) {
            $n = (int)$m[1];
            if ($n <= 15) {
                $fg = $n;
            } elseif ($n <= 23) {
                $bg = $n - 16;
            }
            continue;
        }
        $style = 'color:' . $vga[$fg] . ($bg !== null ? ';background:' . $vga[$bg] : '');
        $out .= '<span style="' . $style . '">' . h($tok) . '</span>';
    }
    return $out;
}

// --- Ajax --------------------------------------------------------------------
if (($_GET['ajax'] ?? '') === 'pipe') {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['html' => pipe_html((string)($_GET['s'] ?? ''))]);
    exit;
}
if (($_GET['ajax'] ?? '') === 'piece') {
    // A gallery piece as board-decoded text, for the binding previews.
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['text' => Art::load((string)($_GET['f'] ?? ''))]);
    exit;
}

// --- Export (1.6.0): one theme as one file --------------------------------
// Strings, bindings, and every bound gallery piece ride together as JSON
// (art as base64), so SysOps can trade whole looks. Legacy per-theme
// art/*.ans folds in as pieces named <theme>-<slot>, gallery-style.
if (isset($_GET['export'])) {
    $t = (string)$_GET['export'];
    if ($t === 'default') {
        // Every board ships it; there is nothing to trade (1.6.0).
        flash_set('err', 'The default theme ships with every board - nothing to export. Duplicate it and export your copy.');
        admin_redirect('themes.php');
    }
    $themes = theme_list();
    $info = $themes[$t] ?? null;
    if ($info === null) {
        http_response_code(404);
        exit('No such theme.');
    }
    $theme = new Theme($t);
    $map = $theme->artMap();
    $legacy = [];
    foreach (Theme::BUILTIN_SLOTS as $slot) {
        if (isset($map[$slot])) {
            continue;
        }
        foreach ([
            ATDT_ROOT . '/data/themes/' . $t . '/art/' . $slot . '.ans',
            ATDT_ROOT . '/themes/' . $t . '/art/' . $slot . '.ans',
        ] as $f) {
            if (is_file($f)) {
                $pn = substr(preg_replace('/[^a-z0-9_-]+/', '-', strtolower($t . '-' . $slot)) ?? '', 0, 32);
                if (preg_match(Art::NAME_RE, $pn) === 1) {
                    $map[$slot] = $pn;
                    $legacy[$pn] = (string)file_get_contents($f);
                }
                break;
            }
        }
    }
    $art = [];
    foreach ($map as $slot => $piece) {
        $bytes = $legacy[$piece] ?? Art::bytes($piece);
        if ($bytes !== null && $bytes !== '') {
            $art[$piece] = base64_encode($bytes);
        }
    }
    $stringsFile = theme_file($t, 'strings.ini');
    $metaRaw = theme_file($t, 'theme.json');
    $meta = $metaRaw === null ? [] : (json_decode((string)@file_get_contents($metaRaw), true) ?: []);
    header('Content-Type: application/json; charset=utf-8');
    header('Content-Disposition: attachment; filename="' . $t . '.atdt-theme.json"');
    echo json_encode([
        'atdt_theme' => 1,
        'name' => $t,
        'exported' => now_utc(),
        'board' => (string)(atdt_settings()->get('board_name', 'ATDT') ?? 'ATDT'),
        'meta' => (object)(is_array($meta) ? $meta : []),
        'strings_ini' => $stringsFile === null ? '' : (string)file_get_contents($stringsFile),
        'art_map' => (object)$map,
        'art' => (object)$art,
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    exit;
}

// --- POST actions ------------------------------------------------------------
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    $t = (string)($_POST['t'] ?? '');
    $themes = theme_list();
    switch ((string)($_POST['action'] ?? '')) {
        case 'duplicate':
            $src = $themes[$t] ?? null;
            $new = strtolower(trim((string)($_POST['new'] ?? '')));
            if ($src === null || !preg_match(NAME_RE, $new)) {
                flash_set('err', 'New theme name: lowercase letters, digits, _ or -.');
                admin_redirect('themes.php');
            }
            if (isset($themes[$new]) || is_dir(ATDT_ROOT . '/data/themes/' . $new)) {
                flash_set('err', "Theme \"$new\" already exists.");
                admin_redirect('themes.php');
            }
            $dst = ATDT_ROOT . '/data/themes/' . $new;
            if (!@mkdir($dst, 0755, true)) {
                flash_set('err', 'Could not create data/themes. Check permissions.');
                admin_redirect('themes.php');
            }
            foreach (['strings.ini', 'theme.json', 'art.json'] as $rel) {
                $from = theme_file($t, $rel);
                if ($from !== null) {
                    @copy($from, $dst . '/' . $rel);
                }
            }
            // Stamp the copy's identity so the list reads honestly.
            $meta = json_decode((string)@file_get_contents($dst . '/theme.json'), true);
            $meta = is_array($meta) ? $meta : [];
            $meta['name'] = $new;
            $meta['based_on'] = $t;
            @file_put_contents($dst . '/theme.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
            admin_log("Duplicated theme $t as $new");
            flash_set('ok', "Theme \"$new\" created. It is editable now.");
            admin_redirect('themes.php?t=' . rawurlencode($new));

        case 'savestrings':
            $info = $themes[$t] ?? null;
            if ($info === null || !$info['editable']) {
                flash_set('err', $t === 'default'
                    ? 'The default theme is the board\'s fallback and is never editable. Duplicate it first.'
                    : 'Shipped themes are read-only. Duplicate first.');
                admin_redirect('themes.php');
            }
            // Merge over what the file already holds; a partial POST must
            // never truncate a theme. Keys that are INI reserved words would
            // silently kill the whole file on the next parse; refuse them.
            // 1.7.0: the editor lists the full engine catalog, so the file
            // only carries CUSTOMIZED keys. A key posted empty returns to
            // inherited (removed from the file); keys not posted at all are
            // left exactly as they were.
            $reserved = ['on', 'off', 'true', 'false', 'yes', 'no', 'none', 'null'];
            $sections = [];
            $existing = theme_file($t, 'strings.ini');
            foreach ($existing === null ? [] : (@parse_ini_file($existing, true, INI_SCANNER_NORMAL) ?: []) as $sec => $pairs) {
                if (is_array($pairs)) {
                    $sections[(string)$sec] = $pairs;
                }
            }
            foreach ((array)($_POST['s'] ?? []) as $section => $pairs) {
                if (!preg_match('/^[a-z0-9_]{1,24}$/', (string)$section) || !is_array($pairs)) {
                    continue;
                }
                foreach ($pairs as $k => $v) {
                    if (!preg_match('/^[a-z0-9_]{1,32}$/', (string)$k) || in_array((string)$k, $reserved, true)) {
                        continue;
                    }
                    $v = str_replace(["\r", "\n"], '', (string)$v);
                    if ($v === '') {
                        unset($sections[(string)$section][(string)$k]);
                        continue;
                    }
                    $sections[(string)$section][(string)$k] = $v;
                }
            }
            $ini = "; ATDT theme strings, edited from the SysOp console.\n"
                . "; Only customized keys live here; everything else inherits the\n"
                . "; default theme. Pipe codes: |00-|15 foreground, |16-|23\n"
                . "; background, %XX variables.\n";
            foreach ($sections as $section => $pairs) {
                if ((array)$pairs === []) {
                    continue;
                }
                $ini .= "\n[$section]\n";
                foreach ((array)$pairs as $k => $v) {
                    $ini .= $k . ' = "' . str_replace('"', '\"', (string)$v) . "\"\n";
                }
            }
            if (@file_put_contents($info['dir'] . '/strings.ini', $ini) === false) {
                flash_set('err', 'Could not write strings.ini. Check permissions.');
            } else {
                admin_log("Edited strings for theme $t");
                flash_set('ok', 'Strings saved. Callers see them on their next screen.');
            }
            admin_redirect('themes.php?t=' . rawurlencode($t) . '&tab=strings');

        case 'saveart':
            // Bind the built-in screens to gallery pieces (1.1.0).
            $info = $themes[$t] ?? null;
            if ($info === null || !$info['editable']) {
                flash_set('err', $t === 'default'
                    ? 'The default theme is the board\'s fallback and is never editable. Duplicate it first.'
                    : 'Shipped themes are read-only. Duplicate first.');
                admin_redirect('themes.php');
            }
            $pieces = Art::pieces();
            $map = [];
            foreach (Theme::BUILTIN_SLOTS as $slot) {
                $pick = (string)($_POST['bind'][$slot] ?? '');
                if ($pick !== '' && in_array($pick, $pieces, true)) {
                    $map[$slot] = $pick;
                }
            }
            if ($map === []) {
                @unlink($info['dir'] . '/art.json');
            } elseif (@file_put_contents($info['dir'] . '/art.json', json_encode($map, JSON_PRETTY_PRINT)) === false) {
                flash_set('err', 'Could not write art.json. Check permissions.');
                admin_redirect('themes.php?t=' . rawurlencode($t));
            }
            admin_log("Bound art for theme $t: " . ($map === [] ? 'all generated' : json_encode($map)));
            flash_set('ok', 'Art bindings saved. Callers see them on their next screen.');
            admin_redirect('themes.php?t=' . rawurlencode($t));

        case 'importtheme':
            // One file in, a whole look installed (1.6.0). Art pieces land
            // in the gallery; a name already on the shelf is reused when
            // the bytes match and renamed (-2, -3, ...) when they differ,
            // with the theme's bindings remapped to follow.
            $up = $_FILES['bundle'] ?? null;
            if (!is_array($up) || (int)($up['error'] ?? 1) !== UPLOAD_ERR_OK
                || (int)($up['size'] ?? 0) > 8 * 1024 * 1024) {
                flash_set('err', 'Upload a .atdt-theme.json file (8 MB ceiling).');
                admin_redirect('themes.php');
            }
            $j = json_decode((string)@file_get_contents((string)$up['tmp_name']), true);
            if (!is_array($j) || (int)($j['atdt_theme'] ?? 0) !== 1 || !is_string($j['strings_ini'] ?? null)) {
                flash_set('err', 'That file is not an ATDT theme export.');
                admin_redirect('themes.php');
            }
            $name = substr(preg_replace('/[^a-z0-9_-]+/', '-', strtolower(trim((string)($j['name'] ?? '')))) ?? '', 0, 24);
            $name = trim($name, '-');
            if (!preg_match(NAME_RE, $name)) {
                $name = 'imported';
            }
            $base = $name;
            $n = 2;
            while (isset($themes[$name]) || is_dir(ATDT_ROOT . '/data/themes/' . $name)
                || is_dir(ATDT_ROOT . '/themes/' . $name)) {
                $name = $base . '-' . $n;
                $n++;
            }
            // Art first, so the bindings can follow any renames.
            $landed = [];
            $renamed = 0;
            $shared = 0;
            foreach ((array)($j['art'] ?? []) as $piece => $b64) {
                $piece = (string)$piece;
                if (preg_match(Art::NAME_RE, $piece) !== 1 || !is_string($b64)) {
                    continue;
                }
                $bytes = base64_decode($b64, true);
                if ($bytes === false || $bytes === '' || strlen($bytes) > Art::MAX_BYTES) {
                    continue;
                }
                $target = $piece;
                if (Art::exists($target)) {
                    if (Art::bytes($target) === $bytes) {
                        $landed[$piece] = $target; // identical: share the shelf copy
                        $shared++;
                        continue;
                    }
                    $i = 2;
                    do {
                        $suffix = '-' . $i;
                        $target = substr($piece, 0, 32 - strlen($suffix)) . $suffix;
                        $i++;
                    } while (Art::exists($target));
                    $renamed++;
                }
                if (Art::save($target, $bytes)) {
                    $landed[$piece] = $target;
                }
            }
            $dst = ATDT_ROOT . '/data/themes/' . $name;
            if (!@mkdir($dst, 0755, true)) {
                flash_set('err', 'Could not create data/themes. Check permissions.');
                admin_redirect('themes.php');
            }
            $ini = str_replace("\0", '', (string)$j['strings_ini']);
            @file_put_contents($dst . '/strings.ini', substr($ini, 0, 512 * 1024));
            $map = [];
            foreach ((array)($j['art_map'] ?? []) as $slot => $piece) {
                if (in_array((string)$slot, Theme::BUILTIN_SLOTS, true) && isset($landed[(string)$piece])) {
                    $map[(string)$slot] = $landed[(string)$piece];
                }
            }
            if ($map !== []) {
                @file_put_contents($dst . '/art.json', json_encode($map, JSON_PRETTY_PRINT));
            }
            $meta = is_array($j['meta'] ?? null) ? $j['meta'] : [];
            $meta['name'] = $name;
            $meta['imported_from'] = (string)($j['board'] ?? '');
            @file_put_contents($dst . '/theme.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
            admin_log("Imported theme $name (" . count($landed) . ' art piece(s), ' . $renamed . ' renamed)');
            flash_set('ok', "Theme \"$name\" imported with " . count($landed) . ' art piece(s)'
                . ($shared > 0 ? ", $shared already on the shelf" : '')
                . ($renamed > 0 ? ", $renamed renamed to avoid a clash" : '') . '.');
            admin_redirect('themes.php?t=' . rawurlencode($name));

        case 'deltheme':
            $info = $themes[$t] ?? null;
            if ($info === null || !$info['editable']) {
                flash_set('err', $t === 'default'
                    ? 'The default theme is the board\'s fallback and cannot be removed.'
                    : 'Shipped themes cannot be deleted.');
                admin_redirect('themes.php');
            }
            $inUse = (int)$db->val('SELECT COUNT(*) FROM users WHERE theme = ?', [$t]);
            if ($inUse > 0 && !is_dir(ATDT_ROOT . '/themes/' . $t)) {
                // Deleting a data override of a shipped theme just reverts it;
                // deleting a standalone theme still worn by callers is refused.
                flash_set('err', "$inUse caller(s) still wear \"$t\". Reassign them first (Users page).");
                admin_redirect('themes.php');
            }
            if ((string)atdt_settings()->get('default_theme', 'default') === $t) {
                flash_set('err', "\"$t\" is the board's default theme (Settings). Pick another default first.");
                admin_redirect('themes.php');
            }
            foreach (glob($info['dir'] . '/art/*') ?: [] as $f) {
                @unlink($f); // legacy per-theme art, pre-gallery
            }
            @rmdir($info['dir'] . '/art');
            foreach (glob($info['dir'] . '/*') ?: [] as $f) {
                @unlink($f);
            }
            @rmdir($info['dir']);
            admin_log("Deleted theme copy $t");
            flash_set('ok', "Theme \"$t\" removed" . (is_dir(ATDT_ROOT . '/themes/' . $t) ? '. The shipped version shows through again.' : '.'));
            admin_redirect('themes.php');
    }
    admin_redirect('themes.php');
}

// --- Render ------------------------------------------------------------------
$themes = theme_list();
$current = (string)($_GET['t'] ?? '');
$currentInfo = $themes[$current] ?? null;
$tab = (string)($_GET['tab'] ?? 'art');

// The full string catalog (1.7.0): every key the engine knows, in the
// baked-in order, merged with the theme's own file. A copy made under an
// older release shows the newer keys too, marked inherited, so any theme
// can be customized completely in one place. File keys the engine no
// longer recognizes ride along at the end; nothing a SysOp wrote goes
// invisible. Each entry: ['value', 'default', 'custom'].
$strings = [];
if ($currentInfo !== null) {
    $ini = theme_file($current, 'strings.ini');
    $parsed = $ini === null ? [] : (@parse_ini_file($ini, true, INI_SCANNER_NORMAL) ?: []);
    $file = [];
    foreach ($parsed as $section => $pairs) {
        if (is_array($pairs)) {
            foreach ($pairs as $k => $v) {
                $file[(string)$section][(string)$k] = (string)$v;
            }
        }
    }
    foreach (Theme::catalog() as $addr => $def) {
        [$sec, $key] = explode('.', $addr, 2);
        $custom = isset($file[$sec]) && array_key_exists($key, $file[$sec]);
        $strings[$sec][$key] = [
            'value' => $custom ? $file[$sec][$key] : '',
            'default' => $def,
            'custom' => $custom,
        ];
        unset($file[$sec][$key]);
    }
    foreach ($file as $sec => $pairs) {
        foreach ((array)$pairs as $key => $v) {
            $strings[(string)$sec][(string)$key] = ['value' => (string)$v, 'default' => null, 'custom' => true];
        }
    }
}
$bindings = $currentInfo === null ? [] : (new Theme($current))->artMap();
$pieces = Art::pieces();
$userCounts = [];
foreach ($db->all('SELECT theme, COUNT(*) AS n FROM users GROUP BY theme') as $r) {
    $userCounts[(string)$r['theme']] = (int)$r['n'];
}
$defaultTheme = (string)atdt_settings()->get('default_theme', 'default');
$slotBlurb = [
    'connect' => 'the pre-login matrix screen',
    'logon' => 'the welcome screen after login',
    'goodbye' => 'the log-off farewell',
];

admin_page_start('Themes', 'themes');
?>

<div class="cols">
<div>
  <div class="card">
    <table class="list">
      <tr><th>Theme</th><th>Kind</th><th>Worn by</th><th></th></tr>
      <?php foreach ($themes as $name => $info): ?>
      <tr<?= $name === $current ? ' class="pending"' : '' ?>>
        <td><a href="themes.php?t=<?= rawurlencode($name) ?>"><?= h($name) ?></a><?= $name === $defaultTheme ? ' <span class="hint">(board default)</span>' : '' ?></td>
        <td><?= $name === 'default' ? 'fallback' : ($info['editable'] ? 'editable copy' : 'shipped') ?></td>
        <td><?= $userCounts[$name] ?? 0 ?></td>
        <td class="nowrap">
          <?php if ($name !== 'default'): ?>
          <a class="btn small secondary" href="themes.php?export=<?= rawurlencode($name) ?>">Export</a>
          <?php endif; ?>
          <?php if (!$info['editable']): ?>
          <form method="post" class="row inline"><?= csrf_field() ?>
            <input type="hidden" name="action" value="duplicate"><input type="hidden" name="t" value="<?= h($name) ?>">
            <input type="text" name="new" size="10" placeholder="copy name">
            <button class="btn small secondary">Duplicate</button></form>
          <?php else: ?>
          <form method="post" class="inline" onsubmit="return confirm('Remove the editable copy <?= h($name) ?>?');">
            <?= csrf_field() ?>
            <input type="hidden" name="action" value="deltheme"><input type="hidden" name="t" value="<?= h($name) ?>">
            <button class="btn small danger">Remove</button></form>
          <?php endif; ?>
        </td>
      </tr>
      <?php endforeach; ?>
    </table>
    <p class="hint">Duplicating a theme makes an editable copy under <code>data/themes/</code>.
    The <code>default</code> theme is the board's permanent fallback and is never editable -
    to change anything, duplicate it and set your copy as the board default in Settings.</p>
  </div>

  <div class="card">
    <h2>Import a theme</h2>
    <form method="post" enctype="multipart/form-data">
      <?= csrf_field() ?>
      <input type="hidden" name="action" value="importtheme">
      <input type="file" name="bundle" accept=".json,application/json" required>
      <button class="btn">Import</button>
    </form>
    <p class="hint">One <code>.atdt-theme.json</code> file, made by the Export
    button here on any ATDT board. Strings, art bindings, and the bound
    gallery pieces all ride in the one file; imported art lands in the
    <a href="gallery.php">Gallery</a>. A piece whose name is already on the
    shelf is reused when the bytes match and renamed when they differ, so
    nothing on your board is ever overwritten. Trade looks freely.</p>
  </div>

  <aside class="cheat">
    <strong>How themes work</strong>
    <p>A theme is the board's whole look: every prompt and message a caller
    reads (<em>Strings</em>) plus which <em>art gallery</em> pieces dress the
    big moments - the <code>connect</code> screen, the <code>logon</code>
    welcome, and the <code>goodbye</code>. Draw or import pieces on the
    ANSI Editor page; bind them here.</p>
    <p><strong>The editing loop:</strong> <em>Duplicate</em> the default,
    edit your copy, then pick it as the board default in Settings - every
    caller without a personal theme gets it at once. Individual callers can
    still be given a different look from the Users page.</p>
    <p>Anything you leave out falls back to the built-in text and generated
    screens, so a half-edited theme always works.</p>
  </aside>
</div>

<div>
<?php if ($currentInfo === null): ?>
  <div class="card"><p class="hint">Pick a theme on the left.</p></div>
<?php else: ?>
  <div class="card">
    <h2><?= h($current) ?>
      <span class="hint"><?= $current === 'default' ? 'the fallback; duplicate to edit' : ($currentInfo['editable'] ? 'editable' : 'read-only; duplicate to edit') ?></span></h2>
    <p>
      <a class="btn small<?= $tab === 'art' ? '' : ' secondary' ?>" href="themes.php?t=<?= rawurlencode($current) ?>&amp;tab=art">Art</a>
      <a class="btn small<?= $tab === 'strings' ? '' : ' secondary' ?>" href="themes.php?t=<?= rawurlencode($current) ?>&amp;tab=strings">Strings</a>
    </p>

    <?php if ($tab === 'art'): ?>
      <?php if ($pieces === []): ?>
        <p class="hint">The art gallery is empty. Draw or import <code>.ans</code> pieces on the
        <a href="editor.php">ANSI Editor</a> page first, then bind them here.</p>
      <?php endif; ?>
      <form method="post">
        <?= csrf_field() ?>
        <input type="hidden" name="action" value="saveart">
        <input type="hidden" name="t" value="<?= h($current) ?>">
        <div class="thumbgrid" id="bindgrid">
        <?php foreach (Theme::BUILTIN_SLOTS as $slot): ?>
          <figure>
            <canvas width="320" height="200" data-preview="<?= h($slot) ?>"></canvas>
            <figcaption>
              <code><?= h($slot) ?></code> <span class="hint"><?= h($slotBlurb[$slot] ?? '') ?></span><br>
              <?php if ($currentInfo['editable']): ?>
              <select name="bind[<?= h($slot) ?>]" data-slot="<?= h($slot) ?>">
                <option value="">(generated default)</option>
                <?php foreach ($pieces as $p): ?>
                <option value="<?= h($p) ?>"<?= ($bindings[$slot] ?? '') === $p ? ' selected' : '' ?>><?= h($p) ?></option>
                <?php endforeach; ?>
              </select>
              <?php else: ?>
              <span class="hint"><?= ($bindings[$slot] ?? '') !== '' ? h($bindings[$slot]) : '(generated default)' ?></span>
              <?php endif; ?>
            </figcaption>
          </figure>
        <?php endforeach; ?>
        </div>
        <?php if ($currentInfo['editable']): ?>
        <button class="btn">Save art bindings</button>
        <p class="hint">Menu backdrops and bulletin art are not per-theme: pick those
        straight from the gallery on the Menus and Community pages.</p>
        <?php else: ?>
        <p class="hint">The <?= $current === 'default' ? 'fallback' : 'shipped' ?> theme always uses
        its generated screens. Duplicate it to bind gallery art.</p>
        <?php endif; ?>
      </form>

    <?php else: ?>
      <?php if (!$currentInfo['editable']): ?>
        <p class="hint">Read-only view. Duplicate the theme to change strings.</p>
      <?php else: ?>
        <p class="hint">Every string the board knows is listed, including keys added
        after this theme was copied. A dimmed row is inherited from the default
        theme: type in it to customize, empty it to go back to inheriting.</p>
      <?php endif; ?>
      <form method="post">
        <?= csrf_field() ?>
        <input type="hidden" name="action" value="savestrings">
        <input type="hidden" name="t" value="<?= h($current) ?>">
        <div class="stringsedit">
        <?php foreach ($strings as $section => $pairs): ?>
          <details<?= $section === array_key_first($strings) ? ' open' : '' ?>>
            <summary>[<?= h((string)$section) ?>]</summary>
            <?php foreach ($pairs as $k => $row): ?>
            <?php
                $custom = (bool)$row['custom'];
                $def = $row['default'];
                $effective = $custom ? (string)$row['value'] : (string)($def ?? '');
            ?>
            <label class="stringrow">
              <code><?= h((string)$k) ?><?= $def === null ? ' <span class="hint" title="This key is not used by this release; it stays in your file untouched.">?</span>' : '' ?></code>
              <input type="text" name="s[<?= h((string)$section) ?>][<?= h((string)$k) ?>]"
                     value="<?= h($currentInfo['editable'] ? (string)$row['value'] : $effective) ?>"
                     <?= $currentInfo['editable'] ? '' : 'readonly' ?>
                     <?= $custom || !$currentInfo['editable'] ? '' : ' placeholder="' . h((string)$def) . '"' ?>
                     <?= $def !== null ? ' data-def="' . h((string)$def) . '"' : '' ?>
                     class="pipesrc" autocomplete="off">
              <span class="pipeview"><?= pipe_html($effective) ?></span>
              <?php if (!$custom && $currentInfo['editable']): ?><span class="hint">inherited</span><?php endif; ?>
            </label>
            <?php endforeach; ?>
          </details>
        <?php endforeach; ?>
        </div>
        <?php if ($currentInfo['editable']): ?><button class="btn">Save strings</button><?php endif; ?>
      </form>
    <?php endif; ?>
  </div>
<?php endif; ?>
</div>
</div>

<script src="../assets/cp437render.js"></script>
<script src="../assets/msged.js"></script>
<script src="../assets/term.js"></script>
<script>
(function () {
  // Binding previews: the real client parser paints the chosen gallery
  // piece into each slot's canvas, live as the pick changes.
  var grid = document.getElementById('bindgrid');
  if (grid) {
    var initial = <?= json_encode((object)$bindings) ?>;
    var paint = function (cv, piece) {
      var g = cv.getContext('2d');
      g.fillStyle = '#000';
      g.fillRect(0, 0, cv.width, cv.height);
      if (!piece) { return; }
      fetch('themes.php?ajax=piece&f=' + encodeURIComponent(piece), {credentials: 'same-origin'})
        .then(function (r) { return r.json(); })
        .then(function (j) {
          if (!j.text) return;
          var buf = new Cp437Render.ScreenBuffer();
          var painter = new Cp437Render.CanvasPainter(cv, buf);
          var parser = new AtdtTerm.AnsiParser(buf, {});
          Cp437Render.loadFont('../assets/fonts/Web437_IBM_VGA_8x16.woff', function (fam) {
            parser.feed(j.text);
            painter.setFont(fam);
          });
        }).catch(function () {});
    };
    grid.querySelectorAll('canvas[data-preview]').forEach(function (cv) {
      paint(cv, initial[cv.getAttribute('data-preview')] || '');
    });
    grid.querySelectorAll('select[data-slot]').forEach(function (sel) {
      sel.addEventListener('change', function () {
        var cv = grid.querySelector('canvas[data-preview="' + sel.getAttribute('data-slot') + '"]');
        if (cv) { paint(cv, sel.value); }
      });
    });
  }
  // Live pipe preview while editing strings.
  document.querySelectorAll('input.pipesrc').forEach(function (inp) {
    var view = inp.parentElement.querySelector('.pipeview');
    var timer = null;
    inp.addEventListener('input', function () {
      clearTimeout(timer);
      timer = setTimeout(function () {
        // An emptied field returns to inheriting: preview the default.
        var s = inp.value !== '' ? inp.value : (inp.dataset.def || '');
        fetch('themes.php?ajax=pipe&s=' + encodeURIComponent(s), {credentials: 'same-origin'})
          .then(function (r) { return r.json(); })
          .then(function (j) { view.innerHTML = j.html; })
          .catch(function () {});
      }, 250);
    });
  });
})();
</script>

<?php admin_page_end(); ?>
