<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Node chat + sysop paging over the chat_msgs queue (SPEC §9.6, §10.6).
 *
 * Delivery model: the CLIENT holds the high-water mark (envelope field
 * chat_hw) and sends it with every poll; the server returns relevant rows
 * with id > hw. Relevant to a node: broadcasts (to_node NULL, kind
 * chat|broadcast) plus the sysop conversation touching that node (kind
 * sysop, to_node = node or from_node = node). Page requests (kind page)
 * surface only on the admin side (M6 dashboard).
 */
final class Chat
{
    public const MAX_BODY = 70;

    public static function post(
        Db $db,
        int $fromNode,
        string $handle,
        string $body,
        string $kind = 'chat',
        ?int $toNode = null
    ): int {
        return $db->insert('chat_msgs', [
            'from_node' => $fromNode,
            'from_handle' => mb_strimwidth($handle, 0, 20),
            'to_node' => $toNode,
            'kind' => $kind,
            'body' => self::clean($body),
            'at' => now_utc(),
        ]);
    }

    /** Highest chat_msgs id — the fresh client watermark. */
    public static function maxId(Db $db): int
    {
        return (int)$db->val('SELECT COALESCE(MAX(id), 0) FROM chat_msgs');
    }

    /**
     * Rows relevant to $node newer than $afterId, oldest first. 'sysophup'
     * is the console hanging up a private line (1.5.5): delivered only to
     * the node it names, so that caller learns the sysop left.
     * @return list<array<string, mixed>>
     */
    public static function since(Db $db, int $node, int $afterId, int $limit = 50): array
    {
        $rows = $db->all(
            "SELECT * FROM chat_msgs
             WHERE id > ?
               AND (
                     (to_node IS NULL AND kind IN ('chat', 'broadcast'))
                  OR (kind = 'sysop' AND (to_node = ? OR from_node = ?))
                  OR (kind = 'sysophup' AND to_node = ?)
                   )
             ORDER BY id LIMIT $limit",
            [$afterId, $node, $node, $node]
        );
        return $rows;
    }

    /**
     * Recent PUBLIC-room scrollback (chat + old broadcasts), oldest first.
     * Private sysop lines live on the SysopChat screen, not here.
     * @return list<array<string, mixed>>
     */
    public static function recent(Db $db, int $node, int $limit = 19): array
    {
        return array_reverse($db->all(
            "SELECT * FROM chat_msgs
             WHERE to_node IS NULL AND kind IN ('chat', 'broadcast')
             ORDER BY id DESC LIMIT $limit"
        ));
    }

    /**
     * A page of public-room history, $offset rows back from the newest,
     * oldest first (PgUp browsing). The room keeps its newest
     * Cron::KEEP_CHAT lines however old they are (1.6.1) -- age never
     * empties it; only the overflow beyond the cap is swept.
     * @return list<array<string, mixed>>
     */
    public static function history(Db $db, int $offset, int $limit = 19): array
    {
        $offset = max(0, $offset);
        return array_reverse($db->all(
            "SELECT * FROM chat_msgs
             WHERE to_node IS NULL AND kind IN ('chat', 'broadcast')
             ORDER BY id DESC LIMIT $limit OFFSET $offset"
        ));
    }

    /** Rows in the public room (for clamping history offsets). */
    public static function publicCount(Db $db): int
    {
        return (int)$db->val(
            "SELECT COUNT(*) FROM chat_msgs WHERE to_node IS NULL AND kind IN ('chat', 'broadcast')"
        );
    }

    /**
     * The private line: this node's conversation with the console, oldest
     * first (sysop → node, and node → console rows). $since bounds the
     * backfill to the CURRENT session (1.6.0 owner report: node numbers
     * recycle, so an unbounded pull replayed the previous caller's
     * break-in to whoever connected on that node next).
     * @return list<array<string, mixed>>
     */
    public static function recentSysop(Db $db, int $node, int $limit = 19, ?string $since = null): array
    {
        // The window over-fetches, then the cut applies in PHP through
        // strtotime: 'at' stamps arrive in more than one shape (ISO-T
        // from now_utc(), space-form from older tooling), and a lexical
        // SQL compare across shapes silently hid same-session rows.
        $fetch = $since === null || $since === '' ? $limit : $limit * 4;
        $rows = array_reverse($db->all(
            "SELECT * FROM chat_msgs
             WHERE kind = 'sysop' AND (to_node = ? OR from_node = ?)
             ORDER BY id DESC LIMIT $fetch",
            [$node, $node]
        ));
        if ($since !== null && $since !== '') {
            $cut = strtotime($since) ?: 0;
            $rows = array_values(array_filter(
                $rows,
                fn(array $r) => (strtotime((string)$r['at']) ?: PHP_INT_MAX) >= $cut
            ));
        }
        return array_slice($rows, -$limit);
    }

    /**
     * Page the sysop (§10.6): queue the request and raise the dashboard flag
     * (§9.6 settings sysop_paged_at/by). Returns whether the sysop claims to
     * be around (setting sysop_available, admin-toggled in M6).
     */
    public static function page(Db $db, Settings $settings, int $node, string $handle, string $reason): bool
    {
        self::post($db, $node, $handle, $reason, 'page');
        $settings->set('sysop_paged_at', now_utc());
        $settings->set('sysop_paged_by', mb_strimwidth($handle, 0, 20) . ' (node ' . $node . ')');
        $db->insert('events', [
            'at' => now_utc(),
            'level' => 'info',
            'actor' => 'node' . $node,
            'msg' => $handle . ' paged the sysop: ' . self::clean($reason),
        ]);
        // The bell follows the SysOp out of the room (1.6.1): any browser
        // subscribed in the console gets the page as a push notification.
        // Best-effort by contract -- the caller's ringing screen never
        // waits on a push service having a bad day.
        try {
            $base = rtrim(trim((string)($settings->get('base_url', '') ?? '')), '/');
            WebPush::notify(
                $db,
                $settings,
                (string)$settings->get('board_name', 'ATDT'),
                mb_strimwidth($handle, 0, 20) . ' is paging you (node ' . $node . '): ' . self::clean($reason),
                $base !== '' ? $base . '/admin/index.php' : ''
            );
        } catch (\Throwable $e) {
            error_log('[atdt:push] page notify failed: ' . $e->getMessage());
        }
        return $settings->bool('sysop_available', false);
    }

    /**
     * One chat row as a pipe-coded scrollback line. The room is persistent,
     * so the stamp carries the date (mm-dd hh:mm), rendered in the board's
     * timezone when one is given. Bodies are escaped unless the board
     * allows caller pipe colors (§19.7, allow_user_color).
     */
    public static function format(array $row, int $myNode, bool $allowColor = false, ?\DateTimeZone $tz = null): string
    {
        $at = (string)$row['at'];
        if ($tz !== null) {
            $t = date_create_immutable($at, new \DateTimeZone('UTC'));
            $stamp = $t === false
                ? substr($at, 5, 5) . ' ' . substr($at, 11, 5)
                : $t->setTimezone($tz)->format('m-d H:i');
        } else {
            $stamp = substr($at, 5, 5) . ' ' . substr($at, 11, 5);
        }
        $body = $allowColor ? (string)$row['body'] : Pipe::escape((string)$row['body']);
        if ((int)$row['from_node'] === 0) {
            // From the console: the sysop, whether in public chat or private.
            return "|08$stamp |12<SYSOP>|07 $body";
        }
        $handle = Pipe::escape((string)$row['from_handle']);
        $who = (int)$row['from_node'] === $myNode ? "|14<$handle>|07" : "|11<$handle>|07";
        return "|08$stamp $who $body";
    }

    /** Strip controls + pipes-hostile input, cap length (§19.7). */
    public static function clean(string $body, int $max = self::MAX_BODY): string
    {
        $body = preg_replace('/[\x00-\x1f\x7f]/', '', $body) ?? '';
        return mb_strimwidth(trim($body), 0, $max);
    }
}
