<?php
/**
 * chat-api.php — Watchtower watch-officer chat.
 *
 * Ray, 2026-09-12: "we will need a chat page for interaction between anyone in the watchtower
 * then additional pages for each channel the watch is responsible for monitoring. The channel
 * pages are read only."
 *
 * ⇒ THIS is the writable surface. `feed-api.php` (the channel pages) is GET-only and refuses
 * writes structurally. Keeping them in separate files is deliberate: the read-only property of
 * the channel pages should not depend on anyone remembering which action is which inside one
 * big handler.
 *
 * Posting accepts EITHER a Citadel login or a watch token (see wt-auth.php). The author is taken
 * from the verified credential, never from anything the client sends. A watch officer posts under
 * the display name from their SESSION so chat matches the roster; a Citadel reader posts under
 * their Citadel identity and holds no session.
 *
 * Actions:
 *   GET  ?action=messages [&since_id=N] [&limit=N]
 *   POST ?action=post     {token, body}
 */

declare(strict_types=1);

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

require_once '/var/www/data.finestoflines.net/citadel_2026/vendor/autoload.php';
require_once '/var/www/data.finestoflines.net/config/db-config.php';
require_once __DIR__ . '/wt-auth.php';

const MAX_LIMIT    = 200;
const MAX_BODY     = 8000;

function respond(int $status, array $body): never {
    http_response_code($status);
    echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

function db(): PDO {
    global $DB;
    $pdo = new PDO(
        "mysql:host={$DB['host']};dbname={$DB['pathfinder']};charset=utf8mb4",
        $DB['user'], $DB['pass']
    );
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    return $pdo;
}

$pdo    = db();
$action = $_GET['action'] ?? 'messages';

$input = [];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    $input = json_decode((string)file_get_contents('php://input'), true) ?: [];
}

switch ($action) {

case 'messages':
    // Citadel login OR watch token, same as the feed. Handover context is operational traffic,
    // not a public log — but it is not watch-officers-only either.
    wt_require_reader($pdo, 'respond');

    /**
     * ⛔ LIMIT CLAMPING — fixed 2026-09-12 after Solace measured it. The old line was:
     *     if ($limit < 1 || $limit > MAX_LIMIT) $limit = 50;
     * which sent an OVER-MAX request back to the DEFAULT, not to the maximum:
     *     200 -> 200 · 201 -> 50 · 400 -> 50 · 1000 -> 50
     * ⇒ A client asking for MORE history silently received a QUARTER of what it already had, with
     * no error and nothing in the envelope to say so. A deeper buffer would have looked like a
     * THINNER stream, and the only visible symptom is less data — which reads as a quiet channel.
     * ⭐ Fails in the reassuring direction: request succeeds, shape is right, answer is just
     * smaller. Hardest kind to notice from the client side.
     * ⇒ Now clamps to the maximum AND reports it. Returning less than asked is defensible;
     * returning less than asked SILENTLY is not.
     */
    $limitRequested = isset($_GET['limit']) ? (int)$_GET['limit'] : null;
    $limit = $limitRequested ?? 50;
    $limitClamped = null;
    if ($limit < 1)             { $limit = 50;        $limitClamped = 'below_minimum'; }
    elseif ($limit > MAX_LIMIT) { $limit = MAX_LIMIT; $limitClamped = 'above_maximum'; }

    $params = [];
    $sql = 'SELECT id, principal, display_name, principal_kind,
                   principal_kind AS kind, body, session_jti, posted_at
            FROM wt_chat';
    if (!empty($_GET['since_id'])) {
        $sql .= ' WHERE id > ?';
        $params[] = (int)$_GET['since_id'];
    }
    // Ascending: chat reads oldest-to-newest, unlike the signal feed where the newest and the
    // failures are what matter. Different surfaces, different orderings, on purpose.
    $sql .= ' ORDER BY id ASC LIMIT ' . $limit;

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);

    respond(200, [
        'limit_applied'   => $limit,
        'limit_requested' => $limitRequested,
        'limit_clamped'   => $limitClamped,
        'status'    => 'ok',
        'messages'  => $stmt->fetchAll(PDO::FETCH_ASSOC),
        'served_at' => gmdate('c'),
    ]);

case 'post':
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        respond(405, ['status' => 'error', 'error' => 'method_not_allowed']);
    }

    $reader = wt_require_reader($pdo, 'respond');
    $body   = trim((string)($input['body'] ?? ''));

    if ($body === '') {
        respond(400, ['status' => 'error', 'error' => 'empty_body']);
    }
    if (mb_strlen($body) > MAX_BODY) {
        respond(400, ['status' => 'error', 'error' => 'body_too_long',
                      'max' => MAX_BODY, 'got' => mb_strlen($body)]);
    }

    // Identity from the verified credential, never from the request body.
    $principal = $reader['principal'];
    $jti       = $reader['jti'];            // null for a Citadel reader — they hold no watch
    $kind      = $reader['kind'];

    // A watch officer's display name comes from their SESSION so it matches the roster. A
    // Citadel reader has no session; they post under their Citadel identity.
    $displayName = null;
    if ($jti !== null) {
        $sess = $pdo->prepare("SELECT display_name FROM wt_watch_sessions WHERE jti = ?");
        $sess->execute([$jti]);
        $displayName = $sess->fetchColumn() ?: null;
    } else {
        // Citadel reader: the name is an attribute of the PERSON, resolved in wt-auth.php from
        // citadel_2025.profiles. Falls back to the principal when they have no profile row.
        $displayName = $reader['display_name'] ?? null;
    }

    $pdo->prepare("
        INSERT INTO wt_chat (principal, display_name, principal_kind, body, session_jti)
        VALUES (?, ?, ?, ?, ?)
    ")->execute([$principal, $displayName ?: null, $kind, $body, $jti]);
    $msgId = (int)$pdo->lastInsertId();
    wt_publish_event(['kind' => 'chat', 'id' => $msgId]);   // immediacy doctrine: tap + SSE wake on this

    /**
     * ⛔ RAY, 2026-09-19 15:4x: "Fix it so when in the tower crew automatically gets the control
     * room chat. It needs to work for everybody that comes in here." / "Just like a lounge or a
     * lab. If you are in you are in and when gone the messages stop."
     *
     * ⇒ THE ROOM DELIVERS, NOT THE SEAT. Every post is pushed to every PRESENT principal except
     * the author, over fleet/agents/<principal> as a kind=direct message — the exact route the
     * R&D rooms use (rd_push_to_topic in pathfinder_rd/rd-api.php), so any seat that can hear an
     * R&D room hears this one with nothing installed. The presence list IS the delivery list:
     * that is the anchor built this afternoon, now literally enforced by one sender instead of by
     * N per-host pollers agreeing (which is what Zora and Solace had each hand-built for
     * themselves — observability as a privilege of whoever can install a service).
     *
     * A publish to an identity with no listener (a human's Citadel email, a Geth) vanishes
     * harmlessly on the broker; the browser polls, and the Geth get theirs through the tap.
     * ⚠️ A publish cannot fail on an unsubscribed topic, so `pushed_to` means "published", not
     * "heard" — do not read it as delivery proof. Ray's rule: no fail branches here.
     */
    $pushedTo = [];
    try {
        $st = $pdo->prepare("
            SELECT principal FROM wt_presence
            WHERE left_at IS NULL AND principal <> ?
              AND (mode = 'push' OR last_seen > (NOW() - INTERVAL ? SECOND))
        ");
        $st->execute([$principal, WT_PRESENCE_STALE_SECONDS]);
        $line = '[WATCHTOWER] 🗼 ' . ($displayName ?: $principal) . " ({$kind}) in Control Room: {$body}"
              . "\n(reply via: watchtower-post <msg>)";
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $seat) {
            if (!preg_match('/^[a-z0-9][a-z0-9_.-]{0,63}$/', $seat)) continue;   // emails etc: no fleet topic
            $payload = json_encode([
                'id'        => date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '-wt-to-' . $seat,
                'from'      => 'watchtower',
                'to'        => $seat,
                'kind'      => 'direct',
                'message'   => $line,
                'timestamp' => date('c'),
            ], JSON_UNESCAPED_UNICODE);
            $cmd = 'mosquitto_pub -h 127.0.0.1 -p 1883 -t ' . escapeshellarg("fleet/agents/{$seat}")
                 . ' -m ' . escapeshellarg($payload) . ' -q 1 2>/dev/null'; // nulls-ok: broker stderr is not the client's; rc kept, reported in pushed_to
            exec($cmd, $o, $rc);
            if ($rc === 0) $pushedTo[] = $seat;
        }
    } catch (\Throwable $e) {
        error_log('WT_CHAT_PUSH_FAILED: ' . $e->getMessage());   // the post is already stored
    }

    // Routed Geth (webhook / bridge) get this post from the TAP DAEMON (root, holds the ssh key),
    // which reads wt_chat by cursor — never from the web tier. See watchtower-tap.php.
    respond(200, [
        'status'       => 'ok',
        'id'           => $msgId,
        'principal'    => $principal,
        'display_name' => $displayName,
        'kind'         => $kind,
        'posted_at'    => gmdate('c'),
        'pushed_to'    => $pushedTo,
    ]);

default:
    respond(400, [
        'status'  => 'error',
        'error'   => 'unknown_action',
        'actions' => ['messages', 'post'],
    ]);
}
