export interface FaviconBadgeOptions {
  /**
   * Badge fill color. Default Facebook-ish red.
   */
  badgeColor?: string;
  /**
   * Text/number color. Default white.
   */
  textColor?: string;
  /**
   * `true` → paint the count inside the badge (caps at 99+).
   * `false` → flat dot. Default `false` — Facebook-style restraint.
   */
  showCount?: boolean;
  /**
   * Optional explicit base favicon URL. If omitted, we read the current
   * `<link rel="icon">` href; if there is none, we paint onto a blank
   * canvas so something useful still appears on the tab.
   */
  baseUrl?: string;
  /**
   * Canvas size in CSS px. 32 is the standard favicon hi-DPI size.
   */
  size?: number;
  /**
   * Pulse the badge by alternating between the badged frame and the
   * bare base favicon. Default `true` — periphery-vision cue that
   * makes the dot findable in a tab bar of dozens of icons. Set
   * `false` for a static dot (quieter; matches enterprise tastes).
   */
  pulse?: boolean;
  /**
   * Milliseconds the badge stays visible per cycle. Default 600.
   */
  pulseOnMs?: number;
  /**
   * Milliseconds the badge is hidden per cycle. Default 400.
   * Asymmetric on/off (default 600/400) gives a Slack/FB-style
   * heartbeat — the badge is the dominant state, the gap is brief.
   */
  pulseOffMs?: number;
}

interface BadgeHandle {
  set(count: number): void;
  clear(): void;
}

const LINK_REL_VARIANTS = ['icon', 'shortcut icon'] as const;
const MANAGED_ATTR = 'data-chat-favicon-badge';

/**
 * Paints a small badge over the page's favicon using a hidden canvas
 * and swaps the resulting data URL into `<link rel="icon">`. SSR-safe.
 *
 * Notes / gotchas (encoded as invariants):
 *   - SVG favicons can't be rastered via `<img>` reliably across
 *     browsers without inlining. We attempt anyway; on failure we paint
 *     a blank background + the badge so the tab still signals unread.
 *   - We never mutate the host's original `<link>`. We add a managed
 *     `<link rel="icon" data-chat-favicon-badge>` ahead of it; on clear
 *     we remove that managed node. This is the cleanest way to handle
 *     hosts that swap their own favicons (e.g. Next.js dynamic icons).
 *   - Cross-origin favicons need CORS to draw; if drawing throws we
 *     fall back to a CORS-less canvas (badge only, no base image).
 */
export function createFaviconBadge(opts: FaviconBadgeOptions = {}): BadgeHandle {
  if (typeof document === 'undefined') {
    return { set() {}, clear() {} };
  }

  const badgeColor = opts.badgeColor ?? '#ef4444';
  const textColor = opts.textColor ?? '#ffffff';
  const showCount = opts.showCount ?? false;
  const size = opts.size ?? 32;
  const pulse = opts.pulse ?? true;
  const pulseOnMs = opts.pulseOnMs ?? 600;
  const pulseOffMs = opts.pulseOffMs ?? 400;

  let baseImg: HTMLImageElement | null = null;
  let baseLoaded = false;
  let lastCount = -1;

  // Pulse animation state.
  let frameOn: string | null = null;   // badged frame data URL
  let frameOff: string | null = null;  // bare base frame data URL (or empty)
  let pulseTimer: ReturnType<typeof setTimeout> | null = null;
  let pulsePhase: 'on' | 'off' = 'on';

  const detectBaseUrl = (): string | null => {
    if (opts.baseUrl) return opts.baseUrl;
    for (const rel of LINK_REL_VARIANTS) {
      const link = document.querySelector<HTMLLinkElement>(
        `link[rel="${rel}"]:not([${MANAGED_ATTR}])`,
      );
      if (link?.href) return link.href;
    }
    return null;
  };

  const loadBase = () => {
    if (baseImg) return;
    const url = detectBaseUrl();
    if (!url) return;
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      baseLoaded = true;
      // If a `set()` came in before load completed, re-render now.
      if (lastCount >= 0) renderFrames(lastCount);
    };
    img.onerror = () => {
      // Network/CORS failure: leave baseLoaded=false; we'll paint badge
      // on a blank canvas.
      baseLoaded = false;
    };
    img.src = url;
    baseImg = img;
  };

  const removeManagedLinks = () => {
    document
      .querySelectorAll(`link[${MANAGED_ATTR}]`)
      .forEach((node) => node.remove());
  };

  const applyDataUrl = (dataUrl: string) => {
    removeManagedLinks();
    const link = document.createElement('link');
    link.rel = 'icon';
    link.type = 'image/png';
    link.href = dataUrl;
    link.setAttribute(MANAGED_ATTR, 'true');
    document.head.appendChild(link);
  };

  // Paint the off-phase frame. If we have a usable base favicon, this
  // is the bare base; otherwise a transparent canvas. Returning a
  // transparent frame (rather than `null` + removing the link) is
  // load-bearing: Chrome only re-rasters the tab favicon when the
  // managed `<link>`'s href *changes*, so off-phase MUST swap to a
  // different data URL — not vanish — for the pulse to be visible.
  const renderOffFrame = (): string | null => {
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;

    if (baseImg && baseLoaded) {
      try {
        ctx.drawImage(baseImg, 0, 0, size, size);
      } catch {
        // Tainted canvas → fall through to the empty frame.
        ctx.clearRect(0, 0, size, size);
      }
    }
    // If no base, the canvas stays fully transparent — Chrome falls
    // back to its default tab glyph, which still gives a visible
    // on/off contrast against the red badge.
    try {
      return canvas.toDataURL('image/png');
    } catch {
      return null;
    }
  };

  // Paint the badged frame. Reused for both the static (no-pulse) case
  // and the `on` phase of the pulse loop.
  const renderBadged = (count: number): string | null => {
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;

    if (baseImg && baseLoaded) {
      try {
        ctx.drawImage(baseImg, 0, 0, size, size);
      } catch {
        ctx.clearRect(0, 0, size, size);
      }
    }

    const r = size * 0.28;
    const cx = size - r - size * 0.04;
    const cy = size - r - size * 0.04;

    ctx.fillStyle = badgeColor;
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.fill();

    if (showCount) {
      const label = count > 99 ? '99+' : String(count);
      ctx.fillStyle = textColor;
      const fontSize = label.length >= 3 ? r * 0.85 : r * 1.25;
      ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(label, cx, cy + fontSize * 0.06);
    }

    try {
      return canvas.toDataURL('image/png');
    } catch {
      // Tainted-canvas fallback: badge only, no base.
      const fb = document.createElement('canvas');
      fb.width = size;
      fb.height = size;
      const fctx = fb.getContext('2d');
      if (!fctx) return null;
      fctx.fillStyle = badgeColor;
      fctx.beginPath();
      fctx.arc(cx, cy, r, 0, Math.PI * 2);
      fctx.fill();
      return fb.toDataURL('image/png');
    }
  };

  const stopPulse = () => {
    if (pulseTimer !== null) {
      clearTimeout(pulseTimer);
      pulseTimer = null;
    }
  };

  const tickPulse = () => {
    if (frameOn === null) return;
    // Always swap the managed <link>'s href between two distinct data
    // URLs — Chrome / Safari skip the redraw if the href is identical
    // to what was just set, so removing-and-readding the node isn't
    // enough on its own (`renderOffFrame` guarantees a non-null off
    // frame whenever frameOn exists).
    if (pulsePhase === 'on') {
      applyDataUrl(frameOn);
      pulsePhase = 'off';
      pulseTimer = setTimeout(tickPulse, pulseOnMs);
    } else {
      applyDataUrl(frameOff ?? frameOn);
      pulsePhase = 'on';
      pulseTimer = setTimeout(tickPulse, pulseOffMs);
    }
  };

  const renderFrames = (count: number) => {
    frameOn = renderBadged(count);
    frameOff = renderOffFrame();

    if (frameOn === null) return;

    if (!pulse) {
      applyDataUrl(frameOn);
      return;
    }

    // (Re-)start the pulse loop. If a previous loop was running we
    // tear it down first so the cadence doesn't double up.
    stopPulse();
    pulsePhase = 'on';
    tickPulse();
  };

  return {
    set(count) {
      lastCount = count;
      loadBase();
      // If base not loaded yet, the onload handler will call
      // renderFrames(lastCount) once it resolves.
      renderFrames(count);
    },
    clear() {
      lastCount = -1;
      stopPulse();
      frameOn = null;
      frameOff = null;
      removeManagedLinks();
    },
  };
}
