/**
 * Notifications REST client — typed wrapper around the slice-6 endpoints
 * (`/notifications`, `/notifications/unread-count`,
 * `/notifications/:id/read`, `/notifications/read-all`).
 *
 * Lives in `cms-admin` so the bell, the toast layer, and any future
 * background sync agent share one request path, error model, and DTO
 * shape. The wire format mirrors `NotificationDTO` in
 * `@actuate-media/cms-core/realtime/notifications.ts`; we duplicate the
 * shape here to avoid taking a hard dependency on the server module
 * (which carries Prisma types that don't belong in the admin bundle).
 */

import { cmsApi, getApiBase } from './api.js'

export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'

export interface CommentReplyPayload {
  kind: 'comment_reply'
  actorId: string | null
  actorName: string | null
  excerpt: string
}

export interface CommentResolvedPayload {
  kind: 'comment_resolved'
  actorId: string | null
  actorName: string | null
  rootExcerpt: string
}

export interface CommentMentionPayload {
  kind: 'comment_mention'
  actorId: string | null
  actorName: string | null
  excerpt: string
}

export type NotificationPayload =
  | CommentReplyPayload
  | CommentResolvedPayload
  | CommentMentionPayload

export interface Notification {
  id: string
  userId: string
  kind: NotificationKind
  documentId: string | null
  commentId: string | null
  payload: NotificationPayload
  readAt: string | null
  createdAt: string
}

export type NotificationOutcome<T> =
  | { ok: true; result: T }
  | { ok: false; error: string; status: number }

export interface ListNotificationsOptions {
  /** Default: false. When true the response includes already-read rows. */
  includeRead?: boolean
  /** Default: server-side cap. Hard upper bound is 200. */
  pageSize?: number
}

function failure(error: string, status: number): { ok: false; error: string; status: number } {
  return { ok: false, error, status }
}

function envelope<T>(res: {
  data?: unknown
  error?: string
  status: number
}): NotificationOutcome<T> {
  if (res.error || res.data === undefined) {
    return failure(res.error ?? `Request failed (${res.status})`, res.status)
  }
  return { ok: true, result: res.data as T }
}

function buildQuery(opts: ListNotificationsOptions = {}): string {
  const params = new URLSearchParams()
  if (opts.includeRead) params.set('includeRead', 'true')
  if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
  const qs = params.toString()
  return qs ? `?${qs}` : ''
}

export async function listNotifications(
  opts: ListNotificationsOptions = {},
): Promise<NotificationOutcome<Notification[]>> {
  const res = await cmsApi<Notification[]>(`/notifications${buildQuery(opts)}`, {
    method: 'GET',
  })
  return envelope<Notification[]>(res)
}

export async function unreadCount(): Promise<NotificationOutcome<number>> {
  const res = await cmsApi<{ count: number }>(`/notifications/unread-count`, {
    method: 'GET',
  })
  if (res.error || !res.data) {
    return failure(res.error ?? `Request failed (${res.status})`, res.status)
  }
  return { ok: true, result: res.data.count }
}

export async function markNotificationRead(id: string): Promise<NotificationOutcome<Notification>> {
  if (!id) return failure('notification id is required', 0)
  const res = await cmsApi<Notification>(`/notifications/${encodeURIComponent(id)}/read`, {
    method: 'POST',
    body: '{}',
  })
  return envelope<Notification>(res)
}

export async function markAllNotificationsRead(): Promise<NotificationOutcome<number>> {
  const res = await cmsApi<{ count: number }>(`/notifications/read-all`, {
    method: 'POST',
    body: '{}',
  })
  if (res.error || !res.data) {
    return failure(res.error ?? `Request failed (${res.status})`, res.status)
  }
  return { ok: true, result: res.data.count }
}

/**
 * Subscribe to the per-user SSE push channel that ships notifications
 * the instant they're persisted server-side. Replaces the 30-second
 * poll loop in the notification bell.
 *
 * The browser's native `EventSource` handles reconnection for us — on
 * a server-initiated close (the stream caps at ~4 minutes by design),
 * the connection re-opens automatically. We DO NOT abort the poll
 * fallback in callers: SSE is a latency-win, the poll is what keeps
 * multi-node deployments correct (each node only pushes to its own
 * subscribers).
 *
 * Returns a `close()` function that callers MUST invoke on unmount —
 * otherwise the browser keeps the connection open and we leak event
 * listeners.
 *
 * `EventSource` itself isn't available in the JSDOM test environment;
 * callers can inject a `factory` to substitute a test double. In
 * production we fall back to the global `EventSource` when no factory
 * is supplied.
 */
export interface NotificationStreamOptions {
  /** Called for every `event: notification` frame. */
  onNotification: (n: Notification) => void
  /** Called once on the `event: ready` frame. */
  onReady?: (payload: { userId: string }) => void
  /** Called on connection errors. Useful for falling back to the poll loop. */
  onError?: (event: Event) => void
  /**
   * Optional factory so unit tests can inject a fake `EventSource`,
   * or a null sentinel when the environment lacks one (SSR, JSDOM
   * without a polyfill). Production code can rely on the default
   * (global `EventSource`).
   */
  factory?: (url: string) => EventSourceLike | null
}

/**
 * Minimal subset of the `EventSource` interface our subscriber relies
 * on. Browsers ship the full DOM type; the admin only needs add/remove
 * event listeners plus `close()`.
 */
export interface EventSourceLike {
  addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
  removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
  close: () => void
}

export interface NotificationStreamHandle {
  /** Tear down the EventSource and stop receiving events. Idempotent. */
  close: () => void
}

export function subscribeNotificationStream(
  opts: NotificationStreamOptions,
): NotificationStreamHandle {
  // The `cmsApi()` helper handles base-path / origin resolution for
  // fetch calls, but EventSource takes a raw URL. We mirror its
  // logic locally — `/api/cms/notifications/stream` is correct for
  // both the local dev server and any production deploy that mounts
  // the CMS API at the default prefix. Custom prefixes are handled by
  // the consumer override (factory).
  // Resolve against `setApiBase()` so a consumer mounting the CMS at
  // a non-default prefix (e.g. `/cms-api`) still hits the right SSE
  // endpoint — `EventSource` doesn't go through `cmsApi()`, so we
  // can't rely on its url-construction logic.
  const url = `${getApiBase()}/notifications/stream`
  const make = opts.factory ?? defaultEventSourceFactory
  const source = make(url)
  if (!source) {
    // No EventSource available (SSR? JSDOM without polyfill? Or the
    // injected factory deliberately returns null in tests). Return a
    // no-op handle so callers can compose without conditional logic.
    return { close: () => {} }
  }
  let closed = false

  const onReady = (event: MessageEvent | Event) => {
    if (!opts.onReady) return
    const data = parseEventData(event)
    if (data && typeof (data as { userId?: unknown }).userId === 'string') {
      opts.onReady({ userId: (data as { userId: string }).userId })
    }
  }
  const onNotification = (event: MessageEvent | Event) => {
    const data = parseEventData(event)
    if (data && isNotification(data)) {
      opts.onNotification(data)
    }
  }
  const onError = (event: Event) => {
    opts.onError?.(event)
  }

  source.addEventListener('ready', onReady)
  source.addEventListener('notification', onNotification)
  source.addEventListener('error', onError)

  return {
    close: () => {
      if (closed) return
      closed = true
      source.removeEventListener?.('ready', onReady)
      source.removeEventListener?.('notification', onNotification)
      source.removeEventListener?.('error', onError)
      source.close()
    },
  }
}

function defaultEventSourceFactory(url: string): EventSourceLike | null {
  if (typeof EventSource === 'undefined') return null
  // `withCredentials: true` so the admin session cookie travels with
  // the connection upgrade — every notification stream requires auth
  // and the same-origin cookie is the only credential we trust here.
  return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
}

function parseEventData(event: MessageEvent | Event): unknown {
  // EventSource emits MessageEvents with `data: string`. Plain
  // `Event` (e.g. the `error` event) has no payload; ignore.
  const data = (event as MessageEvent).data
  if (typeof data !== 'string') return null
  try {
    return JSON.parse(data)
  } catch {
    return null
  }
}

function isNotification(value: unknown): value is Notification {
  if (!value || typeof value !== 'object') return false
  const v = value as Record<string, unknown>
  // Defensive: don't trust the wire blindly — verify the discriminant
  // properties our consumers will read. A drift in the server-side
  // wire format must surface as "missing fields" in the test suite,
  // not as runtime errors deep in the bell's render path.
  return (
    typeof v.id === 'string' &&
    typeof v.userId === 'string' &&
    typeof v.createdAt === 'string' &&
    !!v.payload &&
    typeof (v.payload as { kind?: unknown }).kind === 'string'
  )
}

/**
 * Cheap single-line summary for the bell list — the server gives us a
 * structured payload, but the dropdown wants one human-readable string
 * per row. Falls back to `Notification` when an unknown kind sneaks in
 * (defensive against future server-side additions).
 */
export function notificationSummary(notification: Notification): string {
  const actor = notification.payload.actorName ?? 'Someone'
  switch (notification.payload.kind) {
    case 'comment_reply':
      return `${actor} replied: "${notification.payload.excerpt}"`
    case 'comment_resolved':
      return `${actor} resolved your thread: "${notification.payload.rootExcerpt}"`
    case 'comment_mention':
      return `${actor} mentioned you: "${notification.payload.excerpt}"`
    default:
      return 'Notification'
  }
}
