'use client'

import React, { useCallback, useEffect, useRef, useState } from 'react'

import {
  type Notification,
  type NotificationOutcome,
  type NotificationStreamHandle,
  type NotificationStreamOptions,
  listNotifications as defaultListNotifications,
  markAllNotificationsRead as defaultMarkAllRead,
  markNotificationRead as defaultMarkRead,
  notificationSummary,
  subscribeNotificationStream as defaultSubscribeStream,
  unreadCount as defaultUnreadCount,
} from '../lib/notifications-client.js'

/**
 * Top-bar notification bell + dropdown panel. Slice 6 of Phase 3
 * surfaces every collaboration event that affects the current user —
 * comment replies, resolves, and mentions — without forcing the
 * editor to round-trip through the comments side panel.
 *
 * Design choices:
 *
 * - **SSE push, with poll fallback (Phase 7).** A persistent
 *   `EventSource` subscribes to `/api/cms/notifications/stream` for
 *   instant delivery; new notifications bump the badge immediately
 *   instead of after the next 30 s poll tick. The poll loop is kept
 *   alive at a back-pressure-friendly cadence (default 60 s — twice
 *   the legacy 30 s value because SSE handles the common case) and
 *   acts as a backstop for:
 *     1. multi-node deployments where the in-process bus only
 *        publishes to subscribers on the same node, and
 *     2. proxies / browsers that silently drop the EventSource
 *        without firing `onerror`.
 * - **Optimistic read state.** When the user clicks a notification we
 *   mark it read locally before the server confirms — so the bell
 *   counter feels instant. Server failures roll the state back via
 *   the `onError` toast.
 * - **API injection.** The dependency-injection seam matches the rest
 *   of the admin (`CommentSidePanel.api`) so unit tests can substitute
 *   in-memory fakes — including the EventSource subscriber, which
 *   isn't available in JSDOM by default.
 */
export interface NotificationsApi {
  list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
  unread: () => Promise<NotificationOutcome<number>>
  markRead: (id: string) => Promise<NotificationOutcome<Notification>>
  markAllRead: () => Promise<NotificationOutcome<number>>
  /**
   * Open a per-user SSE subscription. Implementations MUST always
   * return a handle — even in environments without `EventSource`
   * (SSR, JSDOM without a polyfill), where a no-op `{ close() {} }`
   * is the right answer. The bell's effect calls `handle.close()`
   * unconditionally on unmount; a `null` return would crash.
   * Omitting the member entirely is the supported way to opt out of
   * SSE (the bell then runs poll-only).
   */
  subscribe?: (opts: NotificationStreamOptions) => NotificationStreamHandle
}

const defaultApi: NotificationsApi = {
  list: (opts) => defaultListNotifications(opts),
  unread: () => defaultUnreadCount(),
  markRead: (id) => defaultMarkRead(id),
  markAllRead: () => defaultMarkAllRead(),
  subscribe: (opts) => defaultSubscribeStream(opts),
}

export interface NotificationBellProps {
  /**
   * Polling cadence in milliseconds for the fallback loop. Default
   * 60,000 (twice the pre-SSE value — push handles the common case).
   * Pass 0 to disable polling entirely (relies on SSE only — only
   * safe on single-node deployments).
   */
  pollIntervalMs?: number
  /** Optional click hook — called with the notification when a row is clicked. */
  onSelect?: (notification: Notification) => void
  /** Error sink — bubble server failures to a toast / banner. */
  onError?: (message: string) => void
  /** Override the API client (tests). */
  api?: NotificationsApi
  className?: string
}

export function NotificationBell({
  pollIntervalMs = 60_000,
  onSelect,
  onError,
  api = defaultApi,
  className,
}: NotificationBellProps): React.JSX.Element {
  const [open, setOpen] = useState(false)
  const [unread, setUnread] = useState(0)
  const [items, setItems] = useState<Notification[]>([])
  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<Set<string>>(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: Notification) => {
      // 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 (
    <div className={`relative ${className ?? ''}`} data-testid="notification-bell">
      <button
        type="button"
        className="text-muted-foreground hover:bg-accent hover:text-foreground relative rounded p-1.5"
        aria-label="Notifications"
        aria-haspopup="menu"
        aria-expanded={open}
        onClick={() => setOpen((v) => !v)}
        data-testid="bell-button"
      >
        <BellIcon />
        {unread > 0 && (
          <span
            className="bg-destructive absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium text-white"
            data-testid="bell-badge"
          >
            {unread > 99 ? '99+' : unread}
          </span>
        )}
      </button>
      {open && (
        <div
          role="menu"
          className="border-border bg-card absolute right-0 z-50 mt-1 w-80 rounded border shadow-lg"
          data-testid="bell-dropdown"
        >
          <header className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
            <span className="text-foreground text-sm font-medium">Notifications</span>
            <button
              type="button"
              className="text-muted-foreground hover:text-foreground text-xs disabled:opacity-50"
              onClick={handleMarkAll}
              disabled={unread === 0}
              data-testid="bell-mark-all"
            >
              Mark all read
            </button>
          </header>
          <div className="max-h-80 overflow-y-auto" aria-busy={loading} data-testid="bell-list">
            {loading ? (
              <p className="text-muted-foreground px-3 py-2 text-xs">Loading…</p>
            ) : items.length === 0 ? (
              <p className="text-muted-foreground px-3 py-2 text-xs">All caught up.</p>
            ) : (
              <ul>
                {items.map((n) => (
                  <li key={n.id}>
                    <button
                      type="button"
                      role="menuitem"
                      className={`hover:bg-accent block w-full px-3 py-2 text-left text-xs ${
                        n.readAt ? 'text-muted-foreground' : 'text-foreground font-medium'
                      }`}
                      onClick={() => handleClickRow(n)}
                      data-testid={`bell-row-${n.id}`}
                      data-read={n.readAt ? 'true' : 'false'}
                    >
                      <div>{notificationSummary(n)}</div>
                      <div className="text-muted-foreground mt-0.5 text-[10px]">{n.createdAt}</div>
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </div>
        </div>
      )}
    </div>
  )
}

function BellIcon(): React.JSX.Element {
  return (
    <svg
      aria-hidden="true"
      width="18"
      height="18"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
      <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
    </svg>
  )
}
