<?php
/**
 * wt-auth.php — shared credential resolution for Watchtower's read surfaces.
 *
 * Ray, 2026-09-12: "when I open pathfinder the feed is live and constantly running. Watchtower
 * should be just as straightforward. It shouldn't need an extra step or extra trigger."
 *
 * ⛔ THE BUG THIS FIXES, and it was a design error of mine rather than a slip.
 * I built feed/chat to require a WATCH TOKEN — the RS256 credential minted for Geth. Humans log
 * in through the front door and get a CITADEL token. So Ray logged in, and every channel told him
 * "No watch session — report for watch to see the feed." I had made READING the feed conditional
 * on STANDING A WATCH, which is two different things welded together: the watch is a DUTY ROSTER,
 * not a read permission. Pathfinder has no such step and that is the bar Watchtower is held to.
 *
 * ⇒ TWO CREDENTIAL TYPES, EACH VERIFIED WITH ITS OWN KEY, GRANTING DIFFERENT THINGS:
 *      watch token   RS256, public key here, private key root-only off-docroot
 *                    -> read the feed AND stand a watch
 *      citadel token HS256, Citadel's shared secret via SetEnv
 *                    -> read the feed. CANNOT stand a watch.
 *
 * ⛔ THIS DOES NOT UNDO THE RS256/HS256 SPLIT I BUILT DELIBERATELY. That split exists so a watch
 * token cannot BE a Citadel token and vice versa — and it still holds: each is verified with its
 * own key, and `report-for-watch` still refuses anything but a watch token. Accepting a second
 * credential type for READ is not the same as signing one credential with the other's secret,
 * which is the thing that would have made them indistinguishable.
 */

declare(strict_types=1);

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

const WT_PUB_KEY_PATH = __DIR__ . '/watch-verify-key.pub.pem';
const WT_ISSUER       = 'watchtower';
const WT_AUDIENCE     = 'watch-officer';

/** Pull a bearer token from the Authorization header, or ?token= as a fallback. */
function wt_presented_token(): string
{
    $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? (getallheaders()['Authorization'] ?? '');
    if (preg_match('/^Bearer\s+(.+)$/i', (string)$hdr, $m)) {
        return trim($m[1]);
    }
    if (!empty($_GET['token'])) {
        return trim((string)$_GET['token']);
    }
    return '';
}


/**
 * Friendly name for a person, from citadel_2025.profiles.
 *
 * ⭐ WHY THIS LIVES HERE AND NOT IN THE CHAT TABLE'S SESSION LOOKUP — Solace's argument, which is
 * better than the one I offered: A DISPLAY NAME IS AN ATTRIBUTE OF THE PERSON, NOT OF A WATCH
 * SESSION. Sourcing it from the session is exactly WHY it was missing — a Citadel reader has no
 * session and never will, so no client-side mapping fixes the model, it only papers over it at one
 * view. Putting it in the credential resolver means every consumer of a reader identity gets it.
 *
 * ⛔ AND THE SYMPTOM THAT FOUND IT IS A CLASS WORTH NAMING: Ray's messages rendered as
 * `oldbones@finestoflines.net` while the two agent seats rendered friendly names. THE PERSON MOST
 * LIKELY TO USE THIS APP WAS THE ONE WHOSE NAME RENDERED WORST — because every test either of us
 * ran seeded an AGENT session. The human path is the one neither builder inhabits, so it is the
 * least exercised by reflex.
 *
 * ⚠️ `login_users` has NO name column (identifier/password_hash/user_id/role/permissions only) —
 * I checked before building the lookup I had offered, and the lookup as described was impossible.
 * The names live in `citadel_2025.profiles`, keyed by user_id, which the JWT carries.
 * ⚠️ 4 of 15 users have NO profile row, so the email fallback is load-bearing, not decoration.
 */
function wt_person_name($userId, string $fallback): string
{
    if ($userId === null) return $fallback;
    global $DB;
    try {
        $pdo = new PDO(
            "mysql:host={$DB['host']};dbname=citadel_2025;charset=utf8mb4",
            $DB['user'], $DB['pass']
        );
        $st = $pdo->prepare('SELECT first_name, last_name, nickname FROM profiles WHERE user_id = ?');
        $st->execute([(int)$userId]);
        $r = $st->fetch(PDO::FETCH_ASSOC);
        if (!$r) return $fallback;
        // Nickname first: Ray's profile nickname is "Raydog", which is the name he actually uses.
        $nick = trim((string)($r['nickname'] ?? ''));
        if ($nick !== '') return $nick;
        $full = trim(trim((string)($r['first_name'] ?? '')) . ' ' . trim((string)($r['last_name'] ?? '')));
        return $full !== '' ? $full : $fallback;
    } catch (\Throwable $e) {
        // A name lookup must never be able to break authentication. Fall back silently to the
        // principal — a worse label is not a reason to refuse a valid credential.
        return $fallback;
    }
}

/**
 * Resolve whoever is calling to a reader identity, or null if the credential is not valid.
 *
 * Returns ['type' => 'watch'|'citadel', 'principal' => string, 'kind' => string, 'jti' => ?string]
 *
 * ⚠️ Order matters only for efficiency, not correctness: a token is either RS256-signed by our
 * watch key or HS256-signed by Citadel's secret, and it cannot be both.
 */
function wt_resolve_reader(PDO $pdo): ?array
{
    $raw = wt_presented_token();
    if ($raw === '') return null;

    // --- watch token (RS256) -------------------------------------------------
    if (is_readable(WT_PUB_KEY_PATH)) {
        try {
            $c = (array)JWT::decode($raw, new Key(file_get_contents(WT_PUB_KEY_PATH), 'RS256'));
            if (($c['iss'] ?? null) === WT_ISSUER && ($c['aud'] ?? null) === WT_AUDIENCE) {
                $jti = (string)($c['jti'] ?? '');
                // Revocation still applies — a revoked watch token reads nothing.
                $st = $pdo->prepare("SELECT 1 FROM wt_revoked_tokens WHERE jti = ?");
                $st->execute([$jti]);
                if ($st->fetchColumn()) return null;

                return [
                    'type'      => 'watch',
                    'principal' => (string)($c['sub'] ?? ''),
                    'kind'      => in_array(($c['kind'] ?? ''), ['human','geth','senior'], true)
                                   ? (string)$c['kind'] : 'geth',
                    'jti'       => $jti,
                ];
            }
        } catch (\Throwable $e) {
            // Not a watch token — fall through and try Citadel. An expired WATCH token also
            // lands here and will simply fail the Citadel check too, yielding a clean 401.
        }
    }

    // --- Citadel session token (HS256) --------------------------------------
    $secret = getenv('CITADEL_2025_JWT_SECRET');
    if ($secret) {
        try {
            $c = (array)JWT::decode($raw, new Key($secret, 'HS256'));
            // Citadel tokens carry user_id/email/role. They have no iss/aud in this estate, so
            // there is nothing further to check beyond the signature and expiry JWT::decode
            // already enforced.
            $who = (string)($c['email'] ?? $c['user_id'] ?? 'citadel-user');
            return [
                'type'         => 'citadel',
                'principal'    => $who,
                'kind'         => 'human',
                'jti'          => null,
                'display_name' => wt_person_name($c['user_id'] ?? null, $who),
            ];
        } catch (\Throwable $e) {
            // Not a valid Citadel token either.
        }
    }

    return null;
}

/**
 * Require a reader or emit 401 and stop.
 * $respond is the caller's responder so each endpoint keeps its own envelope shape.
 */
function wt_require_reader(PDO $pdo, callable $respond): array
{
    $r = wt_resolve_reader($pdo);
    if ($r === null) {
        $respond(401, [
            'status'  => 'error',
            'error'   => 'auth_required',
            // ⚠️ Say what actually works. The old message said "report for watch to see the feed",
            // which sent a logged-in human off to do a thing that was never the requirement.
            'message' => 'Sign in with Citadel, or present a watch token. Reading the feed does '
                       . 'not require standing a watch.',
        ]);
    }
    return $r;
}
