<?php

declare(strict_types=1);

/**
 * DOS doors console (SPEC-DOSDOORS §13): the Scan with its teaching
 * validation, per-door policy (enable, ACS, order, debug), the typed
 * reset, live seats with force disconnect, the diagnostics ring, and
 * the manifest documentation right where a SysOp writes one.
 */

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

if (!\Atdt\Dos\Doors::on($settings)) {
    admin_page_start('DOS Doors', 'dosdoors');
    echo '<div class="card"><h2>DOS doors are switched off</h2>'
        . '<p>This board has the DOS doors system disabled, which is the shipped default. '
        . 'Nothing is served, no menu action is offered, and callers see nothing of it.</p>'
        . '<p>To turn it on: <a href="settings.php">Settings</a> → Access → check <strong>DOS doors</strong>.</p></div>';
    admin_page_end();
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    $id = (int)($_POST['id'] ?? 0);
    $door = $id > 0 ? $db->one('SELECT * FROM dos_doors WHERE id = ?', [$id]) : null;
    switch ((string)($_POST['action'] ?? '')) {
        case 'scan':
            $r = \Atdt\Dos\Doors::scan($db);
            $msg = [];
            if ($r['added'] !== []) {
                $msg[] = 'registered ' . implode(', ', $r['added']);
            }
            if ($r['updated'] !== []) {
                $msg[] = 'refreshed ' . implode(', ', $r['updated']);
            }
            if ($msg === []) {
                $msg[] = 'nothing new in dosdoors/';
            }
            if ($r['errors'] !== []) {
                $lines = [];
                foreach ($r['errors'] as $folder => $problems) {
                    $lines[] = $folder . ': ' . implode(' / ', $problems);
                }
                $msg[] = 'skipped ' . implode(' -- ', $lines);
            }
            flash_set($r['errors'] === [] ? 'ok' : 'err', 'Scan done: ' . implode('; ', $msg) . '.');
            admin_log('Scanned DOS doors');
            admin_redirect('dosdoors.php');

        case 'toggle':
            if ($door !== null) {
                $db->q('UPDATE dos_doors SET enabled = ? WHERE id = ?', [(int)$door['enabled'] === 1 ? 0 : 1, $id]);
                admin_log(((int)$door['enabled'] === 1 ? 'Disabled' : 'Enabled') . " DOS door {$door['slug']}");
            }
            admin_redirect('dosdoors.php');

        case 'debug':
            if ($door !== null) {
                $db->q('UPDATE dos_doors SET debug = ? WHERE id = ?', [(int)$door['debug'] === 1 ? 0 : 1, $id]);
                if ((int)$door['debug'] === 1) {
                    \Atdt\Dos\Diag::clear((string)$door['slug']);
                }
                admin_log(((int)$door['debug'] === 1 ? 'Stopped' : 'Started') . " diagnostics on DOS door {$door['slug']}");
            }
            admin_redirect('dosdoors.php');

        case 'save':
            if ($door !== null) {
                $db->q('UPDATE dos_doors SET acs = ? WHERE id = ?', [trim((string)($_POST['acs'] ?? '')), $id]);
                admin_log("Edited DOS door {$door['slug']}");
                flash_set('ok', "DOS door {$door['slug']} saved.");
            }
            admin_redirect('dosdoors.php');

        case 'move':
            if ($door !== null) {
                $dirn = (string)($_POST['dir'] ?? '') === 'up' ? -1 : 1;
                $swap = $db->one(
                    'SELECT * FROM dos_doors WHERE sort ' . ($dirn < 0 ? '<' : '>') . '?
                     ORDER BY sort ' . ($dirn < 0 ? 'DESC' : 'ASC') . ' LIMIT 1',
                    [(int)$door['sort']]
                );
                if ($swap !== null) {
                    $db->q('UPDATE dos_doors SET sort = ? WHERE id = ?', [(int)$swap['sort'], $id]);
                    $db->q('UPDATE dos_doors SET sort = ? WHERE id = ?', [(int)$door['sort'], (int)$swap['id']]);
                }
            }
            admin_redirect('dosdoors.php');

        case 'unregister':
            if ($door !== null) {
                $slug = (string)$door['slug'];
                $withData = (string)($_POST['with_data'] ?? '') === '1';
                $db->q('DELETE FROM dos_doors WHERE id = ?', [$id]);
                $db->q('DELETE FROM dos_sessions WHERE slug = ?', [$slug]);
                $db->q('DELETE FROM dos_locks WHERE slug = ?', [$slug]);
                if ($withData) {
                    \Atdt\Dos\Doors::purgeState($slug);
                    admin_log("Removed DOS door $slug AND its game state");
                    flash_set('ok', "DOS door $slug removed and its game state deleted - every player, "
                        . 'every score. A Scan re-installs it factory-new unless you also delete its '
                        . 'folder from dosdoors/ on the server.');
                } else {
                    admin_log("Removed DOS door $slug (game state kept)");
                    flash_set('ok', "DOS door $slug removed. Its game state is kept - a Scan re-registers "
                        . 'it exactly where it left off. To wipe the world too, remove it with the '
                        . 'delete checkbox ticked.');
                }
            }
            admin_redirect('dosdoors.php');

        case 'reset':
            if ($door !== null && (string)($_POST['confirm_slug'] ?? '') === (string)$door['slug']) {
                \Atdt\Dos\Doors::resetState((string)$door['slug']);
                $db->q('DELETE FROM dos_sessions WHERE slug = ?', [(string)$door['slug']]);
                $db->q('DELETE FROM dos_locks WHERE slug = ?', [(string)$door['slug']]);
                admin_log("Reset DOS door {$door['slug']} game state");
                flash_set('ok', "DOS door {$door['slug']}: game state wiped and reseeded from files/. "
                    . 'Every player starts over.');
            } else {
                flash_set('err', 'Reset not done: the typed name did not match the slug.');
            }
            admin_redirect('dosdoors.php');

        case 'kick':
            $sess = $db->one('SELECT * FROM dos_sessions WHERE id = ?', [(int)($_POST['sid'] ?? 0)]);
            if ($sess !== null) {
                \Atdt\Dos\Sessions::close($db, (string)$sess['token']);
                admin_log("Force-closed DOS door seat {$sess['id']} ({$sess['slug']})");
                flash_set('ok', 'Seat closed. The caller\'s door ends on its next call home (a few seconds).');
            }
            admin_redirect('dosdoors.php');
    }
    admin_redirect('dosdoors.php');
}

$doors = $db->all('SELECT * FROM dos_doors ORDER BY sort, id');
$edit = isset($_GET['edit']) ? $db->one('SELECT * FROM dos_doors WHERE id = ?', [(int)$_GET['edit']]) : null;
$live = $db->all(
    'SELECT s.*, u.handle FROM dos_sessions s LEFT JOIN users u ON u.id = s.user_id
     WHERE s.last_seen >= ? ORDER BY s.slug, s.node',
    [gmdate('Y-m-d\TH:i:s\Z', time() - \Atdt\Dos\Sessions::STALE_SECONDS)]
);

admin_page_start('DOS Doors', 'dosdoors');
?>

<div class="cols">
<div>
  <div class="card">
    <table class="list">
      <tr><th></th><th>Door</th><th>Slug</th><th>ACS</th><th>Nodes</th><th>Minutes/day</th><th>Status</th><th></th></tr>
      <?php if ($doors === []): ?><tr><td colspan="8" class="hint">No DOS doors registered. Drop a folder into <code>dosdoors/</code> and Scan. The shipped <code>testdoor</code> is a good first try.</td></tr><?php endif; ?>
      <?php foreach ($doors as $i => $d): $m = json_decode((string)$d['manifest'], true) ?: []; ?>
      <tr>
        <td class="nowrap">
          <form method="post" class="inline"><?= csrf_field() ?>
            <input type="hidden" name="action" value="move"><input type="hidden" name="id" value="<?= (int)$d['id'] ?>">
            <input type="hidden" name="dir" value="up">
            <button class="btn small secondary" <?= $i === 0 ? 'disabled' : '' ?>>↑</button></form>
          <form method="post" class="inline"><?= csrf_field() ?>
            <input type="hidden" name="action" value="move"><input type="hidden" name="id" value="<?= (int)$d['id'] ?>">
            <input type="hidden" name="dir" value="down">
            <button class="btn small secondary" <?= $i === count($doors) - 1 ? 'disabled' : '' ?>>↓</button></form>
        </td>
        <td><?= h((string)$d['title']) ?></td>
        <td><code><?= h((string)$d['slug']) ?></code></td>
        <td><code><?= h((string)$d['acs']) ?: '<span class="hint">open</span>' ?></code></td>
        <td><?= (int)$d['max_nodes'] ?></td>
        <td><?= (int)$d['minutes'] ?></td>
        <td>
          <?= (int)$d['enabled'] === 1 ? '<span class="ok">enabled</span>' : '<span class="warn">disabled</span>' ?>
          <?php if ((int)$d['debug'] === 1): ?> <span class="badge">debug</span><?php endif; ?>
          <?php if (empty($m['static'])): ?>
            <br><span class="warn" title="Without static patterns every file the door reads rides the wire on every open. Declare the .EXE and other never-changing files as static in dosdoor.json.">no statics</span>
          <?php endif; ?>
        </td>
        <td class="nowrap">
          <form method="post" class="inline"><?= csrf_field() ?>
            <input type="hidden" name="action" value="toggle"><input type="hidden" name="id" value="<?= (int)$d['id'] ?>">
            <button class="btn small secondary"><?= (int)$d['enabled'] === 1 ? 'Disable' : 'Enable' ?></button></form>
          <a href="dosdoors.php?edit=<?= (int)$d['id'] ?>">edit</a>
          <a href="dosmaint.php?door=<?= h((string)$d['slug']) ?>">maintain</a>
        </td>
      </tr>
      <?php endforeach; ?>
    </table>
  </div>

  <form method="post">
    <?= csrf_field() ?>
    <input type="hidden" name="action" value="scan">
    <button class="btn secondary">Scan dosdoors/ folder</button>
  </form>
  <p class="hint">Install a DOS door by dropping its folder into <code>dosdoors/</code> off the
  BBS root, next to <code>doors/</code>: the game's own files go in
  <code>dosdoors/&lt;slug&gt;/files/</code> and a <code>dosdoor.json</code> manifest sits beside
  them. Then Scan. A folder with a broken manifest is skipped and every problem is listed here,
  by name. To put a door in front of callers, add a menu item with the <code>DOSDOOR</code>
  action on the Menus page.</p>

  <?php if ($live !== []): ?>
  <div class="card">
    <h2>Seats in use</h2>
    <table class="list">
      <tr><th>Door</th><th>Node</th><th>Caller</th><th>Since</th><th>Last seen</th><th></th></tr>
      <?php foreach ($live as $s): ?>
      <tr>
        <td><code><?= h((string)$s['slug']) ?></code></td>
        <td><?= (int)$s['node'] ?></td>
        <td><?= h((string)($s['handle'] ?? '?')) ?></td>
        <td class="hint"><?= h((string)$s['started_at']) ?></td>
        <td class="hint"><?= h((string)$s['last_seen']) ?></td>
        <td>
          <form method="post" class="inline" onsubmit="return confirm('Close this seat? The caller is dropped back to the menu.');">
            <?= csrf_field() ?>
            <input type="hidden" name="action" value="kick">
            <input type="hidden" name="sid" value="<?= (int)$s['id'] ?>">
            <button class="btn small danger">Disconnect</button></form>
        </td>
      </tr>
      <?php endforeach; ?>
    </table>
    <p class="hint">A seat that stops calling home is swept automatically after
    <?= (int)\Atdt\Dos\Sessions::STALE_SECONDS ?> seconds - closed tabs clean up after themselves.</p>
  </div>
  <?php endif; ?>

  <div class="card">
    <h2>Writing a manifest</h2>
    <p class="hint">A <code>dosdoor.json</code> tells ATDT what to run and which files are which.
    The full story is in the SysOp manual; this is the shape of it:</p>
<pre>{
    "api": 1,
    "slug": "lord",                <span class="hint">folder name: 2-16 of a-z 0-9 _ -</span>
    "title": "Legend of the Red Dragon",
    "command": "LORD.EXE",         <span class="hint">what you would type at D:\&gt;</span>
    "dropfile": "DOOR.SYS",        <span class="hint">or DORINFO1.DEF or CHAIN.TXT</span>
    "static": ["*.EXE", "*.DAT"],  <span class="hint">files that never change: cached in the browser</span>
    "session": ["TEMP*.*"],        <span class="hint">per-node scratch files: never shared</span>
    "max_nodes": 4,                <span class="hint">1-32 simultaneous seats</span>
    "minutes": 45                  <span class="hint">5-480 per caller per day</span>
}</pre>
    <p class="hint"><strong>How to sort a door's files:</strong> anything the game only ever
    reads (the program itself, art, item lists) belongs in <code>static</code> - callers cache
    those and the door opens fast. Anything the game writes that is <em>its own scratch</em>
    (install temp files, per-node work files) belongs in <code>session</code>. Everything else -
    player records, scores, the shared world - needs <em>no pattern at all</em>: it lives on the
    server, every write is locked and serialized, and every node sees it live. When in doubt,
    declare less: a wrongly-shared file is slow but correct; a wrongly-static one serves stale
    bytes. Patterns are plain DOS names with <code>*</code> and <code>?</code>, matched against
    the file name anywhere in the door's directory tree.</p>
    <p class="hint"><strong>Multi-node doors</strong> must use DOS record locking (they all did -
    anything that ran under SHARE on a LAN). The lock calls arbitrate through the server, so two
    nodes can never tear the same player file, exactly like SHARE.EXE on a Novell drive.</p>
    <p class="hint"><strong>Batch files must stay relative.</strong> The game always runs at the
    root of drive D:. A batch file copied from a DOSBox-era collection often hardcodes paths
    (<code>C:\LORD\LORD.EXE</code>) or guards its launch line with <code>IF EXIST C:\...</code> -
    on this drive there is no C:, the guard is silently false, and the door ends without a word.
    Strip drive letters and guards: <code>LORD.EXE /N%1</code> is the whole idea. To see exactly
    what a live door's batch file says, set the manifest command to <code>TYPE START.BAT</code>
    temporarily and enter with diagnostics on: the mirror shows the file.</p>
  </div>
</div>

<div>
  <?php if ($edit !== null): $em = json_decode((string)$edit['manifest'], true) ?: []; ?>
  <div class="card drawer">
    <h2>Edit: <?= h((string)$edit['slug']) ?></h2>
    <form method="post">
      <?= csrf_field() ?>
      <input type="hidden" name="action" value="save">
      <input type="hidden" name="id" value="<?= (int)$edit['id'] ?>">
      <label>ACS <span class="hint">(blank = everyone; cheat-sheet on the Areas page)</span>
        <input type="text" name="acs" value="<?= h((string)$edit['acs']) ?>"></label>
      <button class="btn">Save door</button>
      <a class="btn secondary" href="dosdoors.php">Close</a>
    </form>
    <p class="hint">Title, nodes (<?= (int)$edit['max_nodes'] ?>), daily minutes
    (<?= (int)$edit['minutes'] ?>), drop file (<?= h((string)($em['dropfile'] ?? '?')) ?>) and the
    command line come from <code>dosdoor.json</code>; edit the file and Scan to change them.</p>
    <hr>
    <h2>Diagnostics</h2>
    <p class="hint">With debug on, the last 50 file operations and their round-trip times are
    kept for this door. A door that feels slow shows exactly which files are riding the wire;
    the usual fix is adding them to <code>static</code> in the manifest.</p>
    <p class="hint">While it is on, a SysOp who enters the door also sees the machine's own
    DOS screen mirrored in a corner of the terminal. A door that dies before it says anything
    over the COM port says why there - a bad path in its batch file, a missing file, a runtime
    error. After a failed start the mirror stays up for half a minute; click it to close.</p>
    <form method="post" class="inline"><?= csrf_field() ?>
      <input type="hidden" name="action" value="debug"><input type="hidden" name="id" value="<?= (int)$edit['id'] ?>">
      <button class="btn small secondary"><?= (int)$edit['debug'] === 1 ? 'Stop diagnostics' : 'Start diagnostics' ?></button>
    </form>
    <?php if ((int)$edit['debug'] === 1): $ring = \Atdt\Dos\Diag::ring((string)$edit['slug']); ?>
      <?php if ($ring === []): ?>
        <p class="hint">Nothing recorded yet. Enter the door and play a little.</p>
      <?php else: ?>
      <table class="list">
        <tr><th>At (UTC)</th><th>Op</th><th>ms</th></tr>
        <?php foreach (array_reverse($ring) as $r): ?>
        <tr><td><?= h((string)$r['at']) ?></td><td><code><?= h((string)$r['op']) ?></code></td>
            <td<?= (int)$r['ms'] > 500 ? ' class="warn"' : '' ?>><?= (int)$r['ms'] ?></td></tr>
        <?php endforeach; ?>
      </table>
      <?php endif; ?>
    <?php endif; ?>
    <hr>
    <h2>Maintenance</h2>
    <p class="hint">Run the door's config program, an installer, or a plain DOS prompt against
    the live game drive, right in this browser: no DOSBox, no copying files back and forth.
    The machine boots on <code>D:\</code> with its screen and keyboard facing you.</p>
    <p><a class="btn secondary" href="dosmaint.php?door=<?= h((string)$edit['slug']) ?>">Open maintenance</a></p>
    <hr>
    <h2>Files</h2>
    <p class="hint">The door's shipped <code>files/</code> beside the live game directory
    callers actually play in. A size mismatch on a file the game never writes (the .EXE
    above all) means the live copy is stale or a bad upload: fix <code>files/</code> and
    Reset. Live-only files are the game's own state; that is normal.</p>
    <?php
    $srcDir = \Atdt\Dos\Doors::root() . '/' . (string)$edit['slug'] . '/files';
    $liveDir = \Atdt\Dos\Doors::stateDir((string)$edit['slug']);
    $rows = [];
    foreach (glob($srcDir . '/*') ?: [] as $f) {
        if (is_dir($f)) {
            continue;
        }
        $bn = strtoupper(basename($f));
        $rows[$bn]['src'] = (int)filesize($f);
        if (str_ends_with($bn, '.BAT')) {
            // The seed normalizes .BAT line endings (an ASCII-mode FTP
            // upload strips the carriage returns and DOS then abandons
            // the batch after its first line), so the honest size to
            // compare against the live copy is the normalized one.
            $text = (string)@file_get_contents($f);
            $fixed = preg_replace('/\r?\n/', "\r\n", $text) ?? $text;
            if ($fixed !== $text) {
                $rows[$bn]['lf'] = 1;
                $rows[$bn]['src'] = strlen($fixed);
            }
        }
    }
    foreach (glob($liveDir . '/*') ?: [] as $f) {
        if (!is_dir($f)) {
            $rows[strtoupper(basename($f))]['live'] = (int)filesize($f);
        }
    }
    ksort($rows);
    ?>
    <table class="list">
      <tr><th>File</th><th>files/</th><th>Live</th><th></th></tr>
      <?php foreach ($rows as $name => $r): ?>
      <tr>
        <td><code><?= h($name) ?></code></td>
        <td><?= isset($r['src']) ? number_format($r['src']) : '<span class="hint">-</span>' ?></td>
        <td><?= isset($r['live']) ? number_format($r['live']) : '<span class="warn">missing</span>' ?></td>
        <td><?php if (isset($r['src'], $r['live']) && $r['src'] !== $r['live']): ?><span class="warn">differs</span><?php endif; ?><?php if (!empty($r['lf'])): ?> <span class="warn" title="This batch file has Unix line endings; DOS abandons such a batch after its first line. The seed repairs the live copy; fix files/ by re-uploading in binary mode.">unix line endings</span><?php endif; ?></td>
      </tr>
      <?php endforeach; ?>
      <?php if ($rows === []): ?><tr><td colspan="4" class="hint">Nothing here yet.</td></tr><?php endif; ?>
    </table>
    <hr>
    <h2>Reset game state</h2>
    <p class="hint">Wipes the live game directory and reseeds it from the door's
    <code>files/</code> - every player record, score and saved world is gone, and the door is
    factory-new. There is no undo. Type the slug to arm the button.</p>
    <form method="post">
      <?= csrf_field() ?>
      <input type="hidden" name="action" value="reset">
      <input type="hidden" name="id" value="<?= (int)$edit['id'] ?>">
      <label>Type <code><?= h((string)$edit['slug']) ?></code> to confirm
        <input type="text" name="confirm_slug" autocomplete="off"></label>
      <button class="btn danger">Reset game state</button>
    </form>
    <hr>
    <h2>Remove this door</h2>
    <p class="hint">Removing takes it off the board and closes its seats either way.
    <strong>Keep game state</strong> preserves the live world - players, scores, everything -
    and a Scan re-registers the door exactly where it left off (that pair is also a clean way
    to fix a broken registration). <strong>Delete game state</strong> wipes the world whole;
    with the folder still in <code>dosdoors/</code>, a Scan re-installs the door factory-new.
    Either way the door <em>re-installs on the next Scan</em> unless you also delete its
    folder from the server.</p>
    <form method="post" onsubmit="return confirm(this.with_data.checked
        ? 'Remove <?= h((string)$edit['slug']) ?> and DELETE ITS GAME STATE? Every player and score is gone. There is no undo.'
        : 'Remove <?= h((string)$edit['slug']) ?>? Its game state is kept for a re-scan.');">
      <?= csrf_field() ?>
      <input type="hidden" name="action" value="unregister">
      <input type="hidden" name="id" value="<?= (int)$edit['id'] ?>">
      <label><input type="checkbox" name="with_data" value="1">
        Also delete its game state (the live world, every player) - no undo</label>
      <button class="btn danger">Remove door</button>
    </form>
  </div>
  <?php else: ?>
  <div class="card">
    <h2>How a DOS door runs</h2>
    <p class="hint">Nothing DOS runs on this server. When a caller picks a DOS door, their own
    browser boots a small FreeDOS machine, the game's screen rides the caller's terminal, and
    the game's files are served from here one read and one write at a time - locked, jailed to
    the door's own directory, and counted. Closing the tab, losing the connection, or running
    out of daily minutes frees the seat by itself. The caller needs a current browser and
    nothing else.</p>
    <p class="hint">Callers can always leave a door with <code>Ctrl+]</code> pressed three
    times - the door sees a dropped carrier, exactly like a real hangup, and gets a moment
    to save.</p>
  </div>
  <?php endif; ?>
</div>
</div>

<?php admin_page_end(); ?>
