"use client";

import { ChevronLeft, ChevronRight } from "lucide-react";
import Image, { type StaticImageData } from "next/image";
import { useEffect, useState, useSyncExternalStore } from "react";

import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";

type CrossfadeSlide = {
  src: StaticImageData;
  alt: string;
  /**
   * Accessible name of this slide's pagination dot — the caller renders
   * `Common.goToSlide` itself. A `(n) => string` label factory cannot cross the
   * RSC boundary, and both call sites already build one object per slide.
   */
  goToLabel: string;
};

type CrossfadeLabels = {
  /**
   * Names *this* carousel for assistive tech — "Gallery", a collection title.
   * The word "carousel" is supplied by `aria-roledescription`, so it must not
   * be repeated here (WAI-ARIA APG, Carousel pattern).
   */
  carousel: string;
  previous: string;
  next: string;
};

type CrossfadeProps = {
  slides: CrossfadeSlide[];
  /** `next/image` `sizes`, computed by the caller from its own layout. */
  sizes: string;
  labels: CrossfadeLabels;
  /** Carries the aspect ratio and the backdrop shown during the fade gap. */
  className?: string;
  /** Dwell time of one slide before the crossfade starts. */
  dwellMs?: number;
  /** Crossfade duration. Slow on purpose: one is looking at a jewel. */
  fadeMs?: number;
  /** `drift` adds the slow Ken Burns scale on the active slide. */
  motion?: "still" | "drift";
  /** `below` = a row under the frame; `overlay` = laid on the photo. */
  dots?: "below" | "overlay";
  /** `hover` reveals the arrows on hover/focus (they stay out on touch). */
  arrows?: "always" | "hover";
  /** Drives the dot and focus-ring colours, not the layout. */
  tone?: "gold" | "light" | "dark";
  /** true ONLY for a near-fold carousel whose first slide is the LCP. */
  priority?: boolean;
};

/** Visible fraction of the frame from which the autoplay starts. */
const IN_VIEW_RATIO = 0.4;
/** Scale drift, a little longer than dwell + fade so it never rests mid-move. */
const DRIFT_MS = 8000;
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

function subscribeReducedMotion(onChange: () => void) {
  const query = window.matchMedia(REDUCED_MOTION);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

function readReducedMotion() {
  return window.matchMedia(REDUCED_MOTION).matches;
}

/** Server snapshot: assume motion is allowed, the client corrects on subscribe. */
function readReducedMotionOnServer() {
  return false;
}

/** Only the previous / current / next slides are worth having in the DOM. */
function isWindowed(index: number, current: number, count: number) {
  if (count <= 3) {
    return true;
  }
  return (
    index === current || index === (current - 1 + count) % count || index === (current + 1) % count
  );
}

/**
 * How long a jump outside the window waits for its photograph before showing it
 * regardless. `onLoad` can simply never come (network error, decode failure)
 * and a control that answers nothing is worse than a brief grey frame.
 */
const PRELOAD_MAX_MS = 700;

const ARROW_BASE =
  "absolute top-1/2 flex -translate-y-1/2 items-center justify-center rounded-none border border-white/70 bg-foreground/35 text-white backdrop-blur-[6px] transition-[opacity,background-color] duration-300 hover:bg-foreground/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-white";

const ARROW_LAYOUT = {
  always: "size-10 sm:size-11.5",
  hover: "size-11 opacity-0 focus-visible:opacity-100 group-hover:opacity-100 max-lg:opacity-100",
} as const;

const ARROW_SIDE = {
  always: { previous: "left-3 sm:left-5", next: "right-3 sm:right-5" },
  hover: { previous: "left-4", next: "right-4" },
} as const;

/**
 * The `size-6` target is WCAG 2.2 SC 2.5.8 (24x24 CSS px); the 9px disc inside
 * is the visual, so the dots look exactly as they did before the target grew.
 */
const DOT_TARGET =
  "grid size-6 shrink-0 place-items-center rounded-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid";

const DOT_TONE = {
  gold: { ring: "focus-visible:outline-foreground", border: "border-gold", fill: "bg-gold" },
  light: { ring: "focus-visible:outline-white", border: "border-white", fill: "bg-white" },
  dark: {
    ring: "focus-visible:outline-gold-bright",
    border: "border-gold-bright",
    fill: "bg-gold-bright",
  },
} as const;

/**
 * The one crossfade carousel of the site: the home gallery (12 photographs) and
 * every gallery collection (3 each) are two configurations of this component.
 *
 * No Embla. Both usages are crossfades, not scrolling rails — no drag, no
 * snap — so the ~9 kB of `embla-carousel` + its `fade` plugin would buy nothing
 * and cost the scale drift (`08-dependencies-duplicates.md` §5).
 *
 * Behaviour is the safest of the two components it replaces:
 * - the autoplay only runs while the frame is on screen (`useInView`),
 * - hover *and* keyboard focus pause it,
 * - the first explicit action stops it for good (`tookOver`) — WCAG 2.2.2:
 *   taking the controls and then having the picture move under one's eyes is
 *   hostile,
 * - `prefers-reduced-motion` is a live subscription, not a one-shot read.
 */
export default function Crossfade({
  slides,
  sizes,
  labels,
  className,
  dwellMs = 6000,
  fadeMs = 1400,
  motion = "still",
  dots = "overlay",
  arrows = "hover",
  tone = "light",
  priority = false,
}: CrossfadeProps) {
  // Not `once`: the autoplay has to stop again when the frame scrolls out.
  const [rootRef, inView] = useInView<HTMLDivElement>({ threshold: IN_VIEW_RATIO });
  const [current, setCurrent] = useState(0);
  const [paused, setPaused] = useState(false);
  const [tookOver, setTookOver] = useState(false);
  // A jump *outside* the mounted window (dot 7 while slide 1 shows) has nothing
  // to fade from and nothing decoded to fade to: the outgoing slide would leave
  // the window and unmount on the spot, and the incoming one would mount
  // straight at `opacity-100` — a freshly inserted element does not transition —
  // over an <img> that has not been fetched yet, i.e. a bare grey frame.
  // `pending` mounts the target at `opacity-0` until its <img> is decoded;
  // `leaving` keeps the outgoing slide mounted for the length of the fade so it
  // really fades out. Contiguous moves (arrows, neighbouring dots) keep the
  // plain 3-slide window: both slides are already there and already loaded.
  const [pending, setPending] = useState<number | null>(null);
  const [leaving, setLeaving] = useState<number | null>(null);
  const reduced = useSyncExternalStore(
    subscribeReducedMotion,
    readReducedMotion,
    readReducedMotionOnServer,
  );

  const count = slides.length;
  const running = inView && !paused && !tookOver && !reduced && count > 1;

  const go = (index: number) => {
    const target = (index + count) % count;
    setTookOver(true);
    if (target === current) {
      return;
    }
    if (isWindowed(target, current, count)) {
      setPending(null);
      setLeaving(null);
      setCurrent(target);
      return;
    }
    setPending(target);
  };

  // Falls back to the immediate swap when the target's <img> never reports in.
  useEffect(() => {
    if (pending === null) {
      return;
    }
    const id = window.setTimeout(() => {
      setLeaving(current);
      setCurrent(pending);
      setPending(null);
    }, PRELOAD_MAX_MS);
    return () => window.clearTimeout(id);
  }, [pending, current]);

  // The outgoing slide is only worth a DOM node for the length of its fade.
  useEffect(() => {
    if (leaving === null) {
      return;
    }
    const id = window.setTimeout(() => setLeaving(null), fadeMs);
    return () => window.clearTimeout(id);
  }, [leaving, fadeMs]);

  // `current` is deliberately NOT a dependency (`05-process-gallery.md` §9):
  // it used to tear the interval down and rebuild it on every single tick. A
  // `resetKey` bumped inside `go()` would be redundant here — `tookOver` cuts
  // the autoplay for good on the first explicit action, so there is no timer
  // left to restart. `document.hidden` is read inside the tick rather than
  // through a global `visibilitychange` listener (§7).
  useEffect(() => {
    if (!running) {
      return;
    }
    const id = window.setInterval(() => {
      if (document.hidden) {
        return;
      }
      setCurrent((index) => (index + 1) % count);
    }, dwellMs);
    return () => window.clearInterval(id);
  }, [running, count, dwellMs]);

  const dotTone = DOT_TONE[tone];

  const pagination = (
    <div
      className={cn(
        "flex gap-1",
        dots === "below"
          ? "mt-6 flex-wrap justify-center"
          : "absolute bottom-2.5 left-1/2 -translate-x-1/2",
      )}
    >
      {slides.map((slide, index) => {
        const active = index === current;
        return (
          <button
            key={slide.src.src}
            type="button"
            onClick={() => go(index)}
            aria-label={slide.goToLabel}
            // `undefined` drops the attribute: `aria-current="false"` on the ten
            // other dots is noise in the accessibility tree.
            aria-current={active || undefined}
            className={cn(DOT_TARGET, dotTone.ring)}
          >
            <span
              aria-hidden="true"
              className={cn(
                "size-2.25 rounded-none border transition-colors",
                dotTone.border,
                active ? dotTone.fill : "bg-transparent",
              )}
            />
          </button>
        );
      })}
    </div>
  );

  return (
    // A named <section> IS the `region` landmark the APG carousel pattern asks
    // for — no `role` needed. The pause handlers are pointer/focus-only chrome;
    // every control stays reachable by keyboard on its own.
    <section
      aria-roledescription="carousel"
      aria-label={labels.carousel}
      className="group relative"
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocusCapture={() => setPaused(true)}
      onBlurCapture={() => setPaused(false)}
    >
      <div ref={rootRef} className={cn("relative w-full overflow-hidden", className)}>
        {slides.map((slide, index) => {
          if (!isWindowed(index, current, count) && index !== pending && index !== leaving) {
            return null;
          }
          const active = index === current;
          const drifting = motion === "drift" && active && running;
          return (
            <div
              key={slide.src.src}
              // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
              role="group"
              aria-roledescription="slide"
              aria-label={`${index + 1} / ${count}`}
              aria-hidden={!active}
              style={{ transitionDuration: `${fadeMs}ms` }}
              className={cn(
                "absolute inset-0 transition-opacity ease-in-out",
                active ? "opacity-100" : "opacity-0",
              )}
            >
              <Image
                alt={slide.alt}
                src={slide.src}
                fill
                sizes={sizes}
                // A placeholder only serves the slide one is waiting for. The
                // eleven others are never waited on, and each `blurDataURL`
                // costs ~320 B of RSC payload plus as much again of inline
                // `background-image` in the SSR HTML.
                placeholder={index === 0 ? "blur" : undefined}
                priority={priority && index === 0}
                // Hands the jump over only once this photograph can be shown.
                // next/image also fires `onLoad` from its ref when the <img> was
                // already complete (cache), so the callback has to be — and is —
                // idempotent.
                onLoad={
                  index === pending
                    ? () => {
                        setLeaving(current);
                        setCurrent(index);
                        setPending(null);
                      }
                    : undefined
                }
                style={{ transitionDuration: drifting ? `${DRIFT_MS}ms` : `${fadeMs}ms` }}
                className={cn(
                  "object-cover",
                  motion === "drift" && "motion-safe:transition-transform",
                  drifting ? "ease-linear motion-safe:scale-105" : "scale-100 ease-out",
                )}
              />
            </div>
          );
        })}

        <button
          type="button"
          onClick={() => go(current - 1)}
          aria-label={labels.previous}
          className={cn(ARROW_BASE, ARROW_LAYOUT[arrows], ARROW_SIDE[arrows].previous)}
        >
          <ChevronLeft aria-hidden="true" className="size-5" />
        </button>
        <button
          type="button"
          onClick={() => go(current + 1)}
          aria-label={labels.next}
          className={cn(ARROW_BASE, ARROW_LAYOUT[arrows], ARROW_SIDE[arrows].next)}
        >
          <ChevronRight aria-hidden="true" className="size-5" />
        </button>

        {dots === "overlay" ? pagination : null}
      </div>

      {dots === "below" ? pagination : null}

      {/* Silent while the carousel rotates on its own — announcing a slide the
          reader did not ask for is noise. Once the autoplay is stopped, every
          move is a deliberate one and gets announced. */}
      <div aria-live={running ? "off" : "polite"} className="sr-only">
        {`${current + 1} / ${count}`}
      </div>
    </section>
  );
}
