<?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]);

    respond(200, [
        'status'       => 'ok',
        'id'           => (int)$pdo->lastInsertId(),
        'principal'    => $principal,
        'display_name' => $displayName,
        'kind'         => $kind,
        'posted_at'    => gmdate('c'),
    ]);

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