import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown";
import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc";

const ACCENTS = [
  "#60a5fa",
  "#c084fc",
  "#4ade80",
  "#fbbf24",
  "#f472b4",
  "#2dd4bf",
  "#fb923c",
  "#a78bfa",
  "#34d399",
  "#f87171",
];

// Darker variants of each accent for the light theme — the bright originals are
// tuned for dark cards and wash out on the cream background.
const LIGHT_ACCENTS = [
  "#2563eb",
  "#9333ea",
  "#16a34a",
  "#d97706",
  "#db2777",
  "#0d9488",
  "#ea580c",
  "#7c3aed",
  "#059669",
  "#dc2626",
];

const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));

function seeded(n: number): number {
  const x = Math.sin(n * 9301 + 49297) * 233280;
  return x - Math.floor(x);
}

interface PlacedNode {
  entry: VisualClusterEntry;
  index: number;
  x: number;
  y: number;
  w: number;
  h: number;
  zIndex: number;
  accent: string;
  driftX: number;
  driftY: number;
  driftDuration: number;
  rotateSeed: number;
}

function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] {
  if (!entries.length || containerW <= 0 || containerH <= 0) return [];

  const maxCount = Math.max(...entries.map((e) => e.count));
  const cx = containerW / 2;
  const cy = containerH / 2;
  const n = entries.length;
  const ASPECT = 0.72;
  const PAD = 18;

  // Card width scales with image count; the sub-linear exponent (< 1) widens the
  // gap so the busiest clusters read as clearly larger and more prominent.
  const rawWidth = (count: number) => {
    const ratio = Math.max(count / maxCount, 0.05);
    return 92 + Math.pow(ratio, 0.65) * 158; // ~92–250px before fit scaling
  };

  // Shrink every card uniformly when their padded footprint can't fit the
  // canvas, so overlap resolution can actually pull them apart instead of
  // settling into a pile. (0.6 leaves headroom for imperfect packing.)
  const totalArea = entries.reduce((sum, e) => {
    const w = rawWidth(e.count);
    return sum + (w + PAD) * (w * ASPECT + PAD);
  }, 0);
  const usableArea = containerW * containerH * 0.6;
  const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1;

  const spreadX = containerW * 0.44;
  const spreadY = containerH * 0.4;

  // 1. Seed positions on a phyllotaxis spiral, sized by count.
  const nodes: PlacedNode[] = entries.map((entry, i) => {
    const w = rawWidth(entry.count) * fit;
    const h = w * ASPECT;
    const radialRatio = Math.sqrt((i + 0.5) / n);
    const angle = i * GOLDEN_ANGLE;

    return {
      entry,
      index: i,
      x: cx + Math.cos(angle) * radialRatio * spreadX,
      y: cy + Math.sin(angle) * radialRatio * spreadY,
      w,
      h,
      // Bigger (busier) clusters stack above smaller ones, so they stay
      // clickable even if a sliver of overlap survives.
      zIndex: Math.round(w),
      accent: ACCENTS[i % ACCENTS.length],
      driftX: (seeded(i + 11) - 0.5) * 18,
      driftY: (seeded(i + 17) - 0.5) * 14,
      driftDuration: 8 + seeded(i + 23) * 7,
      rotateSeed: (seeded(i + 31) - 0.5) * 4,
    };
  });

  // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
  //    pass so edge cards settle in-bounds instead of being shoved out and
  //    re-overlapping there.
  const marginX = 14;
  const marginY = 14;
  for (let iter = 0; iter < 160; iter++) {
    for (let a = 0; a < nodes.length; a++) {
      const na = nodes[a];
      for (let b = a + 1; b < nodes.length; b++) {
        const nb = nodes[b];
        const dx = nb.x - na.x;
        const dy = nb.y - na.y;
        const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
        const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
        if (overlapX <= 0 || overlapY <= 0) continue;
        // Push along the smaller overlap axis (ternary yields ±1 so coincident
        // cards still separate rather than stalling at a zero push).
        if (overlapX < overlapY) {
          const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
          nb.x += push;
          na.x -= push;
        } else {
          const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
          nb.y += push;
          na.y -= push;
        }
      }
    }
    for (const node of nodes) {
      node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX);
      node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY);
    }
  }

  return nodes;
}

function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
  const src = mediaSrc(node.entry.thumbnail_path);
  const { w, h, accent } = node;
  const driftTransition = {
    duration: node.driftDuration,
    ease: "easeInOut" as const,
    delay: seeded(node.index + 41) * 1.6,
    repeat: 1,
    repeatType: "reverse" as const,
  };

  return (
    <motion.button
      className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
      style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
      initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
      animate={
        animated
          ? {
              opacity: 1,
              scale: 1,
              x: [0, node.driftX * 0.65, 0],
              y: [0, node.driftY * 0.65, 0],
              rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
            }
          : { opacity: 1, scale: 1, rotate: node.rotateSeed }
      }
      transition={
        animated
          ? {
              opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
              scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
              x: driftTransition,
              y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
              rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
            }
          : { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
      }
      whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
      onClick={() => onOpen(node.entry.image_ids)}
      title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
    >
      {src ? (
        <img
          src={src}
          alt=""
          className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
          draggable={false}
          loading="lazy"
          decoding="async"
        />
      ) : (
        <div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
      )}
      <div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
      {/* Accent glow on hover */}
      <div
        className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
        style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
      />
      <div className="absolute inset-x-0 bottom-0 p-3">
        <div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
        <p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
        <p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
      </div>
      {/* Anchored to the card corner (not in the count's flex row) so a wide
          count can't push it past the edge on small cards. */}
      <span
        className="explore-cluster-open absolute bottom-3 right-3 rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
        style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
      >
        Open
      </span>
    </motion.button>
  );
}

interface AtlasNode {
  entry: ExploreTagEntry;
  index: number;
  x: number;
  y: number;
  w: number;
  h: number;
  fontSize: number;
  ratio: number;
  accent: string;
  driftX: number;
  driftY: number;
}

interface TagAnchor {
  x: number;
  y: number;
}

const TAG_ATLAS_MAX_VISIBLE = 132;
const TAG_ATLAS_DENSE_THRESHOLD = 120;

function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] {
  if (!entries.length || containerW <= 0 || containerH <= 0) return [];

  const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1)));
  const logMin = Math.min(...logs);
  const logRange = Math.max(...logs) - logMin || 1;
  const cx = containerW / 2;
  const cy = containerH * 0.48;
  const spreadX = containerW * 0.47;
  const spreadY = containerH * 0.46;
  const accents = isLight ? LIGHT_ACCENTS : ACCENTS;

  const nodes = entries.map((entry, index) => {
    const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange;
    const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1;
    const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale;
    const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2;
    const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26);
    const w = Math.min(containerW * maxWidthRatio, textWidth);
    const h = fontSize * 1.18 + 14;
    const radialRatio = Math.sqrt((index + 0.5) / entries.length);
    const angle = index * GOLDEN_ANGLE;
    return {
      entry,
      index,
      x: cx + Math.cos(angle) * radialRatio * spreadX,
      y: cy + Math.sin(angle) * radialRatio * spreadY,
      w,
      h,
      fontSize,
      ratio,
      accent: accents[index % accents.length],
      driftX: (seeded(index + 101) - 0.5) * 7,
      driftY: (seeded(index + 113) - 0.5) * 6,
    };
  });

  const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10;
  const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7;
  const margin = 28;
  for (let iter = 0; iter < 140; iter++) {
    for (let a = 0; a < nodes.length; a++) {
      const na = nodes[a];
      for (let b = a + 1; b < nodes.length; b++) {
        const nb = nodes[b];
        const dx = nb.x - na.x;
        const dy = nb.y - na.y;
        const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx);
        const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy);
        if (overlapX <= 0 || overlapY <= 0) continue;
        if (overlapX < overlapY) {
          const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
          nb.x += push;
          na.x -= push;
        } else {
          const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
          nb.y += push;
          na.y -= push;
        }
      }
    }
    for (const node of nodes) {
      node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin);
      node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin);
      if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) {
        node.y += (seeded(node.index + iter + 211) - 0.5) * 2;
      }
      if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) {
        node.x += (seeded(node.index + iter + 223) - 0.5) * 2;
      }
    }
  }

  for (let iter = 0; iter < 5; iter++) {
    const weighted = nodes.reduce(
      (acc, node) => {
        const weight = 1 + Math.pow(node.ratio, 1.35) * 9;
        return {
          x: acc.x + node.x * weight,
          y: acc.y + node.y * weight,
          weight: acc.weight + weight,
        };
      },
      { x: 0, y: 0, weight: 0 },
    );
    const offsetX = containerW * 0.48 - weighted.x / weighted.weight;
    const offsetY = containerH * 0.44 - weighted.y / weighted.weight;
    if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break;
    for (const node of nodes) {
      node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin);
      node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin);
    }
  }

  return nodes;
}

function TagAtlas({
  entries,
  onSearch,
  loadRelatedTags,
}: {
  entries: ExploreTagEntry[];
  onSearch: (tag: string) => void;
  loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
}) {
  const theme = useGalleryStore((state) => state.theme);
  const isLight = theme === "subtle-light";
  const reducedMotion = useReducedMotion();
  const canvasRef = useRef<HTMLDivElement>(null);
  const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
  const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
  const [activeTag, setActiveTag] = useState<string | null>(null);
  const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]);
  const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({});
  const visibleEntries = useMemo(
    () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries),
    [entries],
  );

  useLayoutEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const update = () => {
      const r = el.getBoundingClientRect();
      setCanvasSize({ w: r.width, h: r.height });
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  useEffect(() => {
    if (!activeTag) {
      setRelatedTags([]);
      return;
    }
    let cancelled = false;
    void loadRelatedTags(activeTag).then((entries) => {
      if (!cancelled) setRelatedTags(entries);
    });
    return () => {
      cancelled = true;
    };
  }, [activeTag, loadRelatedTags]);

  const nodes = useMemo(
    () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight),
    [visibleEntries, canvasSize.w, canvasSize.h, isLight],
  );
  const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]);
  const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined;
  const minOpacity = isLight ? 0.62 : 0.42;
  const visibleConnections = useMemo(
    () =>
      activeNode
        ? relatedTags
            .map((related) => {
              const node = nodeByTag.get(related.tag);
              return node ? { node, related } : null;
            })
            .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null)
            .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10)
        : [],
    [activeNode, nodeByTag, relatedTags, visibleEntries.length],
  );
  const activeAnchor = activeTag ? anchors[activeTag] : undefined;
  const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count));
  const connectedByTag = useMemo(
    () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])),
    [visibleConnections],
  );

  const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => {
    if (element) {
      buttonRefs.current.set(tag, element);
    } else {
      buttonRefs.current.delete(tag);
    }
  }, []);

  const measureAnchors = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas || !activeTag) {
      setAnchors({});
      return;
    }

    const canvasRect = canvas.getBoundingClientRect();
    const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)];
    const nextAnchors: Record<string, TagAnchor> = {};
    for (const tag of tagsToMeasure) {
      const element = buttonRefs.current.get(tag);
      if (!element) continue;
      const rect = element.getBoundingClientRect();
      nextAnchors[tag] = {
        x: rect.left - canvasRect.left + rect.width / 2,
        y: rect.top - canvasRect.top + rect.height / 2,
      };
    }
    setAnchors(nextAnchors);
  }, [activeTag, visibleConnections]);

  useLayoutEffect(() => {
    if (!activeTag) {
      setAnchors({});
      return;
    }

    let firstFrame = 0;
    let secondFrame = 0;
    const settleTimer = window.setTimeout(measureAnchors, 180);
    firstFrame = window.requestAnimationFrame(() => {
      measureAnchors();
      secondFrame = window.requestAnimationFrame(measureAnchors);
    });
    return () => {
      window.cancelAnimationFrame(firstFrame);
      window.cancelAnimationFrame(secondFrame);
      window.clearTimeout(settleTimer);
    };
  }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]);

  return (
    <div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
      <svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
        <defs>
          <radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
            <stop offset="0%" stopColor={isLight ? "rgba(60,50,30,0.18)" : "rgba(255,255,255,0.2)"} />
            <stop offset="100%" stopColor="rgba(0,0,0,0)" />
          </radialGradient>
        </defs>
        {activeNode && activeAnchor ? (
          <circle cx={activeAnchor.x} cy={activeAnchor.y} r={Math.max(activeNode.w, activeNode.h) * 0.62} fill="url(#tag-atlas-glow)" opacity="0.24" />
        ) : null}
        {visibleConnections.map(({ node, related }) => {
          const from = activeAnchor;
          const to = anchors[node.entry.tag];
          if (!from || !to) return null;
          const strength = related.shared_count / maxShared;
          return (
            <g key={`${activeTag}:${node.entry.tag}`}>
              <motion.line
                x1={from.x}
                y1={from.y}
                x2={to.x}
                y2={to.y}
                stroke={node.accent}
                strokeWidth={0.35 + strength * 0.55}
                strokeOpacity={0.05 + strength * 0.12}
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
                initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
                animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
                transition={{ duration: 0.32, ease: "easeOut" }}
              />
              {!reducedMotion ? (
                <motion.line
                  x1={from.x}
                  y1={from.y}
                  x2={to.x}
                  y2={to.y}
                  stroke={node.accent}
                  strokeWidth={0.65 + strength * 0.5}
                  strokeOpacity={0.16 + strength * 0.1}
                  strokeLinecap="round"
                  pathLength={1}
                  strokeDasharray="0.08 1"
                  vectorEffect="non-scaling-stroke"
                  initial={{ strokeDashoffset: 1.08, opacity: 0 }}
                  animate={{ strokeDashoffset: [1.08, 0], opacity: [0, 0.65, 0] }}
                  transition={{
                    duration: 4.1 + seeded(node.index + 307) * 1.2,
                    repeat: Infinity,
                    ease: "easeInOut",
                    delay: seeded(node.index + 317) * 0.75,
                  }}
                />
              ) : null}
            </g>
          );
        })}
      </svg>
      {nodes.map((node) => {
        const isActive = activeTag === node.entry.tag;
        const connectedRelated = connectedByTag.get(node.entry.tag);
        const isRelated = connectedRelated !== undefined;
        const dimmed = activeTag !== null && !isActive && !isRelated;
        const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity);
        return (
          <Tooltip
            key={node.entry.tag}
            label={`${node.entry.tag} — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
            followCursor
            delay={250}
          >
            <button
              ref={(element) => setButtonRef(node.entry.tag, element)}
              className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${
                isActive ? "before:opacity-100" : ""
              }`}
              style={{
                left: node.x - node.w / 2,
                top: node.y - node.h / 2,
                width: node.w,
                minHeight: node.h,
                fontSize: node.fontSize,
                color: node.ratio > 0.55 ? node.accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)",
                opacity,
                transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
                zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
              }}
              onMouseEnter={() => setActiveTag(node.entry.tag)}
              onFocus={() => setActiveTag(node.entry.tag)}
              onMouseLeave={() => setActiveTag(null)}
              onBlur={() => setActiveTag(null)}
              onClick={() => onSearch(node.entry.tag)}
            >
              <span className="relative z-10 block w-full truncate text-center font-medium leading-none">{node.entry.tag}</span>
              <span
                className={`absolute left-1/2 top-full z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${
                  isActive || isRelated ? "opacity-100" : "opacity-0 group-hover:opacity-100"
                }`}
                style={{ backgroundColor: `${node.accent}1A`, color: node.accent }}
              >
                {connectedRelated ? connectedRelated.shared_count.toLocaleString() : node.entry.count.toLocaleString()}
              </span>
            </button>
          </Tooltip>
        );
      })}
    </div>
  );
}

function Spinner() {
  return (
    <motion.div
      className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
      animate={{ rotate: 360 }}
      transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
    />
  );
}

function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
  const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies";
  const subtitle =
    mode === "visual"
      ? "Grouping similar images into browsable clusters."
      : "Collecting tags from the current library scope.";

  return (
    <div className="flex flex-1 items-center justify-center px-8">
      <div className="flex max-w-sm flex-col items-center text-center">
        <div className="mb-4 flex items-center justify-center gap-3 text-white/65">
          <Spinner />
          <span className="text-sm font-medium">{title}</span>
        </div>
        <p className="text-sm leading-relaxed text-white/25">{subtitle}</p>
        <div className="mt-6 h-px w-40 overflow-hidden rounded-full bg-white/[0.06]">
          <motion.div
            className="h-full w-16 rounded-full bg-white/25"
            animate={{ x: [-72, 184] }}
            transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
          />
        </div>
      </div>
    </div>
  );
}

// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at ExploreView mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
  entries,
  onOpen,
}: {
  entries: VisualClusterEntry[];
  onOpen: (imageIds: number[]) => void;
}) {
  const reducedMotion = useReducedMotion();
  const canvasRef = useRef<HTMLDivElement>(null);
  const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });

  useLayoutEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const update = () => {
      const r = el.getBoundingClientRect();
      setCanvasSize({ w: r.width, h: r.height });
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const nodes = useMemo(
    () => buildCloud(entries, canvasSize.w, canvasSize.h),
    [entries, canvasSize.w, canvasSize.h],
  );

  return (
    <div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
      <div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
      {nodes.map((node) => (
        <CloudCard
          key={`${node.entry.representative_image_id}:${node.index}`}
          node={node}
          onOpen={onOpen}
          animated={!reducedMotion && node.index < 12}
        />
      ))}
    </div>
  );
}

type TagManageSort = "count_desc" | "count_asc" | "az" | "za";

const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
  { value: "count_desc", label: "Most used" },
  { value: "count_asc", label: "Least used" },
  { value: "az", label: "A-Z" },
  { value: "za", label: "Z-A" },
];

// Compact management tile for a single tag. Rename doubles as merge when the new
// name already exists, and delete applies across the scoped tag set.
function TagManageTile({
  entry,
  onSearch,
  onRename,
  onDelete,
}: {
  entry: ExploreTagEntry;
  onSearch: (tag: string) => void;
  onRename: (from: string, to: string) => Promise<void>;
  onDelete: (tag: string) => Promise<void>;
}) {
  const [editing, setEditing] = useState(false);
  const [value, setValue] = useState(entry.tag);
  const [confirming, setConfirming] = useState(false);
  const [busy, setBusy] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (editing) {
      setValue(entry.tag);
      setTimeout(() => inputRef.current?.select(), 0);
    }
  }, [editing, entry.tag]);

  const commitRename = async () => {
    const next = value.trim();
    if (!next || next === entry.tag) {
      setEditing(false);
      return;
    }
    setBusy(true);
    try {
      await onRename(entry.tag, next);
      setEditing(false);
    } finally {
      setBusy(false);
    }
  };

  return (
    <div
      data-tag-manager-tile
      className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
        editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
      }`}
    >
      <div className="flex min-w-0 items-start gap-2">
        {editing ? (
          <input
            ref={inputRef}
            className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/30"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
              if (e.key === "Escape") setEditing(false);
            }}
            disabled={busy}
          />
        ) : (
          <Tooltip label="Search this tag" delay={500} anchorToCursor className="min-w-0 flex-1">
            <button
              className="tag-manager-name block w-full truncate pr-36 text-left text-sm font-medium text-white/85 transition-colors hover:text-white"
              onClick={() => onSearch(entry.tag)}
            >
              {entry.tag}
            </button>
          </Tooltip>
        )}
        <span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
          {entry.count.toLocaleString()}
        </span>
      </div>

      {editing ? (
        <div className="mt-2 flex items-center gap-1">
          <button
            className="tag-manager-save rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
            onClick={() => void commitRename()}
            disabled={busy || !value.trim()}
          >
            Save
          </button>
          <button
            className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
            onClick={() => setEditing(false)}
            disabled={busy}
          >
            Cancel
          </button>
        </div>
      ) : confirming ? (
        <div className="mt-2 flex items-center gap-1">
          <button
            className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
            onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
            disabled={busy}
          >
            Delete
          </button>
          <button
            className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
            onClick={() => setConfirming(false)}
            disabled={busy}
          >
            Cancel
          </button>
        </div>
      ) : (
        <div className="pointer-events-none absolute right-12 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
          <Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
            <button
              className="tag-manager-action rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
              onClick={() => setEditing(true)}
            >
              Rename
            </button>
          </Tooltip>
          <button
            className="tag-manager-action tag-manager-action-danger rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
            onClick={() => setConfirming(true)}
          >
            Delete
          </button>
        </div>
      )}
    </div>
  );
}

function TagManageList({
  entries,
  onSearch,
  onRename,
  onDelete,
}: {
  entries: ExploreTagEntry[];
  onSearch: (tag: string) => void;
  onRename: (from: string, to: string) => Promise<void>;
  onDelete: (tag: string) => Promise<void>;
}) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const measureRef = useRef<HTMLDivElement>(null);
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<TagManageSort>("count_desc");
  const [columns, setColumns] = useState(3);

  useLayoutEffect(() => {
    const el = measureRef.current;
    if (!el) return;
    const update = () => {
      const width = el.getBoundingClientRect().width;
      setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1);
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const filteredEntries = useMemo(() => {
    const needle = query.trim().toLowerCase();
    const filtered = needle
      ? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
      : entries;
    return [...filtered].sort((left, right) => {
      switch (sort) {
        case "count_asc":
          return left.count - right.count || left.tag.localeCompare(right.tag);
        case "az":
          return left.tag.localeCompare(right.tag);
        case "za":
          return right.tag.localeCompare(left.tag);
        case "count_desc":
        default:
          return right.count - left.count || left.tag.localeCompare(right.tag);
      }
    });
  }, [entries, query, sort]);

  const rowCount = Math.ceil(filteredEntries.length / columns);
  const rowVirtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => 54,
    overscan: 7,
  });
  const visibleItems = rowVirtualizer.getVirtualItems();
  const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);

  return (
    <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
      <div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div className="min-w-0">
            <div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32">
              <span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{entries.length.toLocaleString()} tags</span>
              <span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{totalUses.toLocaleString()} uses</span>
              {query.trim() ? (
                <span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums">
                  {filteredEntries.length.toLocaleString()} matches
                </span>
              ) : null}
            </div>
            <p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
              Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
            </p>
          </div>

          <div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
            <div className="relative min-w-[220px] flex-1 sm:max-w-sm">
              <input
                className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Filter tags"
              />
              {query ? (
                <span className="absolute right-2 top-2 z-10">
                  <Tooltip label="Clear filter" delay={400} anchorToCursor>
                    <button
                      className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70"
                      onClick={() => setQuery("")}
                      aria-label="Clear tag filter"
                    >
                      <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M6 6l12 12M18 6L6 18" />
                      </svg>
                    </button>
                  </Tooltip>
                </span>
              ) : null}
            </div>
            <ThemedDropdown
              value={sort}
              onChange={(value) => setSort(value as TagManageSort)}
              options={TAG_MANAGE_SORTS}
              ariaLabel="Sort managed tags"
              align="right"
            />
          </div>
        </div>
      </div>

      <div ref={scrollRef} data-tag-manager-scroll className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
        <div ref={measureRef} className="mx-auto w-full max-w-7xl">
          {filteredEntries.length === 0 ? (
            <div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30">
              No tags match that filter.
            </div>
          ) : (
            <div
              className="relative w-full"
              style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
            >
              {visibleItems.map((virtualRow) => {
                const start = virtualRow.index * columns;
                const rowEntries = filteredEntries.slice(start, start + columns);
                return (
                  <div
                    key={virtualRow.key}
                    data-index={virtualRow.index}
                    ref={rowVirtualizer.measureElement}
                    className="absolute left-0 top-0 grid w-full gap-x-2 pb-2"
                    style={{
                      gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
                      transform: `translateY(${virtualRow.start}px)`,
                    }}
                  >
                    {rowEntries.map((entry) => (
                      <TagManageTile
                        key={entry.tag}
                        entry={entry}
                        onSearch={onSearch}
                        onRename={onRename}
                        onDelete={onDelete}
                      />
                    ))}
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export function ExploreView() {
  const exploreMode = useGalleryStore((state) => state.exploreMode);
  const setExploreMode = useGalleryStore((state) => state.setExploreMode);
  const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries);
  const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading);
  const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters);
  const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
  const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
  const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
  const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags);
  const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
  const searchForTag = useGalleryStore((state) => state.searchForTag);
  const renameTag = useGalleryStore((state) => state.renameTag);
  const deleteTag = useGalleryStore((state) => state.deleteTag);
  const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
  // Manage mode lives in the store so it can be opened from elsewhere (Settings).
  const manageTags = useGalleryStore((state) => state.tagManagerOpen);
  const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
  const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };

  useEffect(() => {
    if (exploreMode === "visual") void loadVisualClusters();
    else void loadExploreTags();
  }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]);

  const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading;
  const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0;
  const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length;
  const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);

  return (
    <div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
      {/* Header — `relative z-10` keeps the folder-scope dropdown above the
          cluster canvas, whose cards use a high z-index of their own. */}
      <div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
        <div className="flex items-center justify-between gap-4">
          <div className="min-w-0">
            <h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
            <p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
              {loading
                ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
                : hasEntries
                  ? exploreMode === "visual"
                    ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
                    : manageTags
                      ? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage`
                    : visibleTagCount < entryCount
                      ? `${visibleTagCount} of ${entryCount} tags shown — click any to search`
                      : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
                  : exploreMode === "visual"
                    ? "No clusters — images need embeddings first"
                    : "No tags — run the AI tagger or add tags manually"}
            </p>
          </div>
          <div className="flex shrink-0 items-center gap-2">
            {exploreMode === "tags" && hasEntries ? (
              <button
                className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
                  manageTags
                    ? "border-white/15 bg-white/10 text-white"
                    : "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
                }`}
                onClick={() => setManageTags(!manageTags)}
              >
                {manageTags ? "Done" : "Manage"}
              </button>
            ) : null}
            <FolderScopeDropdown />
            <div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
              <button
                className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
                  exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
                }`}
                onClick={() => setExploreMode("visual")}
              >
                Clusters
              </button>
              <button
                className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
                  exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
                }`}
                onClick={() => setExploreMode("tags")}
              >
                Tag Cloud
              </button>
            </div>
          </div>
        </div>
      </div>

      {loading && !hasEntries ? (
        <ExploreLoadingPanel mode={exploreMode} />
      ) : !hasEntries ? (
        <div className="flex flex-1 items-center justify-center px-8">
          <p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
            {exploreMode === "visual"
              ? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
              : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
          </p>
        </div>
      ) : exploreMode === "visual" ? (
        <div className="relative flex min-h-0 flex-1 flex-col">
          <ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
        </div>
      ) : manageTags ? (
        <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
          <TagManageList
            entries={exploreTagEntries}
            onSearch={searchForTag}
            onRename={renameTag}
            onDelete={handleDeleteTag}
          />
        </div>
      ) : (
        <TagAtlas entries={exploreTagEntries} onSearch={searchForTag} loadRelatedTags={loadRelatedTags} />
      )}
    </div>
  );
}
