'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useEffect, useRef, useState } from 'react';
import { listNotifications as defaultListNotifications, markAllNotificationsRead as defaultMarkAllRead, markNotificationRead as defaultMarkRead, notificationSummary, subscribeNotificationStream as defaultSubscribeStream, unreadCount as defaultUnreadCount, } from '../lib/notifications-client.js';
const defaultApi = {
    list: (opts) => defaultListNotifications(opts),
    unread: () => defaultUnreadCount(),
    markRead: (id) => defaultMarkRead(id),
    markAllRead: () => defaultMarkAllRead(),
    subscribe: (opts) => defaultSubscribeStream(opts),
};
export function NotificationBell({ pollIntervalMs = 60_000, onSelect, onError, api = defaultApi, className, }) {
    const [open, setOpen] = useState(false);
    const [unread, setUnread] = useState(0);
    const [items, setItems] = useState([]);
    const [loading, setLoading] = useState(false);
    const mountedRef = useRef(true);
    // Set of notification IDs we've already rendered. The SSE handler
    // consults this synchronously so duplicate deliveries (REST refresh
    // + SSE push, or two SSE retries) don't double-bump the badge.
    //
    // The ref is updated **synchronously** at every call site that
    // changes the set of items (REST refresh, SSE push, dropdown clear).
    // A prior implementation reconciled it via `useEffect([items])`,
    // but that effect runs *after* React commits — so an SSE push that
    // arrives between the REST list resolving (`items` mutated) and the
    // effect running would read a stale ref, fall through the
    // de-dup gate, and append a duplicate row. The reproducible
    // regression was caught by the test
    // `does not double-count a notification that arrives via both
    // SSE and REST`.
    const seenIdsRef = useRef(new Set());
    useEffect(() => {
        return () => {
            mountedRef.current = false;
        };
    }, []);
    const refreshUnread = useCallback(async () => {
        const out = await api.unread();
        if (!mountedRef.current)
            return;
        if (!out.ok) {
            onError?.(out.error);
            return;
        }
        setUnread(out.result);
    }, [api, onError]);
    const refreshList = useCallback(async (includeRead = false) => {
        setLoading(true);
        const out = await api.list({ includeRead });
        if (!mountedRef.current)
            return;
        if (!out.ok) {
            onError?.(out.error);
            setItems([]);
            seenIdsRef.current = new Set();
        }
        else {
            setItems(out.result);
            seenIdsRef.current = new Set(out.result.map((n) => n.id));
        }
        setLoading(false);
    }, [api, onError]);
    useEffect(() => {
        void refreshUnread();
        if (!pollIntervalMs)
            return;
        const id = setInterval(() => void refreshUnread(), pollIntervalMs);
        return () => clearInterval(id);
    }, [refreshUnread, pollIntervalMs]);
    // SSE push subscription. Bumps the badge + prepends the new row to
    // the open dropdown the instant the server publishes. Falls back
    // silently when the api/runtime doesn't support EventSource — the
    // poll loop above keeps the bell correct in that case.
    useEffect(() => {
        if (!api.subscribe)
            return;
        const handle = api.subscribe({
            onNotification: (n) => {
                if (!mountedRef.current)
                    return;
                // SSE events can race with REST refreshes (or with their own
                // retries). Two gates close the door on duplicates:
                //   1. **Synchronous ref check** — fast-path that also
                //      gates the unread badge bump, so a second delivery of
                //      the same row never drifts the counter. The ref is
                //      maintained synchronously by `refreshList` so the
                //      check sees a consistent snapshot.
                //   2. **Inner setItems check** — final safety net. The
                //      updater sees the latest `arr` snapshot React has
                //      queued, which may differ from the ref if a
                //      `refreshList` resolved between this handler's ref
                //      read and the updater running. Without it, an
                //      interleaved REST refresh could leave `items` with a
                //      duplicate row.
                if (seenIdsRef.current.has(n.id))
                    return;
                seenIdsRef.current.add(n.id);
                setItems((arr) => {
                    if (arr.some((existing) => existing.id === n.id))
                        return arr;
                    return [n, ...arr];
                });
                if (!n.readAt)
                    setUnread((v) => v + 1);
            },
            onError: () => {
                // Don't surface every transient SSE disconnect as a UI error —
                // the browser auto-reconnects, and the poll loop keeps state
                // converging. We deliberately stay quiet here.
            },
        });
        return () => handle.close();
    }, [api]);
    useEffect(() => {
        if (!open)
            return;
        void refreshList(false);
    }, [open, refreshList]);
    const handleClickRow = useCallback(async (n) => {
        // Optimistic update: drop the unread count + tag the row read.
        if (!n.readAt) {
            setUnread((v) => Math.max(0, v - 1));
            setItems((arr) => arr.map((row) => (row.id === n.id ? { ...row, readAt: new Date().toISOString() } : row)));
            const out = await api.markRead(n.id);
            if (!out.ok) {
                onError?.(out.error);
                // Roll back on failure.
                setUnread((v) => v + 1);
                setItems((arr) => arr.map((row) => (row.id === n.id ? { ...row, readAt: null } : row)));
            }
        }
        onSelect?.(n);
    }, [api, onError, onSelect]);
    const handleMarkAll = useCallback(async () => {
        // Snapshot ONLY the previously-unread IDs (and their original
        // readAt values, which are null by definition). On rollback we
        // restore those specific rows and leave anything that arrived
        // via SSE while the request was in flight alone — otherwise a
        // failed mark-all-read would drop fresh notifications.
        const previouslyUnreadIds = items.filter((n) => n.readAt === null).map((n) => n.id);
        if (previouslyUnreadIds.length === 0)
            return;
        const ids = new Set(previouslyUnreadIds);
        const now = new Date().toISOString();
        setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: n.readAt ?? now } : n)));
        setUnread(0);
        const out = await api.markAllRead();
        if (!out.ok) {
            onError?.(out.error);
            // Functional rollback: only revert the rows we optimistically
            // marked. Anything mid-flight (SSE arrivals, locally-added) is
            // untouched.
            setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: null } : n)));
            await refreshUnread();
        }
    }, [api, items, onError, refreshUnread]);
    return (_jsxs("div", { className: `relative ${className ?? ''}`, "data-testid": "notification-bell", children: [_jsxs("button", { type: "button", className: "relative rounded p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900", "aria-label": "Notifications", "aria-haspopup": "menu", "aria-expanded": open, onClick: () => setOpen((v) => !v), "data-testid": "bell-button", children: [_jsx(BellIcon, {}), unread > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold text-white", "data-testid": "bell-badge", children: unread > 99 ? '99+' : unread }))] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 z-50 mt-1 w-80 rounded border border-gray-200 bg-white shadow-lg", "data-testid": "bell-dropdown", children: [_jsxs("header", { className: "flex items-center justify-between border-b border-gray-100 px-3 py-2", children: [_jsx("span", { className: "text-sm font-semibold text-gray-900", children: "Notifications" }), _jsx("button", { type: "button", className: "text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50", onClick: handleMarkAll, disabled: unread === 0, "data-testid": "bell-mark-all", children: "Mark all read" })] }), _jsx("div", { className: "max-h-80 overflow-y-auto", "aria-busy": loading, "data-testid": "bell-list", children: loading ? (_jsx("p", { className: "px-3 py-2 text-xs text-gray-500", children: "Loading\u2026" })) : items.length === 0 ? (_jsx("p", { className: "px-3 py-2 text-xs text-gray-500", children: "All caught up." })) : (_jsx("ul", { children: items.map((n) => (_jsx("li", { children: _jsxs("button", { type: "button", role: "menuitem", className: `block w-full px-3 py-2 text-left text-xs hover:bg-gray-50 ${n.readAt ? 'text-gray-500' : 'font-medium text-gray-900'}`, onClick: () => handleClickRow(n), "data-testid": `bell-row-${n.id}`, "data-read": n.readAt ? 'true' : 'false', children: [_jsx("div", { children: notificationSummary(n) }), _jsx("div", { className: "mt-0.5 text-[10px] text-gray-400", children: n.createdAt })] }) }, n.id))) })) })] }))] }));
}
function BellIcon() {
    return (_jsxs("svg", { "aria-hidden": "true", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }), _jsx("path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" })] }));
}
//# sourceMappingURL=NotificationBell.js.map