// @design-system: notification/NotificationList
/**
 * NotificationList — scrollable notification inbox with infinite scroll.
 *
 * Fetches from GET /api/notifications?afterCursor=&limit= via react-query.
 * Supports cursor-based pagination (load-more on scroll-to-bottom).
 * Optimistic read state via `useQueryClient` invalidation.
 *
 * @wave 2c - UI components
 */
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useInfiniteQuery, useQueryClient } from '@/features/query/react-query';
import { useLocale, useT } from '@/lib/i18n/react';
import { ErrorState } from '@/components/ui/feedback/ErrorState';
import { EmptyState } from '@/components/ui/feedback/EmptyState';
import { Spinner } from '@/components/ui/feedback/Spinner';
import { Button } from '@/components/ui/primitives/Button';
import { Icon } from '@/components/ui/icons/Icon';
import {
  Toast,
  ToastAction,
  ToastClose,
  ToastDescription,
  ToastProvider,
  ToastTitle,
  ToastViewport,
  useToast,
} from '@/components/ui/overlays/Toast';
import { pickLocalized } from '@/lib/i18n';
import { getCsrfToken } from '@/lib/csrf';
import { captureCaught } from '@/lib/observability';
import { NotificationItem } from './NotificationItem';
import type { NotificationRow } from '@/server/db/queries/live-notifications';

const PAGE_SIZE = 20;

interface NotifPage {
  items: NotificationRow[];
  unreadCount: number;
  nextCursor: string | null;
}

async function fetchPage(afterCursor: string | null): Promise<NotifPage> {
  const params = new URLSearchParams({ limit: String(PAGE_SIZE) });
  if (afterCursor) params.set('afterCursor', afterCursor);
  const res = await fetch(`/api/notifications?${params.toString()}`);
  if (!res.ok) throw new Error('Failed to load notifications');
  const json = (await res.json()) as { ok: boolean } & NotifPage;
  if (!json.ok) throw new Error('Failed to load notifications');
  return json;
}

async function markRead(ids: string[], all = false): Promise<void> {
  const csrf = getCsrfToken();
  const res = await fetch('/api/notifications', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf },
    body: JSON.stringify(all ? { all: true } : { ids }),
  });
  if (!res.ok) throw new Error('Failed to mark notifications as read');
}

export interface NotificationListProps {
  /** Called when unread count changes (e.g. to update bell badge). */
  onUnreadCountChange?: (count: number) => void;
}

type NotificationGroup = {
  key: string;
  label: string;
  items: NotificationRow[];
};

function startOfDayKey(date: Date): string {
  return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}

function dayLabel(date: Date, now: Date, locale: string, t: (key: string) => string): string {
  const dayMs = 24 * 60 * 60 * 1000;
  const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const diffDays = Math.round((today - target) / dayMs);
  if (diffDays === 0) return t('group_today');
  if (diffDays === 1) return t('group_yesterday');
  return new Intl.DateTimeFormat(locale, {
    day: 'numeric',
    month: 'long',
    year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
  }).format(date);
}

export function groupNotificationsByDay(
  items: NotificationRow[],
  locale: string,
  t: (key: string) => string,
  now = new Date(),
): NotificationGroup[] {
  const groups: NotificationGroup[] = [];
  for (const item of items) {
    const createdAt = new Date(item.createdAt);
    const key = startOfDayKey(createdAt);
    const label = dayLabel(createdAt, now, locale, t);
    const prev = groups[groups.length - 1];
    if (prev && prev.key === key) {
      prev.items.push(item);
      continue;
    }
    groups.push({ key, label, items: [item] });
  }
  return groups;
}

export function NotificationList({ onUnreadCountChange }: NotificationListProps) {
  const t = useT('notif');
  const { locale } = useLocale();
  const qc = useQueryClient();
  const bottomRef = useRef<HTMLDivElement>(null);
  const { toasts, toast, dismiss } = useToast();
  const [dismissed, setDismissed] = useState<NotificationRow[]>([]);

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, refetch } =
    useInfiniteQuery<
      NotifPage,
      Error,
      { pages: NotifPage[]; pageParams: (string | null)[] },
      string[],
      string | null
    >({
      queryKey: ['notifications'],
      queryFn: ({ pageParam }) => fetchPage(pageParam),
      initialPageParam: null,
      getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
    });

  // Notify parent of latest API-provided unread count
  useEffect(() => {
    const lastPage = data?.pages[data.pages.length - 1];
    if (lastPage && onUnreadCountChange) {
      onUnreadCountChange(lastPage.unreadCount);
    }
  }, [data, onUnreadCountChange]);

  // Intersection observer — load next page when bottom sentinel visible
  useEffect(() => {
    const sentinel = bottomRef.current;
    if (!sentinel) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
          void fetchNextPage();
        }
      },
      { threshold: 0.1 },
    );
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  // Optimistic mark-read + invalidate
  const handleRead = useCallback(
    async (id: string) => {
      const previousData = qc.getQueryData<{
        pages: NotifPage[];
        pageParams: (string | null)[];
      }>(['notifications']);
      // Optimistic: update local cache immediately
      qc.setQueryData<{ pages: NotifPage[]; pageParams: (string | null)[] }>(
        ['notifications'],
        (old) => {
          if (!old) return old;
          const wasUnread = old.pages.some((page) =>
            (page?.items ?? []).some((n) => n.id === id && !n.readAt),
          );
          return {
            ...old,
            pages: old.pages.map((page) => ({
              ...page,
              unreadCount: wasUnread ? Math.max(0, page.unreadCount - 1) : page.unreadCount,
              items: (page?.items ?? []).map((n) =>
                n.id === id ? { ...n, readAt: new Date() } : n,
              ),
            })),
          };
        },
      );
      try {
        await markRead([id]);
      } catch (err) {
        try {
          await qc.invalidateQueries({ queryKey: ['notifications'] });
        } catch (refreshErr) {
          qc.setQueryData(['notifications'], previousData);
          captureCaught(refreshErr, {
            scope: 'components.ui.notification.NotificationList.handleRead.refresh',
            severity: 'warning',
          });
        }
        captureCaught(err, {
          scope: 'components.ui.notification.NotificationList.handleRead',
          severity: 'warning',
        });
        toast({ title: t('mark_read_error'), tone: 'danger' });
        return;
      }

      try {
        await qc.invalidateQueries({ queryKey: ['notifications'] });
      } catch (refreshErr) {
        captureCaught(refreshErr, {
          scope: 'components.ui.notification.NotificationList.handleRead.refresh',
          severity: 'warning',
        });
      }
    },
    [qc, t, toast],
  );

  const allItems = useMemo(() => data?.pages.flatMap((p) => p?.items ?? []) ?? [], [data]);
  const visibleItems = useMemo(() => {
    if (dismissed.length === 0) return allItems;
    const hiddenIds = new Set(dismissed.map((item) => item.id));
    return allItems.filter((item) => !hiddenIds.has(item.id));
  }, [allItems, dismissed]);
  const unreadCount = data?.pages[data.pages.length - 1]?.unreadCount ?? 0;
  const groups = useMemo(
    () => groupNotificationsByDay(visibleItems, locale, t as (key: string) => string),
    [visibleItems, locale, t],
  );

  let content: ReactNode;

  async function handleMarkAllRead() {
    if (unreadCount === 0) return;
    qc.setQueryData<{ pages: NotifPage[]; pageParams: (string | null)[] }>(
      ['notifications'],
      (old) => {
        if (!old) return old;
        return {
          ...old,
          pages: old.pages.map((page) => ({
            ...page,
            unreadCount: 0,
            items: page.items.map((item) =>
              item.readAt === null ? { ...item, readAt: new Date() } : item,
            ),
          })),
        };
      },
    );
    try {
      await markRead([], true);
    } catch (err) {
      try {
        await qc.invalidateQueries({ queryKey: ['notifications'] });
      } catch (refreshErr) {
        captureCaught(refreshErr, {
          scope: 'components.ui.notification.NotificationList.markAllRead.refresh',
          severity: 'warning',
        });
      }
      captureCaught(err, {
        scope: 'components.ui.notification.NotificationList.markAllRead',
        severity: 'warning',
      });
      return;
    }
    try {
      await qc.invalidateQueries({ queryKey: ['notifications'] });
    } catch (refreshErr) {
      captureCaught(refreshErr, {
        scope: 'components.ui.notification.NotificationList.markAllRead.refresh',
        severity: 'warning',
      });
    }
  }

  function handleDismiss(item: NotificationRow) {
    setDismissed((prev) => [...prev, item]);
    toast({
      title: t('dismiss_title'),
      description: pickLocalized(item, locale, 'title') ?? t('item_untitled'),
      tone: 'neutral',
      action: {
        label: t('dismiss_undo'),
        altText: t('dismiss_undo'),
        onClick: () => {
          setDismissed((prev) => prev.filter((entry) => entry.id !== item.id));
        },
      },
    });
  }

  if (isError) {
    content = (
      <ErrorState
        title={t('list_error')}
        action={
          <Button variant="secondary" size="sm" onClick={() => void refetch()}>
            {t('list_retry')}
          </Button>
        }
      />
    );
  } else if (isLoading) {
    content = (
      <div className="flex min-h-30 items-center justify-center">
        <Spinner size="md" />
        <span className="sr-only">{t('list_loading')}</span>
      </div>
    );
  } else if (visibleItems.length === 0) {
    content = (
      <EmptyState
        icon={<Icon name="Bell" size="lg" color="muted" />}
        title={t('list_empty')}
        description={t('list_empty_desc')}
      />
    );
  } else {
    let staggerIndex = 0;

    content = (
      <div className="flex max-h-[70dvh] flex-col overflow-y-auto">
        <div className="border-border-default flex items-center justify-between border-b px-4 py-3">
          <span className="text-text-primary text-sm font-semibold">{t('bell_aria')}</span>
          <Button
            variant="ghost"
            size="sm"
            onClick={() => void handleMarkAllRead()}
            disabled={unreadCount === 0}
          >
            {t('mark_all')}
          </Button>
        </div>

        <div aria-label={t('bell_aria')} className="flex flex-col gap-2 py-1">
          {groups.map((group) => {
            return (
              <section key={group.key} aria-labelledby={`notif-group-${group.key}`}>
                <div className="bg-surface-base/95 sticky top-0 z-10 px-3 py-2 backdrop-blur-sm">
                  <h3
                    id={`notif-group-${group.key}`}
                    className="text-text-muted text-xs font-medium"
                  >
                    {group.label}
                  </h3>
                </div>
                <ul className="flex flex-col gap-0.5 px-1">
                  {group.items.map((item) => (
                    <NotificationItem
                      key={item.id}
                      notification={item}
                      onRead={handleRead}
                      onDismiss={handleDismiss}
                      staggerIndex={staggerIndex++}
                    />
                  ))}
                </ul>
              </section>
            );
          })}
        </div>

        <div ref={bottomRef} aria-hidden className="h-1 shrink-0" />

        {isFetchingNextPage && (
          <div className="flex justify-center py-3">
            <Spinner size="sm" />
          </div>
        )}
      </div>
    );
  }

  return (
    <ToastProvider swipeDirection="left">
      {content}
      {toasts.map((item) => (
        <Toast
          key={item.id}
          tone={item.tone ?? 'neutral'}
          duration={item.duration ?? 5000}
          onOpenChange={(open) => {
            if (!open) dismiss(item.id);
          }}
        >
          <div className="flex flex-col gap-1">
            <ToastTitle>{item.title}</ToastTitle>
            {item.description ? <ToastDescription>{item.description}</ToastDescription> : null}
          </div>
          {item.action ? (
            <ToastAction altText={item.action.altText} onClick={item.action.onClick}>
              {item.action.label}
            </ToastAction>
          ) : null}
          <ToastClose />
        </Toast>
      ))}
      <ToastViewport />
    </ToastProvider>
  );
}
