// Client-side notification store. Surfaces transient toasts AND keeps a history that
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";

export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";

interface NotifAction {
  label: string;
  onClick: () => void;
}

// Structured payload that lets the Notification Center offer in-app actions even
// after a reload (when live `action` callbacks are gone).
export type VideoHiddenMeta = {
  kind: "video-hidden";
  videoId: string;
  title: string;
  channelId: string;
  channelName: string;
};
export type VideoWatchedMeta = {
  kind: "video-watched";
  videoId: string;
  title: string;
  channelId: string;
  channelName: string;
};
export type ChannelSubscribedMeta = {
  kind: "channel-subscribed";
  channelId: string;
  channelName: string;
};
// Admin nudge that pending access requests are waiting — carries a durable inbox link to the
// Users page (where Access requests live). No payload; the panel provides the navigation.
type AccessRequestsMeta = {
  kind: "access-requests";
};
export type NotifMeta =
  | VideoHiddenMeta
  | VideoWatchedMeta
  | ChannelSubscribedMeta
  | AccessRequestsMeta;

export interface Notification {
  id: number;
  level: NotifLevel;
  title?: string;
  message: string;
  action?: NotifAction;
  meta?: NotifMeta;
  requiresInteraction: boolean;
  duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
  ts: number;
  read: boolean;
  dismissed: boolean; // transient toast surface closed (still kept in history)
  // Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss
  // timer) that the producer removes itself when the condition resolves. Never persisted
  // to history, so a reload can't orphan it with no live handle to clear it.
  transient: boolean;
  // How many times an identical notification (same level+title+message) fired in quick
  // succession and was coalesced into this one entry (see notify). 1 = fired once; shown as
  // "×N" in the toast/center when >1. Guards against a burst (e.g. an ErrorBoundary catching
  // the same crash on every retry) spamming the center with dozens of copies.
  repeat: number;
}

export interface NotifyInput {
  message: string;
  level?: NotifLevel;
  title?: string;
  action?: NotifAction;
  meta?: NotifMeta;
  requiresInteraction?: boolean;
  sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
  transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
}

const HISTORY_KEY = LS.notifHistory;
const SETTINGS_KEY = LS.notifSettings;
const MAX_HISTORY = 100;
const ERROR_TTL = 15000;

// 6b: per-account notification settings (persisted by the Settings panel into the
// server `preferences`, mirrored to localStorage so they apply before login).
export interface NotifSettings {
  sound: boolean; // play a short tone on attention-needing notifications
  durationMs: number; // auto-dismiss time for info/success toasts
}
const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings();

function loadSettings(): NotifSettings {
  return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
}

export function getNotifSettings(): NotifSettings {
  return config;
}

export function configureNotifications(p: Partial<NotifSettings>): void {
  config = { ...config, ...p };
  writeAccount(SETTINGS_KEY, config);
}

let audioCtx: AudioContext | null = null;
function beep(): void {
  try {
    const Ctx =
      window.AudioContext ||
      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    audioCtx = audioCtx || new Ctx();
    if (audioCtx.state === "suspended") void audioCtx.resume();
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.frequency.value = 660;
    gain.gain.value = 0.04;
    osc.start();
    osc.stop(audioCtx.currentTime + 0.12);
  } catch {
    /* autoplay blocked or unsupported — ignore */
  }
}

let items: Notification[] = load();
let listeners: Array<() => void> = [];
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;

// Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same
// toast) can CLEAR the previous timer first — otherwise the old one still fires at the original
// deadline and dismisses the re-surfaced toast early (C-3.25).
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();

function armDismiss(id: number, duration: number | undefined): void {
  // Always clear any existing timer first. A falsy duration then arms nothing — so a toast that a
  // coalesced repeat upgraded to requiresInteraction (duration undefined) correctly STAYS, instead
  // of the original timer firing and dismissing the now-must-interact toast.
  const existing = dismissTimers.get(id);
  if (existing) {
    clearTimeout(existing);
    dismissTimers.delete(id);
  }
  if (duration)
    dismissTimers.set(
      id,
      setTimeout(() => dismiss(id), duration),
    );
}

// Cached derived snapshots — useSyncExternalStore needs stable references between
// emits, so we recompute these only when `items` changes.
let cachedActive: Notification[] = [];
let cachedReversed: Notification[] = [];
let cachedUnread = 0;

function load(): Notification[] {
  try {
    const raw = readAccount<unknown>(HISTORY_KEY, []);
    if (!Array.isArray(raw)) return [];
    // Restored entries are history only. Mark them dismissed so they don't
    // resurrect as active toasts on reload — their auto-dismiss timers aren't
    // re-armed across a reload, so otherwise they'd stick forever. Also drop the
    // live `action` callback, which can't survive serialization.
    return raw.map(
      (n) =>
        ({
          repeat: 1,
          ...(n as object),
          action: undefined,
          dismissed: true,
          transient: false,
        }) as Notification,
    );
  } catch {
    return [];
  }
}

function persist() {
  try {
    // Transient status notices are live-session only — never write them to history,
    // so a reload can't leave one stranded with no producer left to clear it.
    const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
    writeAccount(HISTORY_KEY, slim);
  } catch {
    /* ignore quota / serialization errors */
  }
}

function recompute() {
  cachedActive = items.filter((n) => !n.dismissed);
  cachedReversed = [...items].reverse();
  cachedUnread = items.reduce((c, n) => c + (n.read ? 0 : 1), 0);
}

function emit() {
  items = items.slice(-MAX_HISTORY);
  recompute();
  persist();
  listeners.forEach((l) => l());
}

recompute();

export function subscribe(listener: () => void): () => void {
  listeners.push(listener);
  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

/** Drop every notification (active or dismissed history) carrying this meta kind. Used to keep
 * a singleton nudge — e.g. re-issuing the "access requests pending" notice without piling up a
 * fresh copy (plus a stale history entry) on every reload. */
export function removeByMetaKind(kind: NotifMeta["kind"]): void {
  const before = items.length;
  items = items.filter((n) => n.meta?.kind !== kind);
  if (items.length !== before) emit();
}

// Window within which an identical notification is coalesced into the prior one (see below).
const COALESCE_WINDOW_MS = 5000;

export function notify(input: NotifyInput): number {
  const requiresInteraction = input.requiresInteraction ?? false;
  const level = input.level ?? "info";
  const now = Date.now();
  // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
  // A transient status stays put until its producer clears it (no auto-dismiss timer),
  // same as an interaction-awaiting notice.
  const duration =
    requiresInteraction || input.transient
      ? undefined
      : level === "error" || level === "fatal"
        ? ERROR_TTL
        : config.durationMs > 0
          ? config.durationMs
          : undefined;

  // Coalesce a rapid burst of identical notifications (same level+title+message within the
  // window) into one entry with a bumped repeat count, instead of appending a fresh copy each
  // time. This tames spam like an ErrorBoundary catching the same crash on every render retry,
  // or a poll that keeps failing the same way. Skipped when the input carries a live `action`
  // (its callback identity matters) or is a transient status (those are singleton-guarded by
  // their producer). Distinct events — any difference in level/title/message — are never merged.
  if (!input.action && !input.transient) {
    const prior = items.find(
      (n) =>
        !n.transient &&
        !n.action &&
        now - n.ts <= COALESCE_WINDOW_MS &&
        n.level === level &&
        (n.title ?? "") === (input.title ?? "") &&
        n.message === input.message,
    );
    if (prior) {
      // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
      items = items.map((n) =>
        n.id === prior.id
          ? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
          : n,
      );
      emit();
      armDismiss(prior.id, duration); // clears the prior timer even when the repeat is now must-interact
      // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
      return prior.id;
    }
  }

  const id = counter++;
  items = [
    ...items,
    {
      id,
      level,
      title: input.title,
      message: input.message,
      action: input.action,
      meta: input.meta,
      requiresInteraction,
      duration,
      ts: now,
      read: false,
      dismissed: false,
      transient: input.transient ?? false,
      repeat: 1,
    },
  ];
  emit();
  if (
    config.sound &&
    (input.sound || requiresInteraction || level === "error" || level === "fatal")
  ) {
    beep();
  }
  armDismiss(id, duration);
  return id;
}

/** Close the transient toast surface; the entry stays in the center's history. */
export function dismiss(id: number): void {
  const t = dismissTimers.get(id);
  if (t) {
    clearTimeout(t);
    dismissTimers.delete(id);
  }
  items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
  emit();
}

export function markAllRead(): void {
  if (items.every((n) => n.read)) return;
  items = items.map((n) => (n.read ? n : { ...n, read: true }));
  emit();
}

export function clearAll(): void {
  if (items.length === 0) return;
  items = [];
  emit();
}

/** Remove a single history entry entirely (per-item clear in the notification center). */
export function remove(id: number): void {
  const before = items.length;
  items = items.filter((n) => n.id !== id);
  if (items.length !== before) emit();
}

/** Resolve any history entries tied to a video — called when it's unhidden/unwatched so the
 * now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */
export function resolveVideo(videoId: string): void {
  const before = items.length;
  items = items.filter(
    (n) =>
      !(
        (n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") &&
        n.meta.videoId === videoId
      ),
  );
  if (items.length !== before) emit();
}

export const getActiveToasts = (): Notification[] => cachedActive;
export const getNotifications = (): Notification[] => cachedReversed;
export const getUnreadCount = (): number => cachedUnread;
