<?php
/**
 * wt-roster.php — the tower roster (presentList) shared by watch-api.php (the poll answer) and
 * events.php (the SSE roster event). One builder, so the pushed roster is byte-for-byte the polled
 * one (Ray 09-20: polls -> push; Solace: one shell-level EventSource retires the roster clock).
 * Moved out of watch-api.php 2026-09-20; that file is a router and cannot be included.
 */
declare(strict_types=1);

const WT_ROUTE_RUNS_CAP_PER_HOUR = 100;

/**
 * ONE combined list: everyone present in the tower, plus any ACTIVE watch-stander, each row
 * carrying both flags. Keyed by principal, so a person in two tabs or a Geth that has cycled is
 * one row.
 *
 * ⛔ SUPERSEDED 2026-09-19 15:2x by Ray's ruling: there is no on-watch-but-absent row. Absence
 * relieves the watch in sweepWatchSessions, so every emitted row is present:true, and on_watch
 * only decorates a row that is here. The list is who is here. The 57-hour shape is still caught,
 * one level up: an unmanned post shows as NOBODY on watch, not as a struck-through name.
 */
function presentList(PDO $pdo): array {
    $rows = [];

    /**
     * ABSENCE VOCABULARY (2026-09-19, agreed with Solace in the room; literal strings, snake_case):
     *   mode          'poll' | 'push'      push = a Geth on watch fed by the tap (Solace's catch: a
     *                                      pushed principal issues no reads, so a TIME rule would
     *                                      render every healthy Geth as "not here"; its evidence is
     *                                      per EVENT — present until a delivery FAILS)
     *   absent_reason null | 'stale' | 'left' | 'stood_down' | 'delivery_failed'
     *   absent_detail null | short human string ('webhook 502 at 15:31:07'); render, never parse
     *   absent_since  null | datetime the row was closed or went stale
     * A poll row is present while last_seen is within the window. A push row is present until
     * left_at is set (by presence-end, stand-down, or the tap on a failed delivery). Quiet channel
     * => nothing attempted => no evidence of failure => still present.
     */
    $p = $pdo->prepare("
        SELECT principal, display_name, principal_kind, cred_type, mode, first_seen, last_seen,
               left_at, absent_reason, absent_detail, (route IS NOT NULL) AS has_route,
               IF(capped_until IS NOT NULL AND capped_until > NOW(), capped_until, NULL) AS capped_until,
               (left_at IS NULL AND (mode = 'push' OR last_seen > (NOW() - INTERVAL ? SECOND))) AS is_present,
               (left_at IS NULL AND mode = 'poll' AND last_seen <= (NOW() - INTERVAL ? SECOND)) AS is_stale
        FROM wt_presence
        WHERE left_at IS NULL OR left_at > (NOW() - INTERVAL 1 DAY)
    ");
    $p->execute([WT_PRESENCE_STALE_SECONDS, WT_PRESENCE_STALE_SECONDS]);
    foreach ($p->fetchAll(PDO::FETCH_ASSOC) as $r) {
        $present = (bool)$r['is_present'];
        $row = [
            'principal'      => $r['principal'],
            'display_name'   => $r['display_name'] ?: $r['principal'],
            'kind'           => $r['principal_kind'],
            'principal_kind' => $r['principal_kind'],
            'cred_type'      => $r['cred_type'],
            'mode'           => $r['mode'],
            'has_route'      => (bool)$r['has_route'],   // a webhook/bridge route: spends Cursor runs
            'capped_until'   => $r['capped_until'],       // non-null = on watch, door fine, out of runs until then
            'present'        => $present,
            'on_watch'       => false,
            'watch_state'    => null,
            'since'          => $r['first_seen'],
            'last_seen'      => $r['last_seen'],
            'last_heartbeat' => null,
            'absent_reason'  => $present ? null : ((bool)$r['is_stale'] ? 'stale' : $r['absent_reason']),
            'absent_detail'  => $present ? null : $r['absent_detail'],
            'absent_since'   => $present ? null : ($r['left_at'] ?: $r['last_seen']),
        ];
        // Ray's ruling (see sweepWatchSessions): the list is WHO IS HERE. Absent rows are not
        // emitted; absent_reason/absent_detail/absent_since are kept in the payload for contract
        // stability and are always null on an emitted row.
        if ($present) $rows[$r['principal']] = $row;
    }

    // Latest session per principal wins (ORDER BY last_heartbeat DESC, first seen kept).
    $w = $pdo->query("
        SELECT principal, display_name, principal_kind, state, reported_at, last_heartbeat
        FROM wt_watch_sessions
        WHERE state IN ('ACTIVE','NEEDS_REAUTH')
        ORDER BY last_heartbeat DESC
    ")->fetchAll(PDO::FETCH_ASSOC);
    $seen = [];
    foreach ($w as $r) {
        if (isset($seen[$r['principal']])) continue;
        $seen[$r['principal']] = true;
        $onWatch = $r['state'] === 'ACTIVE';
        if (isset($rows[$r['principal']])) {
            $rows[$r['principal']]['on_watch']       = $onWatch;
            $rows[$r['principal']]['watch_state']    = $r['state'];
            $rows[$r['principal']]['last_heartbeat'] = $r['last_heartbeat'];
            if (empty($rows[$r['principal']]['display_name']) && $r['display_name']) {
                $rows[$r['principal']]['display_name'] = $r['display_name'];
            }
        }
        // (no branch for on-watch-but-absent: the sweep above has already relieved it)
    }

    // Budget visibility (Solace, 09-19 18:1x: "a cap you can see coming is a budget; a cap you discover
    // by failing is an outage"): Cursor caps a bot at 100 webhook runs/hour. For every routed row,
    // count this hour's accepted deliveries from the tap ledger and show them. No branch, no shedding
    // here — that policy is Ray's call; this just makes the number visible before the 400 does.
    $runs = [];
    if (is_readable('/var/lib/zora/watchtower-tap.log')) {
        $since = time() - 3600;
        foreach (array_slice(file('/var/lib/zora/watchtower-tap.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [], -2000) as $ln) {
            $e = json_decode($ln, true);
            if (!$e || empty($e['to']) || (int)($e['http'] ?? 0) !== 200) continue;
            if (strtotime($e['ts'] ?? '') >= $since) $runs[$e['to']] = ($runs[$e['to']] ?? 0) + 1;
        }
    }
    foreach ($rows as &$row) {
        // Only a ROUTED row spends runs. Crew seats are push-mode too but ride the fleet route (free).
        $routed = !empty($row['has_route']);
        $row['runs_this_hour'] = $routed ? ($runs[$row['principal']] ?? 0) : null;
        // The cap is CURSOR'S number (measured from its 400 body, 09-19: "grok bot user limit 100 per
        // hour"). It lives here, once; clients read it off the row (Solace: one fact, one home).
        $row['runs_cap'] = $routed ? WT_ROUTE_RUNS_CAP_PER_HOUR : null;
        // Honesty of the ratio (Solace 18:4x): runs_this_hour counts THE TOWER'S accepted deliveries only.
        // Cursor's authoritative counter appears only in its 400 body (current_count); a 200 carries no
        // count. So our number is a LOWER BOUND on the real spend — anything else that fires a run
        // (panel tests, scheduled routines) is invisible to it. Render it as "≥ N of cap", never "N/100".
        $row['runs_is_lower_bound'] = $routed ? true : null;
        $row['runs_source'] = $routed ? 'tower-deliveries' : null;
    }
    unset($row);

    // Channel chips: every row carries channels:[...] (empty = standing by / a human).
    // Delegation is open by design (Ray 09-19: the Watch Officer delegates at will), so the compensating
    // control is VISIBILITY (Solace): every row carries assignments:[{channel, by, at}] beside channels[],
    // so a delegation stays legible after the moment of the call.
    $as = $pdo->query("SELECT principal, channel, assigned_by, assigned_at FROM wt_watch_assignment ORDER BY channel")->fetchAll(PDO::FETCH_ASSOC);
    foreach ($rows as &$row) { $row['channels'] = []; $row['assignments'] = []; }
    unset($row);
    foreach ($as as $a) if (isset($rows[$a['principal']])) {
        $rows[$a['principal']]['channels'][]    = $a['channel'];
        $rows[$a['principal']]['assignments'][] = ['channel' => $a['channel'], 'by' => $a['assigned_by'], 'at' => $a['assigned_at']];
    }

    $list = array_values($rows);
    usort($list, function ($a, $b) {
        if ($a['on_watch'] !== $b['on_watch']) return $a['on_watch'] ? -1 : 1;
        if ($a['present']  !== $b['present'])  return $a['present']  ? -1 : 1;
        return strcasecmp((string)$a['display_name'], (string)$b['display_name']);
    });
    return $list;
}

/** Per-channel holders, PRESENT principals only: a channel nobody present holds is UNHELD and must
 *  render so (Solace's rule: an unwatched channel must never look like a quiet one). */
function channelHolders(array $present): array {
    $out = [];
    foreach (['agents','comms','telemetry','dispatch','alerts','other'] as $c) $out[$c] = [];
    foreach ($present as $r) foreach ($r['channels'] ?? [] as $c) $out[$c][] = $r['principal'];
    return $out;
}

const WT_CHANNELS = ['agents','comms','telemetry','dispatch','alerts','other'];
