import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache';
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';

interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
  src: string;
  cacheKey: string;
  /**
   * Added to the viewport-based score when waiting for a `getCachedBlob` **network** slot.
   * Use to order tiers (e.g. search: artist thumbs before album thumbs) without changing layout.
   */
  fetchQueueBias?: number;
  /**
   * How far beyond the scroll viewport `IntersectionObserver` expands the root.
   * Larger = priority / slot ordering updates while the row is still off-screen → less
   * empty flash when it hits the viewport. CSS margin syntax (`440px`, `10% 0`, …).
   */
  observeRootMargin?: string;
  /**
   * Optional explicit IO root (e.g. in-page browse viewport id). When omitted,
   * the nearest scrolling ancestor is used so priority tracks the visible pane.
   */
  observeScrollRootId?: string;
}

/** Search UI: load artist avatars before album covers when many requests compete. */
export const FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM = 1_000_000_000;

/** Default IO lead — slightly before visible to reduce scroll-in jitter (tune per `CachedImage`). */
export const DEFAULT_CACHED_IMAGE_PREPARE_MARGIN = '440px';

/** Blob URL paired with the `cacheKey` it was acquired for (`useCachedUrl`). */
type ResolvedSlice = { key: string | null; url: string };

/**
 * Returns a shared, refcounted object URL for a cached image. Multiple
 * consumers of the same cacheKey see the exact same URL string, so the
 * browser's decoded-image cache hits across instances — critical on
 * Chromium/WebView2 (Windows), which keys decode results by URL.
 *
 * @param fallbackToFetch  If true (default), returns the raw fetchUrl while the
 *   blob is still resolving — useful for <img> tags so the browser starts
 *   loading immediately.  Pass false for CSS background-image consumers that
 *   should only see a stable blob URL (prevents a double crossfade).
 */
// useCachedUrl is the headless companion of CachedImage — it shares the same
// caching contract and the module-local ResolvedSlice type, and is documented
// alongside the component in src/CLAUDE.md. Intentional co-location; the
// fast-refresh rule is an HMR-only concern.
// eslint-disable-next-line react-refresh/only-export-components
export function useCachedUrl(
  fetchUrl: string,
  cacheKey: string,
  fallbackToFetch = true,
  getPriority?: () => number,
): string {
  // `buildCoverArtUrl` rotates salt/token on every call — `fetchUrl` is a new
  // string each render though the logical image is unchanged (`cacheKey`). If
  // `fetchUrl` were an effect dependency, cleanup would run every frame, call
  // `releaseUrl`, revoke the blob, and break <img> until onError hides it.
  const fetchUrlRef = useRef(fetchUrl);
  // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
  // eslint-disable-next-line react-hooks/refs
  fetchUrlRef.current = fetchUrl;

  // Pair blob URL with the `cacheKey` it belongs to. After `cacheKey` changes,
  // React keeps old state for one render — returning that stale blob URL made
  // `<img src>` point at a released object URL (broken image) until effects ran.
  const [resolvedSlice, setResolvedSlice] = useState<ResolvedSlice>(() => {
    if (!fetchUrl) return { key: null, url: '' };
    const sync = acquireUrl(cacheKey);
    return sync ? { key: cacheKey, url: sync } : { key: null, url: '' };
  });
  // Tracks whichever cacheKey we currently hold a refcount on, so we know
  // exactly what to release on cleanup or when keys change.
  const ownedKeyRef = useRef<string | null>(
    resolvedSlice.key === cacheKey && resolvedSlice.url ? cacheKey : null,
  );

  const getPriorityRef = useRef(getPriority);
  // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
  // eslint-disable-next-line react-hooks/refs
  getPriorityRef.current = getPriority;

  useEffect(() => {
    const release = () => {
      if (ownedKeyRef.current) {
        releaseUrl(ownedKeyRef.current);
        ownedKeyRef.current = null;
      }
    };

    const currentUrl = fetchUrlRef.current;
    if (!currentUrl) {
      release();
      setResolvedSlice({ key: null, url: '' });
      return release;
    }

    // Same logical image as last run — only `cacheKey` drives this effect.
    if (ownedKeyRef.current === cacheKey) {
      return release;
    }

    // Different key than we're currently holding: drop the old one.
    release();

    // Fast path: blob is hot in memory → grab the shared URL synchronously.
    const sync = acquireUrl(cacheKey);
    if (sync) {
      ownedKeyRef.current = cacheKey;
      // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setResolvedSlice({ key: cacheKey, url: sync });
      return release;
    }

    // Slow path: fetch (or read from IDB), then acquire.
    setResolvedSlice({ key: cacheKey, url: '' });
    const controller = new AbortController();
    getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => {
      if (controller.signal.aborted || !blob) return;
      const url = acquireUrl(cacheKey);
      if (!url) return;
      ownedKeyRef.current = cacheKey;
      setResolvedSlice({ key: cacheKey, url });
    });
    return () => {
      controller.abort();
      release();
    };
  }, [cacheKey]);

  useEffect(() => {
    if (!fetchUrl || !fallbackToFetch) return;
    let cancelled = false;
    const unsub = subscribeCoverUpgraded(cacheKey, () => {
      if (cancelled) return;
      const refreshed = acquireUrl(cacheKey);
      if (refreshed) {
        ownedKeyRef.current = cacheKey;
        setResolvedSlice({ key: cacheKey, url: refreshed });
      }
    });
    return () => {
      cancelled = true;
      unsub();
    };
  }, [cacheKey, fetchUrl, fallbackToFetch]);

  const matched =
    resolvedSlice.key === cacheKey && resolvedSlice.url ? resolvedSlice.url : '';
  return fallbackToFetch ? (matched || fetchUrl) : matched;
}

export default function CachedImage({
  src,
  cacheKey,
  fetchQueueBias = 0,
  observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
  observeScrollRootId,
  style,
  onLoad,
  onError,
  ...props
}: CachedImageProps) {
  const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
  const imgRef = useRef<HTMLImageElement>(null);
  /**
   * Drives disk/network waiter ordering only. We intentionally do **not** gate
   * `useCachedUrl` on intersection — relying on IO to “arm” loading proved brittle
   * (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
   */
  const priorityRef = useRef(0);
  const getViewportImagePriority = useCallback(
    () => fetchQueueBias + priorityRef.current,
    [fetchQueueBias],
  );

  useEffect(() => {
    const el = imgRef.current;
    if (!el) return;
    const root =
      (observeScrollRootId
        ? (document.getElementById(observeScrollRootId) as Element | null)
        : null)
      ?? resolveIntersectionScrollRoot(el);
    const updateFromEntry = (entry: IntersectionObserverEntry) => {
      if (entry.isIntersecting) {
        const r = entry.boundingClientRect;
        const rootEl = entry.rootBounds;
        const vh = (rootEl?.height ?? window.innerHeight) || 1;
        const originTop = rootEl?.top ?? 0;
        const vc = originTop + vh * 0.5;
        const cy = r.top + r.height * 0.5;
        const dist = Math.abs(cy - vc);
        priorityRef.current = entry.intersectionRatio * 1e7 - dist * 1e3;
      } else {
        priorityRef.current = -1e12;
      }
    };
    const observer = new IntersectionObserver(
      entries => { for (const e of entries) updateFromEntry(e); },
      {
        root: root ?? undefined,
        rootMargin: observeRootMargin,
        threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
      },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [observeRootMargin, observeScrollRootId]);

  // Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
  // then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
  // Priority still applies to the slow path inside getCachedBlob.
  const resolvedSrc = useCachedUrl(src, cacheKey, true, getViewportImagePriority);
  const [loaded, setLoaded] = useState(false);

  // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
  // URL upgrades within the same image — avoids the end-of-load flash.
  // useLayoutEffect: one paint with `loaded` still true + a stale/wrong `src`
  // showed a broken-cover flash in the player bar when switching tracks (#606).
  useLayoutEffect(() => {
    // React Compiler set-state-in-effect rule: state set from a timer/animation callback.
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setLoaded(false);
    setFallbackSrc(undefined);
  }, [cacheKey]);

  const isFallback = fallbackSrc !== undefined;
  const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);

  // Browsers sometimes skip `load` for cache hits / lazy + horizontal scroll — unstick opacity.
  useEffect(() => {
    if (!finalSrc) return;
    let alive = true;
    const id = requestAnimationFrame(() => {
      if (!alive) return;
      const img = imgRef.current;
      if (img?.complete && img.naturalWidth > 0) {
        setLoaded(true);
      }
    });
    return () => {
      alive = false;
      cancelAnimationFrame(id);
    };
  }, [finalSrc]);

  const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
    if (onError) {
      // Caller wants custom error handling (e.g. hide the element)
      onError(e);
    } else {
      // Nullify the DOM-level handler first to prevent any infinite loop
      e.currentTarget.onerror = null;
      setFallbackSrc('/logo-psysonic.png');
    }
  };

  const fallbackStyle: React.CSSProperties = isFallback
    ? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
    : {};

  return (
    <img
      ref={imgRef}
      src={finalSrc}
      style={{ ...style, opacity: loaded ? 1 : 0, ...fallbackStyle }}
      onLoad={e => { setLoaded(true); onLoad?.(e); }}
      onError={handleError}
      {...props}
    />
  );
}
