import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from 'react';
import { api } from '../api/client';
import type { Tone } from './format';
import type { IconName } from '../components/Icon';

/* The administrative feed, shared by the bell in the top bar and the activity page.
 *
 * One provider rather than a hook per consumer, because there must be exactly one live
 * connection: the stream is a held-open HTTP request, and a bell and a page each opening
 * their own would double it — and would let the badge and the list disagree about what
 * has arrived, which is the one thing a notification count must never do. */

export interface AdminEvent {
  id: number;
  occurredAt: string;
  type: string;
  severity: 'info' | 'warning' | 'error';
  title: string;
  summary: string;
  actor?: string;
  target?: string;
  link?: string;
  metadata?: Record<string, unknown>;
  readAt?: string;
}

export interface EventTypeCount {
  type: string;
  count: number;
}

interface NotificationState {
  events: AdminEvent[];
  unread: number;
  types: EventTypeCount[];
  /** connected is whether the live stream is attached. It is shown, because "nothing has
   *  happened" and "we stopped being told" look identical otherwise. */
  connected: boolean;
  error: string;
  markRead: (ids: number[]) => Promise<void>;
  markAllRead: () => Promise<void>;
  reload: () => Promise<void>;
}

const NotificationContext = createContext<NotificationState | null>(null);

/* How many events the bell holds. The activity page fetches its own window with filters;
   this is the dropdown's list and the source of the badge. */
const FEED_LIMIT = 50;

/* If the stream cannot be established, fall back to polling. An SSE connection can be
   defeated by an intermediary the console knows nothing about, and a bell that silently
   stops working is worse than one that is a little late. */
const FALLBACK_POLL_MS = 30_000;

interface FeedResponse {
  events: AdminEvent[];
  total: number;
  unread: number;
  types: EventTypeCount[];
  subscribers: number;
}

export function NotificationProvider({ children }: { children: ReactNode }) {
  const [events, setEvents] = useState<AdminEvent[]>([]);
  const [unread, setUnread] = useState(0);
  const [types, setTypes] = useState<EventTypeCount[]>([]);
  const [connected, setConnected] = useState(false);
  const [error, setError] = useState('');
  const latestId = useRef(0);

  const reload = useCallback(async () => {
    try {
      const feed = await api.get<FeedResponse>(`/admin/api/notifications?limit=${FEED_LIMIT}`);
      setEvents(feed.events);
      setUnread(feed.unread);
      setTypes(feed.types);
      latestId.current = Math.max(latestId.current, feed.events[0]?.id ?? 0);
      setError('');
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
  }, []);

  /* Merge is where the two sources meet. An event can arrive twice — once in the stream's
     opening replay and once live — so it is keyed by id rather than appended, and the list
     is re-sorted rather than assumed ordered: the replay is oldest-first and the live feed
     is not. */
  const merge = useCallback((incoming: AdminEvent) => {
    latestId.current = Math.max(latestId.current, incoming.id);
    setEvents((current) => {
      if (current.some((event) => event.id === incoming.id)) return current;
      return [incoming, ...current].sort((a, b) => b.id - a.id).slice(0, FEED_LIMIT);
    });
    if (!incoming.readAt) setUnread((count) => count + 1);
    // A type that has never been seen before should appear in the filter immediately
    // rather than after the next full fetch.
    setTypes((current) =>
      current.some((entry) => entry.type === incoming.type)
        ? current.map((entry) =>
            entry.type === incoming.type ? { ...entry, count: entry.count + 1 } : entry,
          )
        : [...current, { type: incoming.type, count: 1 }],
    );
  }, []);

  useEffect(() => {
    void reload();
  }, [reload]);

  useEffect(() => {
    let source: EventSource | null = null;
    let fallback: number | undefined;
    let closed = false;

    const openStream = () => {
      if (closed) return;
      // `after` is what makes a reconnection lossless: the gateway replays everything
      // published while the connection was down, so an event dropped by a slow reader or
      // by a sleeping laptop is caught up rather than lost.
      source = new EventSource(`/admin/api/notifications/stream?after=${latestId.current}`);
      source.addEventListener('open', () => {
        setConnected(true);
        window.clearInterval(fallback);
        fallback = undefined;
      });
      source.addEventListener('admin', (event) => {
        try {
          merge(JSON.parse((event as MessageEvent<string>).data) as AdminEvent);
        } catch {
          // A malformed frame is not worth breaking the feed over.
        }
      });
      source.addEventListener('error', () => {
        setConnected(false);
        // EventSource reconnects by itself, but only while the endpoint is answering at
        // all. The poll covers the case where it never will.
        if (fallback === undefined) {
          fallback = window.setInterval(() => void reload(), FALLBACK_POLL_MS);
        }
      });
    };

    openStream();
    return () => {
      closed = true;
      source?.close();
      window.clearInterval(fallback);
    };
  }, [merge, reload]);

  const markRead = useCallback(async (ids: number[]) => {
    const unreadIds = ids.filter((id) => id > 0);
    if (unreadIds.length === 0) return;
    // Optimistic: the badge is the thing an operator watches, and a count that lags a
    // click by a round trip reads as the click not having worked.
    setEvents((current) =>
      current.map((event) =>
        unreadIds.includes(event.id) && !event.readAt
          ? { ...event, readAt: new Date().toISOString() }
          : event,
      ),
    );
    try {
      const result = await api.post<{ unread: number }>('/admin/api/notifications/read', {
        ids: unreadIds,
      });
      setUnread(result.unread);
    } catch {
      // The optimistic change stands until the next reload corrects it; a failed read
      // receipt is not worth a banner over somebody else's page.
      void reload();
    }
  }, [reload]);

  const markAllRead = useCallback(async () => {
    setUnread(0);
    setEvents((current) =>
      current.map((event) => (event.readAt ? event : { ...event, readAt: new Date().toISOString() })),
    );
    try {
      const result = await api.post<{ unread: number }>('/admin/api/notifications/read', { all: true });
      setUnread(result.unread);
    } catch {
      void reload();
    }
  }, [reload]);

  const value = useMemo<NotificationState>(
    () => ({ events, unread, types, connected, error, markRead, markAllRead, reload }),
    [events, unread, types, connected, error, markRead, markAllRead, reload],
  );

  return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
}

export function useNotifications(): NotificationState {
  const value = useContext(NotificationContext);
  if (!value) throw new Error('useNotifications used outside NotificationProvider');
  return value;
}

/* ---------- presentation ---------- */

/** eventTone is the colour an event wears.
 *
 *  It is derived from the *severity* the publisher chose rather than from the type,
 *  because the type list is open — a service added tomorrow publishes a type this console
 *  has never heard of, and it must still be coloured correctly. */
export function eventTone(event: Pick<AdminEvent, 'severity'>): Tone {
  if (event.severity === 'error') return 'bad';
  if (event.severity === 'warning') return 'warn';
  return 'info';
}

/** eventIcon is the mark beside an event. Unknown types get a bell, which is honest: it
 *  says "something happened" without pretending to categorise it. */
export function eventIcon(type: string): IconName {
  if (type.startsWith('auth.')) return 'key';
  if (type.startsWith('device.')) return 'tv';
  if (type.startsWith('admin.')) return 'shield';
  if (type.startsWith('task.')) return 'clock';
  if (type.startsWith('integration.')) return 'plug';
  if (type.startsWith('library.')) return 'library';
  if (type.startsWith('emby.')) return 'globe';
  if (type.startsWith('server.')) return 'power';
  return 'bell';
}

/** eventTypeLabel turns a routing key into words. An unrecognised type falls back to the
 *  key itself with its punctuation softened, so it is readable rather than absent — the
 *  same stance the client takes towards a row kind it does not know. */
export function eventTypeLabel(type: string): string {
  const known: Record<string, string> = {
    'auth.login': 'Signed in',
    'auth.login_failed': 'Sign-in refused',
    'auth.logout': 'Signed out',
    'device.registered': 'New device',
    'device.removed': 'Device removed',
    'device.renamed': 'Device renamed',
    'admin.sign_in': 'Admin sign-in',
    'server.started': 'Server started',
    'server.maintenance': 'Maintenance',
    'task.completed': 'Task finished',
    'task.failed': 'Task failed',
    'integration.failed': 'Integration failed',
    'integration.test': 'Integration test',
    'library.sync': 'Library sync',
    'emby.unreachable': 'Emby unreachable',
    'emby.recovered': 'Emby recovered',
  };
  return known[type] ?? type.replace(/[._]/g, ' ');
}
