<?php
/**
 * feed-api.php — Watchtower's READ-ONLY signal feed.
 *
 * 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."
 *
 * ⛔ READ-ONLY IS ENFORCED HERE, NOT ASSUMED. This file contains no INSERT, UPDATE or DELETE
 * against wt_signal, and it refuses any method other than GET. A UI that only renders a feed
 * is a design; an endpoint that cannot write is a control.
 *
 * Requires a valid watch token (same RS256 verification as watch-api.php) — the feed is the
 * watch's working surface, so standing a watch is the price of reading it.
 *
 * Actions:
 * Auth: a Citadel login OR a watch token (wt-auth.php). Reading does NOT require standing a watch.
 *
 *   GET ?action=feed
 *       &channel=agents|comms|telemetry|dispatch|alerts|other   Ray's tabs; omit for ALL
 *       &collapse=1        fold R&D fan-out copies into one row, with a `copies` count
 *       &class=automated|human|lifecycle|unknown                secondary axis
 *       &source=argus|bell|burn|dispatch|outpost
 *       &failures_only=1  &since_id=N  &limit=N (max 200; over-max CLAMPS and says so)
 *   GET ?action=channels    — per-tab counts incl. empty tabs (drives the nav)
 *   GET ?action=parity      — migration aid: what the clone has seen. Counts, never a verdict.
 *
 * ⚠️ `channel` and `collapse` were absent from this block for three hours after being added —
 * the two parameters the UI depends on most. A docblock that describes a stale surface is a false
 * map with the authority of documentation: a reader does not learn the parameter exists, and has
 * no way to discover the omission. Same class as a cured defect keeping its warning.
 */

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';

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

const PUB_KEY_PATH = __DIR__ . '/watch-verify-key.pub.pem';
const ISSUER       = 'watchtower';
const AUDIENCE     = 'watch-officer';
const MAX_LIMIT    = 200;

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;
}

// ⛔ Structural read-only guard. Refuse anything that could carry a write intent before we
// even look at what was asked for.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
    respond(405, [
        'status' => 'error',
        'error'  => 'read_only',
        'message' => 'The signal feed is read-only. Channel pages cannot write.',
    ]);
}

$pdo    = db();
$action = $_GET['action'] ?? 'feed';
// Citadel login OR watch token. Reading the feed is not conditional on standing a watch —
// see wt-auth.php for why that was wrong and what Ray held it to.
$reader = wt_require_reader($pdo, 'respond');

switch ($action) {

case 'feed':
    $where  = [];
    $params = [];

    if (!empty($_GET['class'])) {
        $c = (string)$_GET['class'];
        if (!in_array($c, ['automated','human','lifecycle','unknown'], true)) {
            respond(400, ['status'=>'error','error'=>'bad_class']);
        }
        $where[] = 'signal_class = ?'; $params[] = $c;
    }
    if (!empty($_GET['source'])) {
        $where[] = 'signal_source = ?'; $params[] = substr((string)$_GET['source'], 0, 32);
    }
    // Ray's tab taxonomy (screenshot 2026-09-12): agents/comms/telemetry/dispatch/alerts/other.
    // 'all' is the absence of this filter, not a value — a tab that filtered for a literal
    // 'all' would silently exclude anything not mapped to it.
    if (!empty($_GET['channel']) && $_GET['channel'] !== 'all') {
        $ch = (string)$_GET['channel'];
        if (!in_array($ch, ['agents','comms','telemetry','dispatch','alerts','rd','other'], true)) {
            respond(400, ['status'=>'error','error'=>'bad_channel',
                          'valid'=>['all','agents','comms','telemetry','dispatch','alerts','rd','other']]);
        }
        $where[] = 'channel = ?'; $params[] = $ch;
    } else {
        /**
         * R&D lab relay is EXCLUDED BY DEFAULT, including from `all`. Ray, 2026-09-12: lab
         * conversation "will never flag anything and shouldn't need monitoring", and the room
         * itself already holds a durable record.
         *
         * ⛔ EXCLUDED FROM THE VIEW, NOT FROM THE STORE. The rows are still captured and still
         * queryable with `&channel=rd` — his EARLIER requirement was a full redundant duplicate
         * of the main Pathfinder channel, and dropping the traffic would break that silently
         * while looking identical. A quieter feed and a lossy one are indistinguishable from the
         * outside, which is exactly why this is a filter and not a capture rule.
         * ⚠️ `all` means "everything the watch is responsible for", NOT "every row" — stated
         * because those differ now and a reader could reasonably assume otherwise.
         */
        $where[] = 'channel <> ?'; $params[] = 'rd';
    }
    if (!empty($_GET['failures_only'])) {
        $where[] = 'is_failure = 1';
    }
    if (!empty($_GET['since_id'])) {
        $where[] = 'id > ?'; $params[] = (int)$_GET['since_id'];
    }

    /**
     * ⛔ 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'; }

    /**
     * &collapse=1 folds fan-out copies into one row.
     *
     * R&D room posts are relayed to every seat, so the same message arrives once per recipient
     * — measured at 1.7x across a live window, up to 3 copies of a single message. Raw, a watch
     * officer sees it repeatedly.
     *
     * ⛔ Collapsing happens HERE, on read. Every copy remains stored, because they are real
     * distinct deliveries and the parallel-run confirmation must be able to see that fleet
     * carried them. `copies` is returned so a collapsed row still declares how many there were
     * — a fold that hid its own arity would be the same reassuring-summary failure the rest of
     * this API avoids.
     */
    $collapse = !empty($_GET['collapse']);

    $cols = 'id, topic, msg_id, sender, recipient, body, sent_at,
             signal_class, signal_source, channel, is_failure, captured_at, dedup_key';   // dedup_key: the fold key, so a push client folds on the SAME rule (Solace 09-20)

    if ($collapse) {
        // Keep the newest row of each fan-out group. Rows with no dedup_key (unparseable
        // payloads, bare state topics) have nothing to group on and are never folded.
        $sql = "SELECT MAX(id) AS id, MAX(topic) AS topic, MAX(msg_id) AS msg_id,
                       MAX(sender) AS sender, MAX(recipient) AS recipient,
                       MAX(body) AS body, MAX(sent_at) AS sent_at,
                       MAX(signal_class) AS signal_class, MAX(signal_source) AS signal_source,
                       MAX(channel) AS channel, MAX(is_failure) AS is_failure,
                       MAX(captured_at) AS captured_at, MAX(dedup_key) AS dedup_key,
                       COUNT(*) AS copies
                FROM wt_signal";
    } else {
        $sql = "SELECT $cols, 1 AS copies FROM wt_signal";
    }
    if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
    if ($collapse) {
        $sql .= ' GROUP BY COALESCE(dedup_key, CONCAT("id:", id))';
    }
    /**
     * Pure reverse-chronological. Newest first, nothing else.
     *
     * ⛔ THIS USED TO BE `is_failure DESC, id DESC` — failures sorted to the top — and removing
     * that is a STRENGTHENING, not a relaxation. Solace's argument, which is better than the one
     * I built it on:
     *
     *   A PIN IS NOT A SORT POSITION. `is_failure DESC` puts a failure at ROW 1, which means it
     *   scrolls away the instant anyone scrolls. That is EXACTLY what the burn gauge already had —
     *   top of the feed, hourly, for 57 hours, and five active seats read none of it. "First" was
     *   never the requirement. UNMISSABLE WHEREVER YOU ARE was.
     *
     * ⇒ The sort could never have satisfied the thing it was added for. The pin now lives in the
     * UI as a `position: sticky` band above the stream, which survives scrolling; the stream
     * underneath is honestly chronological. `is_failure` is still returned on every row so the
     * client can lift them.
     */
    $sql .= $collapse
        ? ' ORDER BY MAX(id) DESC LIMIT ' . $limit
        : ' ORDER BY id DESC LIMIT ' . $limit;

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // Report the denominator alongside the sample. A page showing 50 rows out of 4,000 and a
    // page showing 50 rows out of 50 look identical otherwise, and only one of them is complete.
    $countSql = ($collapse
        ? 'SELECT COUNT(DISTINCT COALESCE(dedup_key, CONCAT("id:", id))) FROM wt_signal'
        : 'SELECT COUNT(*) FROM wt_signal')
        . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
    $cstmt = $pdo->prepare($countSql);
    $cstmt->execute($params);

    respond(200, [
        'status'    => 'ok',
        'read_only' => true,
        'limit_applied'   => $limit,
        'limit_requested' => $limitRequested,
        'limit_clamped'   => $limitClamped,
        'collapsed' => $collapse,
        'count'     => count($rows),
        'total_matching' => (int)$cstmt->fetchColumn(),
        'messages'  => $rows,
        'served_at' => gmdate('c'),
    ]);

case 'channels':
    // Drives the per-channel nav. Counts come from the data, so a channel that exists but is
    // empty still appears — an absent nav entry would read as "no such channel" rather than
    // "nothing has arrived yet", which are different facts.
    // Ray's tabs, counted from the data.
    $raw = $pdo->query("
        SELECT channel, COUNT(*) AS total, SUM(is_failure) AS failures,
               MAX(captured_at) AS latest
        FROM wt_signal GROUP BY channel
    ")->fetchAll(PDO::FETCH_ASSOC);
    $byChannel = [];
    foreach ($raw as $r) $byChannel[$r['channel']] = $r;

    // ⛔ EVERY tab is emitted, including ones with zero rows. An absent nav entry reads as
    // "no such channel"; a present one with total:0 reads as "nothing has arrived yet". Those
    // are different facts and the UI must be able to tell them apart — especially for `comms`,
    // whose topic mapping is INFERRED and has never been exercised by real traffic, so a
    // permanently-empty comms tab is a signal that the inference was wrong rather than that
    // the channel is quiet.
    $tabs = [];
    foreach (['agents','comms','telemetry','dispatch','alerts','other'] as $ch) {
        $tabs[] = [
            'channel'  => $ch,
            'total'    => isset($byChannel[$ch]) ? (int)$byChannel[$ch]['total'] : 0,
            'failures' => isset($byChannel[$ch]) ? (int)$byChannel[$ch]['failures'] : 0,
            'latest'   => $byChannel[$ch]['latest'] ?? null,
            'mapping'  => ($ch === 'comms') ? 'inferred-untested' : 'observed',
        ];
    }

    // Secondary axis, kept because it answers the migration question rather than the UI one.
    $byClass = $pdo->query("
        SELECT signal_class, COALESCE(signal_source,'-') AS source,
               COUNT(*) AS total, SUM(is_failure) AS failures, MAX(captured_at) AS latest
        FROM wt_signal
        GROUP BY signal_class, signal_source
        ORDER BY signal_class, source
    ")->fetchAll(PDO::FETCH_ASSOC);

    respond(200, [
        'status'    => 'ok',
        'read_only' => true,
        'channels'  => $tabs,
        'by_class'  => $byClass,
        'served_at' => gmdate('c'),
    ]);

case 'parity':
    /**
     * MIGRATION AID, and the reason the clone runs in parallel at all.
     *
     * Ray: clone the fleet channel, confirm everything is working, THEN clean fleet. This is
     * what "confirm" is allowed to mean. It reports what the clone has actually seen, per
     * class and per source, with the capture's own start time — so the question "is the watch
     * feed complete?" is answered with counts rather than with an impression.
     *
     * ⚠️ It deliberately does NOT return a green/red verdict. A boolean here would be the
     * reassuring-null failure: it would read as proof of completeness when all it could ever
     * prove is that the capture wrote rows. Completeness is a judgement made by comparing
     * these counts against what fleet actually carried, and that comparison is a human's.
     */
    $first = $pdo->query("SELECT MIN(captured_at) FROM wt_signal")->fetchColumn();
    $last  = $pdo->query("SELECT MAX(captured_at) FROM wt_signal")->fetchColumn();
    $byClass = $pdo->query("
        SELECT signal_class, COUNT(*) n FROM wt_signal GROUP BY signal_class
    ")->fetchAll(PDO::FETCH_ASSOC);
    $topics = $pdo->query("
        SELECT topic, COUNT(*) n FROM wt_signal GROUP BY topic ORDER BY n DESC LIMIT 40
    ")->fetchAll(PDO::FETCH_ASSOC);

    respond(200, [
        'status'    => 'ok',
        'read_only' => true,
        'note'      => 'Counts only. This does NOT assert the clone is complete — compare '
                     . 'these against what fleet carried over the same window.',
        'capture_first_seen' => $first,
        'capture_last_seen'  => $last,
        'by_class'  => $byClass,
        'by_topic'  => $topics,
        'served_at' => gmdate('c'),
    ]);

default:
    respond(400, [
        'status' => 'error',
        'error'  => 'unknown_action',
        'actions' => ['feed', 'channels', 'parity'],
    ]);
}
