<?php
declare(strict_types=1);

/* =========================================================
 * nostr-follows-rss.php
 * Builds an RSS 2.0 feed of kind-1 notes from the accounts
 * followed by your npub, straight from Nostr relays.
 * - Images (NIP-92 imeta tags + image URLs) are embedded
 * - All content is sanitized to valid UTF-8 / XML-safe text
 * - Works without mbstring (falls back to PCRE/iconv)
 *
 * Requirements:
 *   - PHP 7.4+, 64-bit
 *   - Extensions: openssl (wss://), xmlwriter, mbstring recommended
 *   - Outbound TCP/TLS connections allowed by your host
 *   - No Composer packages needed
 * ========================================================= */

/* ------------------------ CONFIG ------------------------ */

// Your Nostr pubkey: npub1... (a raw 64-char hex pubkey also works)
$NPUB = 'npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

// Relays used for kind-0 profiles & kind-3 contact lists.
// purplepag.es aggregates those kinds network-wide, so keep it first.
$PROFILE_RELAYS = [
    'wss://purplepag.es',
    'wss://relay.damus.io',
    'wss://nos.lol',
];

// Relays used for kind-1 notes.
$NOTE_RELAYS = [
    'wss://relay.damus.io',
    'wss://nos.lol',
    'wss://relay.primal.net',
    'wss://offchain.pub',
    'wss://relay.snort.social',
];

$MAX_ITEMS           = 50;        // items in the feed
$TIME_WINDOW         = 3 * 86400; // how far back to look for notes (seconds)
$CACHE_TTL           = 300;       // serve cached feed for this many seconds
$CACHE_DIR           = __DIR__ . '/nostr-rss-cache';
$RELAY_TIMEOUT       = 8.0;       // seconds per relay request
$AUTHORS_PER_REQ     = 80;        // authors per filter (chunked for big lists)
$INCLUDE_REPLIES     = false;     // skip notes marked as replies/root
$SHOW_AUTHOR_NAMES   = true;      // fetch kind-0 profiles for display names
$EMBED_IMAGES        = true;      // embed images in feed items
$MAX_IMAGES_PER_NOTE = 5;         // cap images per item

/* ---------------------- /CONFIG ------------------------- */

@set_time_limit(180);
ignore_user_abort(true);

const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';

/* ================= Minimal WebSocket client ============== */

final class NostrWs
{
    /** @var resource|null */
    private $sock;
    private bool $closed = false;

    public function __construct(string $url, float $timeout)
    {
        $p = parse_url($url);
        if ($p === false || !isset($p['scheme'], $p['host'])) {
            throw new RuntimeException("bad relay url: $url");
        }
        $secure = $p['scheme'] === 'wss';
        $port   = $p['port'] ?? ($secure ? 443 : 80);
        $path   = ($p['path'] ?? '/') . (isset($p['query']) ? '?' . $p['query'] : '');

        // verify_peer disabled for maximum compatibility on shared hosts.
        // Tighten this if you prefer strict TLS verification.
        $ctx = stream_context_create([
            'ssl' => ['SNI_enabled' => true, 'verify_peer' => false, 'verify_peer_name' => false],
        ]);

        $sock = @stream_socket_client(
            ($secure ? 'tls://' : 'tcp://') . $p['host'] . ':' . $port,
            $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx
        );
        if ($sock === false) {
            throw new RuntimeException("connect $url failed: $errstr ($errno)");
        }
        stream_set_timeout($sock, (int)max(1, $timeout));
        $this->sock = $sock;

        $key = base64_encode(random_bytes(16));
        fwrite($sock,
            "GET $path HTTP/1.1\r\n" .
            "Host: {$p['host']}:$port\r\n" .
            "Upgrade: websocket\r\n" .
            "Connection: Upgrade\r\n" .
            "Sec-WebSocket-Key: $key\r\n" .
            "Sec-WebSocket-Version: 13\r\n" .
            "User-Agent: nostr-follows-rss/1.0\r\n\r\n"
        );

        $resp = '';
        while (strpos($resp, "\r\n\r\n") === false) {
            $line = fgets($sock, 8192);
            if ($line === false) throw new RuntimeException("handshake $url: no response");
            $resp .= $line;
        }
        if (!preg_match('#^HTTP/1\.1 101#', $resp)) {
            throw new RuntimeException("handshake $url rejected: " . strtok($resp, "\r\n"));
        }
    }

    public function sendText(string $payload): void { $this->sendFrame(0x1, $payload); }

    /** Returns next text/binary payload, or null on timeout/close. */
    public function recvText(float $timeout): ?string
    {
        $deadline = microtime(true) + $timeout;
        while (true) {
            $hdr = $this->readN(2, $deadline);
            if ($hdr === null) return null;
            $op     = ord($hdr[0]) & 0x0F;
            $masked = (ord($hdr[1]) & 0x80) !== 0;
            $len    = ord($hdr[1]) & 0x7F;
            if ($len === 126) {
                $ext = $this->readN(2, $deadline);
                if ($ext === null) return null;
                $len = unpack('n', $ext)[1];
            } elseif ($len === 127) {
                $ext = $this->readN(8, $deadline);
                if ($ext === null) return null;
                $u   = unpack('N2', $ext);
                $len = $u[1] * 4294967296 + $u[2];
            }
            $mask = '';
            if ($masked) {
                $mask = $this->readN(4, $deadline);
                if ($mask === null) return null;
            }
            $payload = $len > 0 ? $this->readN((int)$len, $deadline) : '';
            if ($payload === null) return null;
            if ($masked) {
                for ($i = 0; $i < $len; $i++) $payload[$i] = $payload[$i] ^ $mask[$i & 3];
            }
            if ($op === 0x8) { $this->closed = true; @fclose($this->sock); return null; } // close
            if ($op === 0x9) { $this->sendFrame(0xA, $payload); continue; }               // ping -> pong
            if ($op === 0xA) { continue; }                                                // pong
            return $payload; // text / binary (fragmented frames are rare from relays)
        }
    }

    public function close(): void
    {
        $this->sendFrame(0x8, '');
        if (!$this->closed) { $this->closed = true; @fclose($this->sock); }
    }

    private function sendFrame(int $opcode, string $payload): void
    {
        if ($this->closed || $this->sock === null) return;
        $len  = strlen($payload);
        $head = chr(0x80 | $opcode); // FIN + opcode, client frames are always masked
        if ($len < 126) {
            $head .= chr($len | 0x80);
        } elseif ($len < 65536) {
            $head .= chr(126 | 0x80) . pack('n', $len);
        } else {
            $head .= chr(127 | 0x80) . pack('NN', 0, $len);
        }
        $mask = random_bytes(4);
        $out  = $payload;
        for ($i = 0; $i < $len; $i++) $out[$i] = $payload[$i] ^ $mask[$i & 3];
        @fwrite($this->sock, $head . $mask . $out);
    }

    private function readN(int $n, float $deadline): ?string
    {
        $buf = '';
        while (strlen($buf) < $n) {
            $left = $deadline - microtime(true);
            if ($left <= 0) return null;
            $r = [$this->sock]; $w = null; $e = null;
            $sel = @stream_select($r, $w, $e, (int)$left, (int)(fmod($left, 1) * 1e6));
            if ($sel === false) return null;
            if ($sel === 0) continue;
            $chunk = @fread($this->sock, $n - strlen($buf));
            if ($chunk === false || $chunk === '') {
                if (feof($this->sock)) return null;
                continue;
            }
            $buf .= $chunk;
        }
        return $buf;
    }
}

/* ================== Nostr relay querying ================= */

/**
 * Opens a WebSocket, sends REQ, collects events until EOSE / maxEvents / timeout.
 * @return array<string,array> map of event-id => event
 */
function nostr_req(string $relay, array $filters, int $maxEvents, float $timeout, array &$log): array
{
    $ws  = new NostrWs($relay, $timeout);
    $sub = bin2hex(random_bytes(6));
    $ws->sendText(json_encode(array_merge(['REQ', $sub], $filters)));

    $events   = [];
    $deadline = microtime(true) + $timeout;
    while (count($events) < $maxEvents) {
        $left = $deadline - microtime(true);
        if ($left <= 0) break;
        $raw = $ws->recvText($left);
        if ($raw === null) break;
        $msg = json_decode($raw, true);
        if (!is_array($msg) || !isset($msg[0])) continue;

        if ($msg[0] === 'EVENT' && ($msg[1] ?? null) === $sub && is_array($msg[2] ?? null)) {
            $ev = $msg[2];
            if (isset($ev['id'])) $events[$ev['id']] = $ev;
        } elseif (in_array($msg[0], ['EOSE', 'CLOSED'], true) && ($msg[1] ?? null) === $sub) {
            if ($msg[0] === 'CLOSED') $log[] = "$relay closed subscription: " . ($msg[2] ?? '');
            break;
        }
    }
    $ws->close();
    return $events;
}

/* ==================== bech32 (npub) ====================== */

function bech32_polymod(array $values): int
{
    static $gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
    $chk = 1;
    foreach ($values as $v) {
        $top = $chk >> 25;
        $chk = (($chk & 0x1ffffff) << 5) ^ $v;
        for ($i = 0; $i < 5; $i++) {
            if (($top >> $i) & 1) $chk ^= $gen[$i];
        }
    }
    return $chk;
}

function bech32_hrp_expand(string $hrp): array
{
    $out = [];
    $len = strlen($hrp);
    for ($i = 0; $i < $len; $i++) $out[] = ord($hrp[$i]) >> 5;
    $out[] = 0;
    for ($i = 0; $i < $len; $i++) $out[] = ord($hrp[$i]) & 31;
    return $out;
}

/** @return array{0:string,1:int[]}|null [hrp, data-words] */
function bech32_decode(string $s): ?array
{
    if ($s !== strtolower($s) && $s !== strtoupper($s)) return null; // mixed case invalid
    $s   = strtolower($s);
    $pos = strrpos($s, '1');
    if ($pos === false || $pos < 1 || $pos + 7 > strlen($s) || strlen($s) > 500) return null;
    $hrp  = substr($s, 0, $pos);
    $data = [];
    for ($i = $pos + 1, $n = strlen($s); $i < $n; $i++) {
        $d = strpos(BECH32_CHARSET, $s[$i]);
        if ($d === false) return null;
        $data[] = $d;
    }
    if (bech32_polymod(array_merge(bech32_hrp_expand($hrp), $data)) !== 1) return null;
    return [$hrp, array_slice($data, 0, -6)];
}

/** @param int[] $data */
function convert_bits(array $data, int $from, int $to, bool $pad): ?array
{
    $acc = 0; $bits = 0; $out = [];
    $maxv = (1 << $to) - 1;
    foreach ($data as $value) {
        if ($value < 0 || ($value >> $from) !== 0) return null;
        $acc  = ($acc << $from) | $value;
        $bits += $from;
        while ($bits >= $to) {
            $bits -= $to;
            $out[] = ($acc >> $bits) & $maxv;
        }
    }
    if ($pad) {
        if ($bits > 0) $out[] = ($acc << ($to - $bits)) & $maxv;
    } elseif ($bits >= $from || (($acc << ($to - $bits)) & $maxv) !== 0) {
        return null;
    }
    return $out;
}

function npub_to_hex(string $input): string
{
    $input = trim($input);
    if (preg_match('/^[0-9a-f]{64}$/i', $input)) return strtolower($input);
    $decoded = bech32_decode($input);
    if ($decoded === null) throw new RuntimeException("not valid bech32: $input");
    [$hrp, $words] = $decoded;
    if ($hrp !== 'npub') throw new RuntimeException("expected npub1..., got {$hrp}1...");
    $bytes = convert_bits($words, 5, 8, false);
    if ($bytes === null || count($bytes) !== 32) throw new RuntimeException('bad npub payload');
    $hex = '';
    foreach ($bytes as $b) $hex .= sprintf('%02x', $b);
    return $hex;
}

function hex_to_npub(string $hex): string
{
    $bytes = [];
    foreach (str_split($hex, 2) as $pair) $bytes[] = hexdec($pair);
    $words = convert_bits($bytes, 8, 5, true) ?? [];
    $pm    = bech32_polymod(array_merge(bech32_hrp_expand('npub'), $words, [0,0,0,0,0,0])) ^ 1;
    $out   = 'npub1';
    foreach ($words as $w) $out .= BECH32_CHARSET[$w];
    for ($i = 0; $i < 6; $i++) $out .= BECH32_CHARSET[($pm >> (5 * (5 - $i))) & 31];
    return $out;
}

/* ================== Text sanitization ==================== */

/**
 * Guarantees a string is valid UTF-8 and only contains chars allowed in
 * XML 1.0. Invalid byte sequences are stripped, control chars removed.
 * Applied to everything user-controlled that enters the feed.
 */
function xml_safe_utf8(string $s): string
{
    if ($s === '') return $s;

    // Fast path: already valid UTF-8
    if (preg_match('//u', $s) !== 1) {
        // mbstring: re-encode, dropping invalid sequences
        if (function_exists('mb_convert_encoding')) {
            $prev  = @mb_substitute_character();
            @mb_substitute_character('none');
            $fixed = @mb_convert_encoding($s, 'UTF-8', 'UTF-8');
            if ($prev !== false) @mb_substitute_character($prev);
            if (is_string($fixed)) $s = $fixed;
        }
        // iconv fallback
        if (preg_match('//u', $s) !== 1 && function_exists('iconv')) {
            $fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $s);
            if (is_string($fixed)) $s = $fixed;
        }
        // pure-PHP last resort
        if (preg_match('//u', $s) !== 1) {
            $s = utf8_keep_valid($s);
        }
    }

    // Strip chars that are illegal in XML 1.0 (C0 controls except \t \n \r, noncharacters)
    $out = preg_replace(
        '/[\x{0000}-\x{0008}\x{000B}\x{000C}\x{000E}-\x{001F}\x{FFFE}\x{FFFF}]/u',
        '',
        $s
    );
    return $out ?? $s;
}

/** Pure-PHP fallback: keeps only well-formed UTF-8 sequences. */
function utf8_keep_valid(string $s): string
{
    preg_match_all('/(
        [\x09\x0A\x0D\x20-\x7E]
      | [\xC2-\xDF][\x80-\xBF]
      | \xE0[\xA0-\xBF][\x80-\xBF]
      | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}
      | \xED[\x80-\x9F][\x80-\xBF]
      | \xF0[\x90-\xBF][\x80-\xBF]{2}
      | [\xF1-\xF3][\x80-\xBF]{3}
      | \xF4[\x80-\x8F][\x80-\xBF]{2}
    )/x', $s, $m);
    return implode('', $m[0] ?? []);
}

/** URL-safe version: valid UTF-8 + percent-encodes anything outside printable ASCII. */
function safe_url(string $url): string
{
    $url = xml_safe_utf8(trim($url));
    return preg_replace_callback(
        '/[^\x21-\x7E]+/',
        static fn(array $m): string => rawurlencode($m[0]),
        $url
    ) ?? $url;
}

/* ==================== Image handling ===================== */

function is_image_url(string $url): bool
{
    $path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
    return (bool)preg_match('~\.(?:jpe?g|png|gif|webp|avif|bmp)(?:[?#]|$)~i', $path);
}

function mime_from_url(string $url): string
{
    $path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?? ''));
    $map = [
        'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
        'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif',
        'bmp' => 'image/bmp', 'svg' => 'image/svg+xml',
    ];
    return $map[pathinfo($path, PATHINFO_EXTENSION)] ?? 'image/jpeg';
}

/**
 * Extract image URLs from an event:
 *  - NIP-92 "imeta" tags (["imeta", "url https://...", "m image/png", ...])
 *  - image-looking URLs inside the note content
 * @return array<string,string> url => mime type
 */
function extract_images(array $ev): array
{
    $images = [];

    // 1) NIP-92 imeta tags
    foreach ($ev['tags'] ?? [] as $tag) {
        if (!is_array($tag) || ($tag[0] ?? null) !== 'imeta') continue;
        $url = ''; $mime = '';
        foreach (array_slice($tag, 1) as $field) {
            if (!is_string($field)) continue;
            if (strncmp($field, 'url ', 4) === 0) $url = substr($field, 4);
            elseif (strncmp($field, 'm ', 2) === 0) $mime = substr($field, 2);
        }
        if ($url === '' || !preg_match('~^https?://~i', $url)) continue;
        $url = safe_url($url);
        if (strncmp($mime, 'image/', 6) === 0) {
            $images[$url] = $mime;
        } elseif ($mime === '' && is_image_url($url)) {
            $images[$url] = mime_from_url($url);
        }
        // imeta entries with a non-image mime (video/audio) are skipped
    }

    // 2) image URLs mentioned directly in the content
    // BULLETPROOF REGEX: Using ~ as delimiter and [^]\s"'<>()[] to avoid backslash-escaping issues
    if (preg_match_all('~https?://[^]\s"\'<>()[]+\.(?:jpe?g|png|gif|webp|avif|bmp)(?:[?#][^]\s"\'<>()[]*)?~i',
        (string)($ev['content'] ?? ''), $m)) {
        foreach ($m[0] as $url) {
            $url = safe_url($url);
            $images[$url] = $images[$url] ?? mime_from_url($url);
        }
    }

    return $images;
}

/* =================== Feed construction =================== */

/** @return array{0:array,1:array} [notes, names] */
function build_feed(string $me, array &$log): array
{
    global $PROFILE_RELAYS, $NOTE_RELAYS, $RELAY_TIMEOUT, $TIME_WINDOW,
           $MAX_ITEMS, $AUTHORS_PER_REQ, $INCLUDE_REPLIES, $SHOW_AUTHOR_NAMES;

    /* --- 1. kind-3 contact list ------------------------------- */
    $best = null;
    foreach ($PROFILE_RELAYS as $relay) {
        try {
            $evs = nostr_req($relay, [['kinds' => [3], 'authors' => [$me], 'limit' => 1]], 3, $RELAY_TIMEOUT, $log);
        } catch (Throwable $e) {
            $log[] = "contact list @ $relay: " . $e->getMessage();
            continue;
        }
        foreach ($evs as $ev) {
            if ($best === null || ($ev['created_at'] ?? 0) > ($best['created_at'] ?? 0)) $best = $ev;
        }
        if ($best !== null) { $log[] = "contact list found on $relay"; break; }
    }
    if ($best === null) {
        throw new RuntimeException('No kind-3 contact list found for this pubkey on the configured PROFILE_RELAYS.');
    }

    $follows = [];
    foreach ($best['tags'] ?? [] as $tag) {
        if (is_array($tag) && ($tag[0] ?? null) === 'p'
            && preg_match('/^[0-9a-f]{64}$/', (string)($tag[1] ?? ''))) {
            $follows[$tag[1]] = true;
        }
    }
    unset($follows[$me]);
    $follows = array_keys($follows);
    if (!$follows) throw new RuntimeException('Contact list contains no follows.');
    $log[] = 'following ' . count($follows) . ' pubkeys';

    /* --- 2. display names (kind 0) ----------------------------- */
    $names = [];
    if ($SHOW_AUTHOR_NAMES) {
        $profiles = [];
        foreach (array_chunk($follows, $AUTHORS_PER_REQ) as $chunk) {
            foreach ($PROFILE_RELAYS as $relay) {
                try {
                    $evs = nostr_req($relay, [['kinds' => [0], 'authors' => $chunk]], 2000, $RELAY_TIMEOUT, $log);
                } catch (Throwable $e) { continue; }
                foreach ($evs as $ev) {
                    $pk = (string)($ev['pubkey'] ?? '');
                    if ($pk === '') continue;
                    if (!isset($profiles[$pk]) || ($ev['created_at'] ?? 0) > ($profiles[$pk]['created_at'] ?? 0)) {
                        $profiles[$pk] = $ev;
                    }
                }
            }
        }
        foreach ($profiles as $pk => $ev) {
            $d = json_decode((string)($ev['content'] ?? ''), true);
            if (!is_array($d)) continue;
            $name = xml_safe_utf8(trim((string)($d['display_name'] ?? $d['name'] ?? '')));
            if ($name !== '') $names[$pk] = $name;
        }
        $log[] = 'resolved ' . count($names) . ' profile names';
    }

    /* --- 3. kind-1 notes ---------------------------------------- */
    $since = time() - $TIME_WINDOW;
    $limit = max(100, $MAX_ITEMS * 3);
    $notes = [];
    $t0    = microtime(true);
    foreach (array_chunk($follows, $AUTHORS_PER_REQ) as $chunk) {
        $filter = ['kinds' => [1], 'authors' => $chunk, 'since' => $since, 'limit' => $limit];
        foreach ($NOTE_RELAYS as $relay) {
            if (microtime(true) - $t0 > 60.0) { $log[] = 'time budget hit, stopping note fetch'; break 2; }
            try {
                $notes += nostr_req($relay, [$filter], $limit * 2, $RELAY_TIMEOUT, $log);
            } catch (Throwable $e) {
                $log[] = "notes @ $relay: " . $e->getMessage();
            }
        }
    }
    if (!$notes) {
        throw new RuntimeException('No notes fetched — relays unreachable or nothing in the time window.');
    }
    $log[] = 'collected ' . count($notes) . ' unique notes';

    $notes = array_values($notes);
    if (!$INCLUDE_REPLIES) {
        $notes = array_values(array_filter($notes, static function (array $ev): bool {
            foreach ($ev['tags'] ?? [] as $tag) {
                if (is_array($tag) && ($tag[0] ?? null) === 'e'
                    && in_array($tag[3] ?? null, ['reply', 'root'], true)) {
                    return false;
                }
            }
            return true;
        }));
    }
    usort($notes, static fn(array $a, array $b): int => ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0));
    return [array_slice($notes, 0, $MAX_ITEMS), $names];
}

/* ======================= Rendering ======================= */

function author_label(?string $name, string $pubkey): string
{
    if ($name !== null && $name !== '') return $name;
    if (preg_match('/^[0-9a-f]{64}$/', $pubkey)) return hex_to_npub($pubkey);
    return $pubkey !== '' ? substr($pubkey, 0, 16) . '...' : 'unknown';
}

/**
 * Truncate to a maximum number of UTF-8 CHARACTERS (not bytes),
 * so multi-byte chars like emoji are never sliced in half.
 * Works with or without mbstring.
 */
function truncate_text(string $s, int $len): string
{
    $s = trim((string)preg_replace('/\s+/u', ' ', $s));
    if ($s === '') return '';

    // Preferred path: mbstring
    if (function_exists('mb_substr') && function_exists('mb_strlen')) {
        if (mb_strlen($s, 'UTF-8') <= $len) return $s;
        return rtrim(mb_substr($s, 0, $len, 'UTF-8')) . '...';
    }

    // No mbstring: split into UTF-8 characters with PCRE instead of bytes
    if (preg_match_all('/./us', $s, $m) === false || !isset($m[0])) return $s;
    $chars = $m[0];
    if (count($chars) <= $len) return $s;
    return rtrim(implode('', array_slice($chars, 0, $len))) . '...';
}

/** @param array<string,string> $images url => mime */
function render_note_html(string $content, ?string $name, string $pubkey, array $images): string
{
    $h = htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

    // linkify plain URLs — image URLs become inline <img> tags
    // BULLETPROOF REGEX: Using ~ as delimiter and [^]\s<>"'[] to avoid backslash-escaping issues
    $h = preg_replace_callback('~https?://[^]\s<>"\'[]+~i', static function (array $m): string {
        $url = $m[0]; $trail = '';
        while ($url !== '' && strpos('.,;:!?', $url[strlen($url) - 1]) !== false) {
            $trail = $url[strlen($url) - 1] . $trail;
            $url   = substr($url, 0, -1);
        }
        $esc = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
        if (is_image_url($url)) {
            return '<img src="' . $esc . '" alt="" loading="lazy" style="max-width:100%" />' . $trail;
        }
        return '<a href="' . $esc . '">' . $esc . '</a>' . $trail;
    }, $h) ?? $h;

    // linkify nostr: mentions
    $h = preg_replace_callback(
        '~nostr:((?:npub|note|nevent|nprofile|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)~',
        static fn(array $m): string => '<a href="https://njump.me/' . $m[1] . '">' . $m[1] . '</a>',
        $h
    ) ?? $h;

    $h = nl2br($h);

    // append images referenced only via imeta tags (not already in content)
    $extra = '';
    foreach ($images as $url => $mime) {
        if (strpos($content, $url) !== false || strpos($content, rawurldecode($url)) !== false) continue;
        $esc = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
        $extra .= '<br /><img src="' . $esc . '" alt="" loading="lazy" style="max-width:100%" />';
    }

    $author = htmlspecialchars(author_label($name, $pubkey), ENT_QUOTES, 'UTF-8');
    return '<p><b>' . $author . '</b></p><p>' . $h . $extra . '</p>';
}

function build_rss(array $notes, array $names, string $npub, ?string $selfUrl): string
{
    global $CACHE_TTL, $EMBED_IMAGES, $MAX_IMAGES_PER_NOTE;
    $w = new XMLWriter();
    $w->openMemory();
    $w->startDocument('1.0', 'UTF-8');
    $w->startElement('rss');
    $w->writeAttribute('version', '2.0');
    $w->writeAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom');
    $w->writeAttribute('xmlns:dc', 'http://purl.org/dc/elements/1.1/');
    $w->writeAttribute('xmlns:media', 'http://search.yahoo.com/mrss/');

    $w->startElement('channel');
    $w->writeElement('title', "Nostr: notes from follows of $npub");
    $w->writeElement('link', "https://njump.me/$npub");
    $w->writeElement('description', "Recent notes from accounts followed by $npub, generated directly from Nostr relays.");
    $w->writeElement('language', 'en');
    $w->writeElement('lastBuildDate', gmdate(DATE_RSS));
    $w->writeElement('ttl', (string)max(1, (int)round($CACHE_TTL / 60)));
    $w->writeElement('generator', 'nostr-follows-rss.php');
    if ($selfUrl !== null) {
        $w->startElement('atom:link');
        $w->writeAttribute('rel', 'self');
        $w->writeAttribute('type', 'application/rss+xml');
        $w->writeAttribute('href', $selfUrl);
        $w->endElement();
    }

    foreach ($notes as $ev) {
        $id      = (string)$ev['id'];
        $content = xml_safe_utf8((string)($ev['content'] ?? ''));
        $pubkey  = (string)($ev['pubkey'] ?? '');
        $name    = $names[$pubkey] ?? null;

        $images = $EMBED_IMAGES ? extract_images($ev) : [];
        $images = array_slice($images, 0, $MAX_IMAGES_PER_NOTE, true);

        $body  = truncate_text($content, 80);
        $title = ($name !== null ? "$name: " : '') . ($body !== '' ? $body : '(empty note)');

        $w->startElement('item');
        $w->writeElement('title', $title);
        $w->writeElement('link', "https://njump.me/$id");
        $w->startElement('guid');
        $w->writeAttribute('isPermaLink', 'false');
        $w->text("nostr:$id");
        $w->endElement();
        $w->writeElement('pubDate', gmdate(DATE_RSS, (int)($ev['created_at'] ?? time())));
        $w->writeElement('dc:creator', author_label($name, $pubkey));

        // <enclosure> for the first image (used by most readers for previews).
        // length is unknown without an extra HEAD request, so it is set to 0.
        if ($images) {
            $firstUrl = array_key_first($images);
            $w->startElement('enclosure');
            $w->writeAttribute('url', $firstUrl);
            $w->writeAttribute('type', $images[$firstUrl] ?: 'image/jpeg');
            $w->writeAttribute('length', '0');
            $w->endElement();
        }
        // Media RSS entries for every image
        foreach ($images as $url => $mime) {
            $w->startElement('media:content');
            $w->writeAttribute('url', $url);
            $w->writeAttribute('type', $mime ?: 'image/jpeg');
            $w->writeAttribute('medium', 'image');
            $w->endElement();
        }

        $html = render_note_html($content, $name, $pubkey, $images);
        $w->startElement('description');
        $w->writeCdata(str_replace(']]>', ']]&gt;', $html));
        $w->endElement();
        $w->endElement(); // item
    }

    $w->endElement(); // channel
    $w->endElement(); // rss
    $w->endDocument();
    $out = $w->outputMemory();

    // Last line of defense: strip bytes that can never appear in XML 1.0
    $out = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $out) ?? $out;
    // ... and if any invalid UTF-8 sequence still slipped through, drop those bytes
    if (preg_match('//u', $out) !== 1) {
        $out = utf8_keep_valid($out);
    }
    return $out;
}

/* ========================= Main ========================== */

function emit(string $xml, bool $isCli): void
{
    if (!$isCli && !headers_sent()) {
        header('Content-Type: application/rss+xml; charset=utf-8');
        header('Cache-Control: public, max-age=60');
    }
    echo $xml;
}

function fail(string $msg, ?array $log, bool $isCli): void
{
    if (!$isCli && !headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/plain; charset=utf-8');
    }
    $out = "ERROR: $msg\n" . ($log ? implode("\n", $log) . "\n" : '');
    if ($isCli) { fwrite(STDERR, $out); exit(1); }
    echo $out;
    exit(1);
}

function main(): void
{
    global $NPUB, $CACHE_DIR, $CACHE_TTL;
    $isCli = PHP_SAPI === 'cli';

    if (!extension_loaded('xmlwriter')) fail('The xmlwriter extension is required.', null, $isCli);
    if (strpos($NPUB, 'xxxxxxxx') !== false) fail('Set $NPUB at the top of this script first.', null, $isCli);

    try {
        $me = npub_to_hex($NPUB);
    } catch (Throwable $e) {
        fail('Invalid NPUB: ' . $e->getMessage(), null, $isCli);
    }
    $npub = hex_to_npub($me);

    if (!is_dir($CACHE_DIR)) @mkdir($CACHE_DIR, 0775, true);
    $cacheFile = rtrim($CACHE_DIR, '/') . '/feed-' . md5($me) . '.xml';

    $forceRefresh = $isCli && in_array('--refresh', $_SERVER['argv'] ?? [], true);

    // Serve fresh cache without touching relays.
    if (!$forceRefresh && is_file($cacheFile) && (time() - (int)filemtime($cacheFile)) < $CACHE_TTL) {
        emit((string)file_get_contents($cacheFile), $isCli);
        return;
    }

    $log = [];
    try {
        [$notes, $names] = build_feed($me, $log);
        $selfUrl = null;
        if (!$isCli && isset($_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'])) {
            $scheme  = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
            $selfUrl = safe_url($scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
        }
        $xml = build_rss($notes, $names, $npub, $selfUrl);
        @file_put_contents($cacheFile, $xml, LOCK_EX);
        if ($isCli && $log) fwrite(STDERR, implode("\n", $log) . "\n");
        emit($xml, $isCli);
    } catch (Throwable $e) {
        // Fall back to a stale cache rather than failing hard.
        if (is_file($cacheFile)) {
            error_log('nostr-follows-rss refresh failed: ' . $e->getMessage());
            emit((string)file_get_contents($cacheFile), $isCli);
            return;
        }
        fail($e->getMessage(), $log, $isCli);
    }
}

main();