'use client';

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { store, DEFAULT_VISIBLE_TOASTS } from '../core/store';
import type {
  Direction,
  RichColorsMode,
  SwipeDirection,
  ToastOffset,
  ToastOptions,
  ToastPosition,
  ToastRecord,
  ToastType,
  ToasterProps,
} from '../core/types';
import { Toast, ToastRuntime } from './Toast';
import '../styles/toast.css';

/** Time the exit animation is given before the node is dropped. */
const EXIT_DURATION = 220;
/** Visible sliver of each toast behind the front one, when collapsed. */
const COLLAPSED_PEEK = 14;
const DEFAULT_GAP = 14;
const DEFAULT_HOTKEY = ['altKey', 'KeyT'];

const toCssLength = (value: string | number | undefined, fallback: string): string => {
  if (value === undefined) return fallback;
  return typeof value === 'number' ? `${value}px` : value;
};

const isOffsetObject = (value: unknown): value is ToastOffset =>
  typeof value === 'object' && value !== null;

/**
 * Turn `offset` into custom properties. A scalar sets every side at once; an
 * object sets only the sides it names, each falling back to the scalar in CSS.
 */
const offsetVars = (
  value: string | number | ToastOffset | undefined,
  prefix: string,
  fallback: string
): Record<string, string> => {
  if (isOffsetObject(value)) {
    const vars: Record<string, string> = { [prefix]: fallback };
    (['top', 'right', 'bottom', 'left'] as const).forEach((side) => {
      const side_value = value[side];
      if (side_value !== undefined) {
        vars[`${prefix}-${side}`] = toCssLength(side_value, fallback);
      }
    });
    return vars;
  }
  return { [prefix]: toCssLength(value, fallback) };
};

/** Directions that make sense for a given corner, when not set explicitly. */
const defaultSwipeDirections = (position: ToastPosition): SwipeDirection[] => {
  const vertical: SwipeDirection = position.startsWith('top') ? 'up' : 'down';
  if (position.endsWith('left')) return ['left', vertical];
  if (position.endsWith('right')) return ['right', vertical];
  return ['left', 'right', vertical];
};

/**
 * What theme has the host application declared?
 *
 * Checked before `prefers-color-scheme`, because an app that states its theme
 * outranks the OS: a Tailwind/next-themes site toggled to light on a machine
 * set to dark must get light toasts, not black ones.
 *
 * Covers the conventions in the wild: a `.dark` / `.light` class (Tailwind's
 * `darkMode: 'class'`, next-themes `attribute="class"`), a `data-theme`
 * attribute (next-themes `attribute="data-theme"`, DaisyUI), and an inline
 * `color-scheme` (next-themes sets this whenever `enableColorScheme` is on).
 */
const readHostTheme = (): 'light' | 'dark' | null => {
  if (typeof document === 'undefined') return null;

  for (const root of [document.documentElement, document.body]) {
    if (!root) continue;

    if (root.classList.contains('dark')) return 'dark';
    if (root.classList.contains('light')) return 'light';

    const attribute = root.getAttribute('data-theme');
    if (attribute === 'dark' || attribute === 'light') return attribute;

    const scheme = root.style.colorScheme;
    if (scheme === 'dark' || scheme === 'light') return scheme;
  }

  return null;
};

/** Best-effort plain text for the live region. JSX content is skipped. */
const textOf = (node: React.ReactNode): string => {
  if (typeof node === 'string' || typeof node === 'number') return String(node);
  if (Array.isArray(node)) return node.map(textOf).filter(Boolean).join(' ');
  return '';
};

export const Toaster: React.FC<ToasterProps> = (props) => {
  const {
    children,
    position: positionProp,
    defaultPosition,
    layout: layoutProp,
    defaultLayout,
    visibleToasts,
    maxToasts,
    duration,
    defaultDuration,
    theme = 'system',
    dir = 'auto',
    richColors = false,
    expand = false,
    closeButton,
    showCloseButton,
    showProgressBar = true,
    color = true,
    invert = false,
    size = 'md',
    gap = DEFAULT_GAP,
    offset,
    mobileOffset,
    toastOptions,
    className,
    containerClassName,
    style,
    swipeDirections,
    swipeDirection,
    swipeThreshold = 0.35,
    hotkey = DEFAULT_HOTKEY,
    pauseWhenPageIsHidden = false,
    pauseOnFocusLoss = false,
    newestFirst = true,
    closeOnClick = false,
    containerAriaLabel = 'Notifications',
    closeButtonAriaLabel = 'Close',
    icons = {},
    loadingIcon,
    zIndex = 9999,
  } = props;

  const overrides = store.getOverrides();
  const position: ToastPosition =
    overrides.position ?? positionProp ?? defaultPosition ?? 'bottom-right';
  const layout = overrides.layout ?? layoutProp ?? defaultLayout ?? 'normal';
  const limit = visibleToasts ?? maxToasts ?? DEFAULT_VISIBLE_TOASTS;
  const resolvedDuration = duration ?? defaultDuration;

  // Intentionally NOT seeded from the store. The server has no queue, so
  // seeding here would make the first client render disagree with the server
  // HTML whenever a toast was dispatched before hydration. The mount effect
  // fills it in immediately afterwards, before paint.
  const [toasts, setToasts] = useState<ToastRecord[]>([]);
  const [ghosts, setGhosts] = useState<ToastRecord[]>([]);
  const [heights, setHeights] = useState<Record<string, number>>({});
  const [hovered, setHovered] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const [systemDark, setSystemDark] = useState(false);
  const [hostTheme, setHostTheme] = useState<'light' | 'dark' | null>(null);
  const [documentDir, setDocumentDir] = useState<Direction>('ltr');
  const [announce, setAnnounce] = useState({ polite: '', assertive: '' });

  const regionRef = useRef<HTMLElement>(null);
  const seenIds = useRef<Set<string>>(new Set());
  const previous = useRef<ToastRecord[]>([]);
  const focusReturn = useRef<HTMLElement | null>(null);
  const [refocus, setRefocus] = useState(0);

  // Stable identity for this instance's claim on rendering.
  const [token] = useState(() => Symbol('vyrn-toaster'));
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    const release = store.claimRenderer(token);
    const sync = () => setIsActive(store.isActiveRenderer(token));
    sync();
    const unsubscribe = store.subscribe(sync);
    return () => {
      unsubscribe();
      release();
    };
  }, [token]);

  useEffect(() => {
    setToasts(store.getToasts());
    return store.subscribe(setToasts);
  }, []);

  useEffect(() => {
    if (!isActive) return;
    store.setLimit(limit);
  }, [limit, isActive]);

  // Read from the document after hydration, never during render.
  useEffect(() => {
    if (dir !== 'auto' || typeof document === 'undefined') return;
    setDocumentDir(document.dir === 'rtl' ? 'rtl' : 'ltr');
  }, [dir]);

  /*
   * Only creation-time defaults go to the store — timing and de-duplication.
   * Presentational defaults stay in the render path so changing a prop restyles
   * toasts that are already on screen, and so nothing is merged in twice.
   */
  useEffect(() => {
    if (!isActive) return;

    const duration =
      resolvedDuration !== undefined ? resolvedDuration : toastOptions?.duration;

    const types: Partial<Record<ToastType, ToastOptions>> = {};
    const perType = toastOptions?.types;
    if (perType) {
      (Object.keys(perType) as ToastType[]).forEach((type) => {
        const entry = perType[type];
        if (!entry) return;
        const slice: ToastOptions = {};
        if (entry.duration !== undefined) slice.duration = entry.duration;
        if (entry.preventDuplicate !== undefined) {
          slice.preventDuplicate = entry.preventDuplicate;
        }
        if (Object.keys(slice).length) types[type] = slice;
      });
    }

    store.setDefaults({
      ...(duration !== undefined ? { duration } : {}),
      ...(toastOptions?.preventDuplicate !== undefined
        ? { preventDuplicate: toastOptions.preventDuplicate }
        : {}),
      ...(Object.keys(types).length ? { types } : {}),
    });
  }, [resolvedDuration, toastOptions, isActive]);

  // Track what the host app declares, and re-read it whenever it changes.
  useEffect(() => {
    if (theme !== 'system' || typeof document === 'undefined') return;

    const sync = () => setHostTheme(readHostTheme());
    sync();

    if (typeof MutationObserver === 'undefined') return;
    const observer = new MutationObserver(sync);
    const options = { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] };
    observer.observe(document.documentElement, options);
    if (document.body) observer.observe(document.body, options);
    return () => observer.disconnect();
  }, [theme]);

  // Resolve `theme="system"`.
  useEffect(() => {
    if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return;
    const query = window.matchMedia('(prefers-color-scheme: dark)');
    const sync = () => setSystemDark(query.matches);
    sync();
    if (query.addEventListener) {
      query.addEventListener('change', sync);
      return () => query.removeEventListener('change', sync);
    }
    query.addListener(sync);
    return () => query.removeListener(sync);
  }, [theme]);

  const visible = useMemo(() => toasts.slice(0, limit), [toasts, limit]);

  // Announce newly arrived toasts, and play their sound if they asked for one.
  useEffect(() => {
    const fresh = visible.filter((toast) => !seenIds.current.has(toast.id));
    visible.forEach((toast) => seenIds.current.add(toast.id));
    if (!fresh.length) return;

    const latest = fresh[fresh.length - 1];
    const message = [textOf(latest.content), textOf(latest.description)]
      .filter(Boolean)
      .join('. ');
    if (message) {
      const assertive = latest.important === true || latest.type === 'error';
      setAnnounce(assertive ? { polite: '', assertive: message } : { polite: message, assertive: '' });
    }

    fresh.forEach((toast) => {
      if (!toast.soundEffect || typeof Audio === 'undefined') return;
      try {
        const audio = new Audio(toast.soundEffect);
        void audio.play().catch(() => undefined);
      } catch {
        /* autoplay blocked — a toast is not worth throwing over */
      }
    });
  }, [visible]);

  // Retain just-removed toasts briefly so they can animate out.
  useEffect(() => {
    const gone = previous.current.filter(
      (candidate) => !toasts.some((toast) => toast.id === candidate.id)
    );
    previous.current = visible;
    if (!gone.length) return;

    setGhosts((current) => [...current, ...gone]);
    const timers = gone.map((toast) =>
      setTimeout(() => {
        setGhosts((current) => current.filter((ghost) => ghost.id !== toast.id));
        setHeights((current) => {
          const next = { ...current };
          delete next[toast.id];
          return next;
        });
        seenIds.current.delete(toast.id);
      }, EXIT_DURATION)
    );
    return () => timers.forEach(clearTimeout);
  }, [toasts, visible]);

  // Pause on hover, on keyboard focus, and optionally while the tab is hidden.
  useEffect(() => {
    if (hovered || focusWithin) store.pause();
    else store.resume();
  }, [hovered, focusWithin]);

  useEffect(() => {
    if (!pauseWhenPageIsHidden || typeof document === 'undefined') return;
    const sync = () => {
      if (document.visibilityState === 'hidden') store.pause();
      else if (!hovered && !focusWithin) store.resume();
    };
    document.addEventListener('visibilitychange', sync);
    return () => document.removeEventListener('visibilitychange', sync);
  }, [pauseWhenPageIsHidden, hovered, focusWithin]);

  useEffect(() => {
    if (!pauseOnFocusLoss || typeof window === 'undefined') return;
    const onBlur = () => store.pause();
    const onFocus = () => {
      if (!hovered && !focusWithin) store.resume();
    };
    window.addEventListener('blur', onBlur);
    window.addEventListener('focus', onFocus);
    return () => {
      window.removeEventListener('blur', onBlur);
      window.removeEventListener('focus', onFocus);
    };
  }, [pauseOnFocusLoss, hovered, focusWithin]);

  // Hotkey moves focus into the list, the way Sonner's ⌥T does.
  useEffect(() => {
    if (typeof document === 'undefined') return;
    const onKeyDown = (event: KeyboardEvent) => {
      const matched = hotkey.every((part) =>
        part.endsWith('Key') ? Boolean((event as unknown as Record<string, boolean>)[part]) : event.code === part
      );
      if (!matched) return;
      const target = regionRef.current?.querySelector<HTMLElement>('[data-vyrn-toast]');
      target?.focus();
    };
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [hotkey]);

  // Losing focus to <body> when a toast is dismissed strands keyboard users.
  const requestRefocus = useCallback(() => setRefocus((n) => n + 1), []);

  useEffect(() => {
    if (refocus === 0) return;
    const region = regionRef.current;
    if (!region) return;
    const next = region.querySelector<HTMLElement>(
      '[data-vyrn-toast][data-removed="false"]'
    );
    if (next) next.focus();
    else if (focusReturn.current && document.contains(focusReturn.current)) {
      focusReturn.current.focus();
    }
  }, [refocus]);

  const onHeight = useCallback((id: string, height: number) => {
    setHeights((current) => (current[id] === height ? current : { ...current, [id]: height }));
  }, []);

  // Explicit prop > what the host declares > the OS preference.
  const resolvedTheme =
    theme === 'system' ? hostTheme ?? (systemDark ? 'dark' : 'light') : theme;
  const resolvedDir: Direction = dir === 'auto' ? documentDir : dir;
  const resolvedRich: RichColorsMode | false =
    richColors === true ? 'minimal' : richColors === false ? false : richColors;

  const isExpanded = expand || layout !== 'stack' || hovered || focusWithin;

  const rendered = useMemo(() => [...visible, ...ghosts], [visible, ghosts]);

  /** One list per position, so per-toast `position` works. */
  const groups = useMemo(() => {
    const map = new Map<ToastPosition, ToastRecord[]>();
    rendered.forEach((toast) => {
      const key = toast.position || position;
      const bucket = map.get(key);
      if (bucket) bucket.push(toast);
      else map.set(key, [toast]);
    });
    return Array.from(map.entries());
  }, [rendered, position]);

  return (
    <>
      {children}
      <section
        ref={regionRef}
        aria-label={containerAriaLabel}
        data-vyrn-region=""
        tabIndex={-1}
        style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex }}
      >
        {/*
          A single pair of live regions does all announcing. The toasts
          themselves carry no live semantics, so nothing is announced twice
          and an exiting toast is never re-read.
        */}
        <div className="vyrn-sr-only" aria-live="polite" aria-atomic="true">
          {announce.polite}
        </div>
        <div className="vyrn-sr-only" aria-live="assertive" aria-atomic="true">
          {announce.assertive}
        </div>

        {(isActive ? groups : []).map(([groupPosition, groupToasts]) => {
          const isTop = groupPosition.startsWith('top');
          // The front of the list is the edge of the viewport.
          const ordered = newestFirst ? [...groupToasts].reverse() : [...groupToasts];
          const swipeSet =
            swipeDirections || (swipeDirection ? [swipeDirection] : defaultSwipeDirections(groupPosition));

          const runtime: ToastRuntime = {
            closeButton: closeButton ?? showCloseButton ?? true,
            showProgressBar,
            size,
            richColors: resolvedRich,
            color,
            invert,
            expanded: isExpanded,
            swipeDirections: swipeSet,
            swipeThreshold,
            icons,
            loadingIcon,
            defaults: toastOptions,
            closeButtonAriaLabel,
            closeOnClick,
          };

          // Real measured heights, so multi-line toasts never overlap.
          let running = 0;
          const offsets = ordered.map((toast, index) => {
            if (index === 0) return 0;
            const previousToast = ordered[index - 1];
            running += (heights[previousToast.id] || 0) + gap;
            return isExpanded ? running : index * COLLAPSED_PEEK;
          });

          return (
            <ol
              key={groupPosition}
              className={['vyrn-toast-list', className, containerClassName]
                .filter(Boolean)
                .join(' ')}
              data-vyrn-list=""
              data-position={groupPosition}
              data-layout={layout}
              data-theme={resolvedTheme}
              data-expanded={isExpanded ? 'true' : 'false'}
              dir={resolvedDir}
              style={{
                ...style,
                zIndex,
                ['--vyrn-gap' as string]: `${gap}px`,
                ...offsetVars(offset, '--vyrn-offset', '24px'),
                ...offsetVars(mobileOffset, '--vyrn-mobile-offset', '16px'),
                ['--vyrn-count' as string]: ordered.length,
              }}
              onPointerEnter={() => setHovered(true)}
              onPointerLeave={() => setHovered(false)}
              onFocus={(event) => {
                if (!focusWithin) {
                  const from = event.relatedTarget as HTMLElement | null;
                  if (from && !regionRef.current?.contains(from)) {
                    focusReturn.current = from;
                  }
                }
                setFocusWithin(true);
              }}
              onBlur={() => setFocusWithin(false)}
            >
              {ordered.map((toast, index) => (
                <Toast
                  key={toast.id}
                  toast={toast}
                  index={index}
                  offset={isTop ? offsets[index] : -offsets[index]}
                  removed={ghosts.some((ghost) => ghost.id === toast.id)}
                  runtime={runtime}
                  onHeight={onHeight}
                  onRequestRefocus={requestRefocus}
                />
              ))}
            </ol>
          );
        })}
      </section>
    </>
  );
};

/** Legacy v4 name. `<ToastProvider>` wrapped children; `<Toaster />` need not. */
export const ToastProvider = Toaster;
