<?php
/**
 * rd-api.php — R&D channel backend: topic-scoped chat history, presence.
 *
 * Solves the browser-localStorage sync gap: R&D history is persisted here,
 * keyed by topic, so switching device/location mid-conversation just means
 * re-fetching the same topic instead of losing continuity.
 *
 * Auth: X-RD-Identity header — a self-reported identity string, matching the
 * trust model the REST of pathfinder-mk2 already uses. This app has its own
 * lightweight local `pathfinder-auth` token (not Citadel JWT — checked the
 * live app before assuming otherwise) and an `identity` string the user picks
 * on first use. Requiring a stronger Citadel-JWT proof here, when nothing
 * else in this app has one, would be inconsistent security theater rather
 * than a real boundary. Crew CLI tools use the same header (see /usr/local/bin/rd).
 *
 * Actions (via ?action= query param):
 *   topics          GET   List all topics
 *   messages        GET   ?topic=X — full history for a topic, oldest first
 *   post            POST  {topic, body} — post a message, sender from header
 *   set_topic       POST  {topic, description?} — create topic if new, mark current
 *   join            POST  {topic} — mark caller present in that topic
 *   leave           POST  {} — clear caller's presence
 *   presence        GET   Who's currently in R&D and which topic
 */

header('Content-Type: application/json');

require_once '/var/www/data.finestoflines.net/config/db-config.php';

$identity = trim($_SERVER['HTTP_X_RD_IDENTITY'] ?? getallheaders()['X-RD-Identity'] ?? '');
// download is exempt from the identity gate: browser <a href> clicks cannot set
// custom headers, and an unguessable id-link is the credential — consistent with
// how every other file URL on this estate serves. All mutating actions stay gated.
$needs_identity = ($_GET['action'] ?? '') !== 'download';
if ($needs_identity && ($identity === '' || strlen($identity) > 32 || !preg_match('/^[a-zA-Z0-9_.@-]+$/', $identity))) {
    http_response_code(401);
    echo json_encode(['status' => 'error', 'error' => 'missing_or_invalid_identity']);
    exit;
}

try {
    $pdo = new PDO(
        "mysql:host={$DB['host']};dbname={$DB['pathfinder']};charset=utf8mb4",
        $DB['user'], $DB['pass'],
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
    );
} catch (PDOException $e) {
    http_response_code(500);
    echo json_encode(['status' => 'error', 'error' => 'db_connection_failed']);
    exit;
}

// Push a text to every identity presently joined to $topic, except $exclude.
// kind=direct ALWAYS — direct passes every mute layer by design, and per the
// ⛔ invariant at the post handler: notices must PUSH and must NOT be kind='chat'
// rows (a chat row would bump last_activity and reset the room's silence clock).
// Ungated by design (no seat allowlist — see the post handler's history note).
function rd_push_to_topic($pdo, $topic, $exclude, $text) {
    $pstmt = $pdo->prepare("SELECT identity FROM rd_presence WHERE topic = ? AND identity != ?");
    $pstmt->execute([$topic, $exclude]);
    $pushed = [];
    foreach ($pstmt->fetchAll() as $row) {
        $seat = $row['identity'];
        $payload = json_encode([
            'id' => date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '-rd-to-' . $seat,
            'from' => 'rd',
            'to' => $seat,
            'kind' => 'direct',
            'message' => $text,
            'timestamp' => date('c'),
        ]);
        $cmd = 'mosquitto_pub -h 127.0.0.1 -p 1883 -t ' . escapeshellarg("fleet/agents/{$seat}")
             . ' -m ' . escapeshellarg($payload) . ' -q 1 2>/dev/null';
        exec($cmd, $out, $rc);
        if ($rc === 0) $pushed[] = $seat;
    }
    return $pushed;
}

$action = $_GET['action'] ?? '';

switch ($action) {

    case 'topics':
        $stmt = $pdo->query("SELECT topic, description, created_by, created_at, is_current FROM rd_topics ORDER BY created_at DESC");
        echo json_encode(['status' => 'success', 'topics' => $stmt->fetchAll()]);
        break;

    case 'messages':
        $topic = trim($_GET['topic'] ?? '');
        if (!$topic) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'missing_topic']);
            exit;
        }
        $stmt = $pdo->prepare("SELECT id, sender, body, kind, created_at FROM rd_messages WHERE topic = ? ORDER BY created_at ASC, id ASC");
        $stmt->execute([$topic]);
        $msgs = $stmt->fetchAll();
        if ($msgs) {
            $ids = array_column($msgs, 'id');
            $ph = implode(',', array_fill(0, count($ids), '?'));
            $astmt = $pdo->prepare("SELECT id, message_id, filename, mime, size FROM rd_attachments WHERE message_id IN ($ph)");
            $astmt->execute($ids);
            $byMsg = [];
            foreach ($astmt->fetchAll() as $a) $byMsg[$a['message_id']][] = $a;
            foreach ($msgs as &$m) $m['attachments'] = $byMsg[$m['id']] ?? [];
            unset($m);
        }
        echo json_encode(['status' => 'success', 'topic' => $topic, 'messages' => $msgs]);
        break;

    case 'post':
        $input = json_decode(file_get_contents('php://input'), true);
        $topic = trim($input['topic'] ?? '');
        $body = trim($input['body'] ?? '');
        if (!$topic || !$body) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'topic_and_body_required']);
            exit;
        }
        $chk = $pdo->prepare("SELECT 1 FROM rd_topics WHERE topic = ?");
        $chk->execute([$topic]);
        if (!$chk->fetch()) {
            http_response_code(404);
            echo json_encode(['status' => 'error', 'error' => 'unknown_topic']);
            exit;
        }
        $stmt = $pdo->prepare("INSERT INTO rd_messages (topic, sender, body) VALUES (?, ?, ?)");
        $stmt->execute([$topic, $identity, $body]);
        $msg_id = $pdo->lastInsertId();

        // Push the post to every OTHER agent currently joined to this topic, as a
        // DIRECT message (kind/channel=direct) — direct passes the rd-mute flag by
        // design, so joined agents hear the room they are in and nothing else.
        // Presence IS the subscription list; rd leave / auto-release stops delivery.
        // Push to EVERY present identity except the sender — no seat allowlist.
        // (2026-08-30, B's finding: a hardcoded seat array meant any FUTURE seat —
        // Max is expected — would join, post, show in presence, and silently never
        // receive a push: joined-but-deaf, undetectable from inside. Removing the
        // gate engineers that CATEGORY out: a publish to an identity with no
        // listener vanishes harmlessly on the broker, so web/human identities cost
        // nothing, and any new seat hears the room the moment its listener exists,
        // zero registration required.)
        // ⛔ INVARIANT for any future notice pushed from other handlers (e.g. a
        // topic-split notice): it must ride THIS direct-push shape and must NOT be
        // inserted as kind='chat' — a chat row bumps last_activity and resets the
        // silence clock for everyone left in the room (the morning's announce-
        // you're-going-quiet trap in new clothes). Push eligibility and the clock
        // are deliberately decoupled: push is handler behavior, the clock is a
        // property of row KIND alone.
        // The payload id carries a CSPRNG suffix: two chat posts in the same second
        // are NORMAL here, and the listener names its incoming file from this id
        // (same-second basename collision, observed live 2026-08-30).
        $pushed = rd_push_to_topic($pdo, $topic, $identity,
            "[R&D '{$topic}'] {$identity}: {$body}\n(reply via: rd post <msg>)");
        echo json_encode(['status' => 'success', 'id' => $msg_id, 'pushed_to' => $pushed]);
        break;

    case 'set_topic':
        $input = json_decode(file_get_contents('php://input'), true);
        $topic = trim($input['topic'] ?? '');
        $description = trim($input['description'] ?? '');
        if (!$topic) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'missing_topic']);
            exit;
        }
        // NEW topic names reject shell-hazard characters (S's test-drive finding,
        // 2026-08-30: unquoted 'R&D SOFT OPEN' backgrounds `rd join R` and execs D —
        // not a bad-topic error and doesn't look like one; the "with quotes exactly"
        // invite warning was the mechanism pushing its fragility onto the human).
        // EXISTING names are grandfathered — rejecting them would break reopening.
        $exists_chk = $pdo->prepare("SELECT 1 FROM rd_topics WHERE topic = ?");
        $exists_chk->execute([$topic]);
        if (!$exists_chk->fetch() && preg_match('/[&`$"\'\\\\;|<>]/', $topic)) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'topic_name_shell_hazard',
                              'detail' => 'new topic names may not contain & ` $ " \' \\ ; | < > — they break unquoted CLI use']);
            exit;
        }
        // make_current defaults TRUE (back-compat: rd topic has always retuned the
        // office). Lab creation passes false — opening a lab must never yank the
        // office's viewscreen (Ray's walk-into-a-room model, 2026-08-30).
        $make_current = !array_key_exists('make_current', $input) || (bool)$input['make_current'];
        $pdo->prepare("INSERT INTO rd_topics (topic, description, created_by) VALUES (?, ?, ?)
                       ON DUPLICATE KEY UPDATE description = IF(VALUES(description) != '', VALUES(description), description)")
            ->execute([$topic, $description, $identity]);
        if ($make_current) {
            $old = $pdo->query("SELECT topic FROM rd_topics WHERE is_current = TRUE")->fetchColumn();
            if ($old !== $topic) {
                $pdo->exec("UPDATE rd_topics SET is_current = FALSE");
                $pdo->prepare("UPDATE rd_topics SET is_current = TRUE WHERE topic = ?")->execute([$topic]);
                // NOW-ON banners, both sides of the dial turn. These replace the
                // per-person move notices for office-follow joins (quiet=1): one
                // banner narrates the whole room's retune.
                $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                    ->execute([$topic, $identity, "📺 the office is NOW ON '{$topic}' (tuned by {$identity})"]);
                if ($old) {
                    $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                        ->execute([$old, $identity, "📺 the office moved on to '{$topic}' (tuned by {$identity})"]);
                }
            }
        }
        echo json_encode(['status' => 'success', 'topic' => $topic, 'is_current' => $make_current]);
        break;

    case 'join':
        $input = json_decode(file_get_contents('php://input'), true);
        $topic = trim($input['topic'] ?? '');
        if (!$topic) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'missing_topic']);
            exit;
        }
        $chk = $pdo->prepare("SELECT 1 FROM rd_topics WHERE topic = ?");
        $chk->execute([$topic]);
        if (!$chk->fetch()) {
            http_response_code(404);
            echo json_encode(['status' => 'error', 'error' => 'unknown_topic']);
            exit;
        }
        // Cross-topic hop detection (Ray's go, 2026-08-30 "I like it"): a dropdown
        // hop moved the presence row silently — the origin room's gradient lost the
        // hopper without a word (live-tested; same silent split that stranded Ray
        // alone in orrery). Notify the origin BOTH ways: a clock-safe system row for
        // the record, and a direct push for the attention (S's record-vs-attention
        // split — the system row alone reaches only whoever is reading).
        // quiet=1: an office-follow retune, not a walk. The set_topic NOW-ON banner
        // already narrates the whole office moving; per-person rows would be spam.
        // ONLY office-follow passes this — a deliberate walk always announces.
        $quiet = !empty($input['quiet']);
        $prev = $pdo->prepare("SELECT topic FROM rd_presence WHERE identity = ? AND topic IS NOT NULL");
        $prev->execute([$identity]);
        $prow = $prev->fetch();
        $from_topic = ($prow && $prow['topic'] !== $topic) ? $prow['topic'] : null;
        $pdo->prepare("INSERT INTO rd_presence (identity, topic, joined_at) VALUES (?, ?, NOW())
                       ON DUPLICATE KEY UPDATE topic = VALUES(topic), joined_at = VALUES(joined_at)")
            ->execute([$identity, $topic]);
        if ($quiet) {
            echo json_encode(['status' => 'success', 'identity' => $identity, 'topic' => $topic, 'quiet' => true]);
            break;
        }
        if ($from_topic !== null) {
            $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                ->execute([$from_topic, $identity, "{$identity} moved to '{$topic}'"]);
            rd_push_to_topic($pdo, $from_topic, $identity,
                "[R&D '{$from_topic}'] — {$identity} moved to '{$topic}' (room split notice)");
        }
        // System row: visible room update that CANNOT reset the silence clock
        // (last_activity filters kind='chat') and is not pushed to terminals.
        // Crew-set flair (rd flair) replaces the default line — Ray, 2026-08-30:
        // "the cron dismiss is kind of cold."
        // Same-topic rejoin (page refresh, tab return) announces NOTHING — the
        // curtain shouldn't re-open on someone who never left (Ray's 14:12/14:14
        // double-flair, caught in his own screenshot). Fresh arrivals and
        // cross-topic hops still announce.
        $was_here_already = ($prow && $prow['topic'] === $topic);
        if (!$was_here_already) {
            $f = $pdo->prepare("SELECT join_msg FROM rd_flair WHERE identity = ?");
            $f->execute([$identity]);
            $frow = $f->fetch();
            $joinNote = ($frow && $frow['join_msg']) ? $frow['join_msg'] : "{$identity} joined";
            $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                ->execute([$topic, $identity, $joinNote]);
        }
        echo json_encode(['status' => 'success', 'identity' => $identity, 'topic' => $topic]);
        break;

    case 'leave':
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $reason = trim($input['reason'] ?? '');
        $cur = $pdo->prepare("SELECT topic FROM rd_presence WHERE identity = ? AND topic IS NOT NULL");
        $cur->execute([$identity]);
        $row = $cur->fetch();
        $pdo->prepare("UPDATE rd_presence SET topic = NULL, joined_at = NULL WHERE identity = ?")
            ->execute([$identity]);
        if ($row) {
            $f = $pdo->prepare("SELECT leave_msg, auto_msg, brb_msg FROM rd_flair WHERE identity = ?");
            $f->execute([$identity]);
            $frow = $f->fetch() ?: [];
            if ($reason === 'auto') {
                $note = !empty($frow['auto_msg']) ? $frow['auto_msg'] : "{$identity} auto-released (topic silent past threshold)";
            } elseif ($reason === 'brb') {
                // BRB advisory (Ray, 2026-08-30): a planned cycle is not a departure.
                $note = !empty($frow['brb_msg']) ? $frow['brb_msg'] : "⏻ {$identity} is cycling — gone, but right back.";
            } else {
                $note = !empty($frow['leave_msg']) ? $frow['leave_msg'] : "{$identity} left";
            }
            $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                ->execute([$row['topic'], $identity, $note]);
        }
        echo json_encode(['status' => 'success']);
        break;

    case 'last_activity':
        // Age of the topic's newest message in SECONDS, computed server-side
        // (TIMESTAMPDIFF against the same clock that stamped created_at) so seat
        // timezones can never skew the auto-release math. null = no messages yet.
        $topic = trim($_GET['topic'] ?? '');
        if (!$topic) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'missing_topic']);
            exit;
        }
        $stmt = $pdo->prepare("SELECT TIMESTAMPDIFF(SECOND, MAX(created_at), NOW()) AS age_seconds FROM rd_messages WHERE topic = ? AND kind = 'chat'");
        $stmt->execute([$topic]);
        $row = $stmt->fetch();
        echo json_encode(['status' => 'success', 'topic' => $topic, 'age_seconds' => $row['age_seconds'] === null ? null : (int)$row['age_seconds']]);
        break;

    case 'upload':
        // Multipart POST: topic, file, caption?. No type limits (Ray, 2026-08-30:
        // CAD files, schematics — the Lab preserves for analysis). SECURITY POSTURE
        // that decision forces: originals land OUTSIDE the docroot
        // (/var/lib/rd-files/, www-data, no PHP engine anywhere near them) and are
        // served ONLY through the download streamer below — an uploaded .php can
        // never execute. Originals are NEVER resized (preservation beats bandwidth
        // here — the 1000px SMS doctrine deliberately does not apply).
        $topic = trim($_POST['topic'] ?? '');
        $caption = trim($_POST['caption'] ?? '');
        if (!$topic || empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'topic_and_file_required', 'php_err' => $_FILES['file']['error'] ?? 'nofile']);
            exit;
        }
        $chk = $pdo->prepare("SELECT 1 FROM rd_topics WHERE topic = ?");
        $chk->execute([$topic]);
        if (!$chk->fetch()) { http_response_code(404); echo json_encode(['status' => 'error', 'error' => 'unknown_topic']); exit; }
        $orig = $_FILES['file']['name'];
        $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($orig));
        $mime = mime_content_type($_FILES['file']['tmp_name']) ?: 'application/octet-stream';
        $size = (int)$_FILES['file']['size'];
        $slug = preg_replace('/[^A-Za-z0-9_-]/', '_', $topic);
        $dir = "/var/lib/rd-files/{$slug}";
        if (!is_dir($dir)) mkdir($dir, 0750, true);
        $dest = $dir . '/' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '-' . $safe;
        if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) {
            http_response_code(500);
            echo json_encode(['status' => 'error', 'error' => 'store_failed']);
            exit;
        }
        $body = $caption !== '' ? $caption : "[file] {$orig}";
        $pdo->prepare("INSERT INTO rd_messages (topic, sender, body) VALUES (?, ?, ?)")
            ->execute([$topic, $identity, $body]);
        $msg_id = $pdo->lastInsertId();
        $pdo->prepare("INSERT INTO rd_attachments (message_id, filename, mime, size, path) VALUES (?, ?, ?, ?, ?)")
            ->execute([$msg_id, $orig, $mime, $size, $dest]);
        $att_id = $pdo->lastInsertId();
        $kb = round($size / 1024);
        $dl = "https://data.finestoflines.net/pathfinder_rd/rd-api.php?action=download&id={$att_id}";
        rd_push_to_topic($pdo, $topic, $identity,
            "[R&D '{$topic}'] {$identity} posted file: {$orig} ({$kb} KB, {$mime})" . ($caption !== '' ? " — {$caption}" : '') . "\ndownload: {$dl}");
        echo json_encode(['status' => 'success', 'message_id' => $msg_id, 'attachment_id' => $att_id, 'download' => $dl]);
        break;

    case 'download':
        // Streamer — the ONLY route to stored files. Id-based lookup (no client
        // paths, no traversal surface). Images/PDF render inline for previews;
        // everything else forces download.
        $aid = (int)($_GET['id'] ?? 0);
        $astmt = $pdo->prepare("SELECT filename, mime, size, path FROM rd_attachments WHERE id = ?");
        $astmt->execute([$aid]);
        $att = $astmt->fetch();
        if (!$att || !is_file($att['path']) || strpos($att['path'], '/var/lib/rd-files/') !== 0) {
            http_response_code(404);
            echo json_encode(['status' => 'error', 'error' => 'not_found']);
            exit;
        }
        // &dl=1 forces a save dialog regardless of mime — the server's disposition
        // header is the only force-download that works cross-origin (the HTML
        // download attribute is silently ignored there; bit Ray 2026-08-30).
        $force_dl = !empty($_GET['dl']);
        // Ray's ask 2026-09-15: an attached HTML artifact should OPEN IN A TAB, not download.
        // ⛔ HTML rendered inline here runs on data.finestoflines.net — THE SAME ORIGIN AS
        //    citadel_2026 (the auth API). Inline HTML without a guard is stored XSS against
        //    the auth origin, uploadable by anyone who can post an attachment.
        // ✅ THE GUARD: 'Content-Security-Policy: sandbox' with NO allow-scripts and NO
        //    allow-same-origin. The browser then treats the document as a unique opaque
        //    origin and refuses to execute script in it. Static HTML/CSS renders fully;
        //    an uploaded page cannot read a token, a cookie, or anything else on this host.
        //    ⚠️ DO NOT add 'allow-scripts' to make some future artifact interactive — that
        //    hands script execution back on the auth origin. Serve it from a separate
        //    hostname instead if an artifact ever genuinely needs JS.
        $text_inline = in_array($att['mime'], ['text/html', 'text/plain', 'text/markdown'], true);
        $inline = !$force_dl && (strpos($att['mime'], 'image/') === 0
                                 || $att['mime'] === 'application/pdf'
                                 || $text_inline);
        header('Content-Type: ' . $att['mime']);
        header('Content-Length: ' . $att['size']);
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . str_replace('"', '', $att['filename']) . '"');
        header('X-Content-Type-Options: nosniff');
        if ($inline && $text_inline) {
            header('Content-Security-Policy: sandbox');
            header('Referrer-Policy: no-referrer');
        }
        readfile($att['path']);
        exit;

    case 'set_flair':
        // Per-crew custom entry/departure/auto-release lines. Empty string clears
        // back to the default. 200-char cap enforced by the column.
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $vals = [];
        foreach (['join_msg', 'leave_msg', 'auto_msg', 'brb_msg'] as $k) {
            if (array_key_exists($k, $input)) {
                $v = trim((string)$input[$k]);
                $vals[$k] = ($v === '') ? null : mb_substr($v, 0, 200);
            }
        }
        if (!$vals) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'error' => 'nothing_to_set']);
            exit;
        }
        $cols = array_keys($vals);
        $set = implode(', ', array_map(fn($c) => "$c = VALUES($c)", $cols));
        $sql = "INSERT INTO rd_flair (identity, " . implode(', ', $cols) . ") VALUES (?" . str_repeat(', ?', count($cols)) . ")
                ON DUPLICATE KEY UPDATE $set";
        $pdo->prepare($sql)->execute(array_merge([$identity], array_values($vals)));
        echo json_encode(['status' => 'success', 'set' => $cols]);
        break;

    case 'get_flair':
        $f = $pdo->prepare("SELECT join_msg, leave_msg, auto_msg, brb_msg FROM rd_flair WHERE identity = ?");
        $f->execute([$identity]);
        echo json_encode(['status' => 'success', 'flair' => $f->fetch() ?: new stdClass()]);
        break;

    case 'touch':
        // Web-client activity heartbeat (Ray's sticky-presence model, 2026-08-30):
        // sent each poll while the page is visible AND focused. last_active is set
        // by THIS action ONLY — CLI seats never call it, so their rows stay NULL
        // and remain the property of their own rd-autoleave-check cron (which also
        // owns clearing their local mute flag; a server-side sweep of a CLI row
        // would orphan that flag into a deaf-seat trap).
        $pdo->prepare("UPDATE rd_presence SET last_active = UNIX_TIMESTAMP() WHERE identity = ? AND topic IS NOT NULL")
            ->execute([$identity]);
        echo json_encode(['status' => 'success']);
        break;

    case 'presence':
        // Per-person idle sweep, inline (no cron needed — every polling client is
        // the sweeper): releases HEARTBEATING rows idle > 15m, with the seat's
        // auto-release flair. System rows never reset the chat clock, so this
        // cannot cascade (the anti-trap invariant from rd-autoleave-check).
        $idle = $pdo->query("SELECT identity, topic FROM rd_presence
                             WHERE topic IS NOT NULL AND last_active IS NOT NULL
                               AND last_active < UNIX_TIMESTAMP() - 900");
        foreach ($idle->fetchAll() as $ir) {
            $pdo->prepare("UPDATE rd_presence SET topic = NULL, joined_at = NULL, last_active = NULL WHERE identity = ?")
                ->execute([$ir['identity']]);
            $f = $pdo->prepare("SELECT auto_msg FROM rd_flair WHERE identity = ?");
            $f->execute([$ir['identity']]);
            $frow = $f->fetch() ?: [];
            $note = !empty($frow['auto_msg']) ? $frow['auto_msg'] : "{$ir['identity']} drifted idle — auto-released (15m)";
            $pdo->prepare("INSERT INTO rd_messages (topic, sender, body, kind) VALUES (?, ?, ?, 'system')")
                ->execute([$ir['topic'], $ir['identity'], $note]);
        }
        $stmt = $pdo->query("SELECT identity, topic, joined_at, last_active FROM rd_presence WHERE topic IS NOT NULL ORDER BY joined_at ASC");
        echo json_encode(['status' => 'success', 'present' => $stmt->fetchAll()]);
        break;

    default:
        http_response_code(400);
        echo json_encode(['status' => 'error', 'error' => 'unknown_action: ' . $action]);
        break;
}
