import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
import type { ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text, Environment } from '@react-three/drei';
import * as THREE from 'three';
import type {
  ArtistDetail,
  MovementGalleryDetail,
  Painting,
  ArtistPeriod,
  ArtistNavigation,
  MovementArtistGroup,
  TourGalleryDetail,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
import MovementHallDetails from './MovementHallDetails';
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
import HallPassage from './HallPassage';
import {
  buildAllMovementHallLayouts,
  computeSideWallWindows,
  type MovementHallLayout,
} from '../utils/movementHallLayout';
import './VirtualGallery.css';
import GalleryLoadingMarker from './GalleryLoadingMarker';

const GalleryTextureLoadContext = createContext<{
  begin: () => void;
  end: () => void;
} | null>(null);

/**
 * Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
 * from unmounting the whole 3D scene and leaving a dark window.
 */
class SceneErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: unknown) {
    console.warn('Gallery scene subtree failed, continuing without it.', error);
  }

  render() {
    if (this.state.hasError) return this.props.fallback ?? null;
    return this.props.children;
  }
}

interface BaseGalleryProps {
  imageRevisions?: Record<number, number>;
  active?: boolean;
  onPaintingClick: (paintingId: number) => void;
  onBack: () => void;
}

interface ArtistGalleryProps extends BaseGalleryProps {
  mode: 'artist';
  data: ArtistDetail;
  onNavigateArtist: (artistId: number) => void;
  onBioClick: () => void;
}

interface MovementGalleryProps extends BaseGalleryProps {
  mode: 'movement';
  data: MovementGalleryDetail;
}

interface TourGalleryProps extends BaseGalleryProps {
  mode: 'tour';
  data: TourGalleryDetail;
}

type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;

const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const BACK_WALL_EXTRA = 0.05;
const FRAME_FACE_Z = 0.018;
const FRAME_DEPTH = 0.07;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
/** Checked paintings — double-width moulding. */
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2;
const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.32;
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 9;
const ROW_GAP = 0.2;
const WALL_PADDING = 1.4;
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
const MAX_WALL_ROWS = 1;
const DOOR_WIDTH = 2.4;
const DOOR_HEIGHT = 2.5;
/** Museum exit — dimensions derived from door opening. */
const EXIT_JAMB = 0.13;
const EXIT_HEADER = 0.15;
const EXIT_TRANSOM = 0.52;
const EXIT_SURROUND = 0.1;
const TURN_SPEED = 0.032;
const MOUSE_TURN_SENSITIVITY = 0.004;
const DRAG_START_THRESHOLD_PX = 5;

const DEFAULT_MOVEMENT_COLOR = '#8B7355';
const WALL_BASE_MAIN = '#f0ebe3';
const WALL_BASE_SIDE = '#e8e2d8';

function parseHex(hex: string): { r: number; g: number; b: number } {
  const n = parseInt(hex.replace('#', ''), 16);
  if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 };
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

function blendHex(base: string, accent: string, accentWeight: number): string {
  const a = parseHex(accent);
  const b = parseHex(base);
  const w = Math.min(1, Math.max(0, accentWeight));
  const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w));
  const r = mix(a.r, b.r);
  const g = mix(a.g, b.g);
  const bl = mix(a.b, b.b);
  return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
}

function galleryWallColors(movementColor?: string) {
  const accent = movementColor || DEFAULT_MOVEMENT_COLOR;
  return {
    main: blendHex(WALL_BASE_MAIN, accent, 0.34),
    side: blendHex(WALL_BASE_SIDE, accent, 0.26),
    trim: blendHex('#b8956a', accent, 0.45),
  };
}

/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;

type WallSide = 'back' | 'left' | 'right';

interface FrameSlot {
  position: [number, number, number];
  rotationY: number;
  maxW: number;
  maxH: number;
  side: WallSide;
}

interface WallSegment {
  side: WallSide;
  label: string;
  paintings: Painting[];
  slots: FrameSlot[];
}

interface HallLayout {
  width: number;
  depth: number;
  segments: WallSegment[];
}

function paintingIsReviewed(painting: Painting): boolean {
  return !!painting.checkup_checked;
}

function frameDimsForReviewed(reviewed: boolean) {
  return reviewed
    ? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 }
    : { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH };
}

function frameOuterW(canvasW: number, reviewed: boolean): number {
  const { matBorder, rail } = frameDimsForReviewed(reviewed);
  return canvasW + matBorder * 2 + rail;
}

function frameOuterH(canvasH: number, reviewed: boolean): number {
  const { matBorder, rail } = frameDimsForReviewed(reviewed);
  return canvasH + matBorder * 2 + rail;
}

function layoutRow(paintings: Painting[], span: number) {
  const count = paintings.length;
  if (count === 0) return { slots: [], spanNeeded: span };

  const gap = FRAME_GAP;
  const available = span - WALL_PADDING;

  let frameW = MAX_FRAME_W;
  for (let attempt = 0; attempt < 40; attempt++) {
    let total = 0;
    for (let i = 0; i < count; i++) {
      total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
      if (i < count - 1) total += gap;
    }
    if (total <= available) break;
    frameW -= 0.015;
  }
  frameW = Math.max(MIN_FRAME_W, frameW);

  const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
  const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
  const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap;
  const slots: { offset: number; maxW: number; maxH: number }[] = [];

  let cursor = -rowWidth / 2;
  for (let i = 0; i < count; i++) {
    const outerW = outers[i];
    slots.push({
      offset: cursor + outerW / 2,
      maxW: frameW,
      maxH: frameH,
    });
    cursor += outerW + gap;
  }

  return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}

function wallRowHeights(paintings: Painting[], span: number, rows: number) {
  const perRow = Math.ceil(paintings.length / rows);
  const heights: number[] = [];
  for (let r = 0; r < rows; r++) {
    const rowStart = r * perRow;
    const row = paintings.slice(rowStart, rowStart + perRow);
    const { slots } = layoutRow(row, span);
    const reviewed = row.some(paintingIsReviewed);
    heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed));
  }
  return heights;
}

function wallStackHeight(heights: number[]) {
  return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
}

function fitsOnWall(paintings: Painting[], span: number, rows: number) {
  const count = paintings.length;
  const perRow = Math.ceil(count / rows);
  const row = paintings.slice(0, perRow);
  const { slots } = layoutRow(row, span);
  if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false;

  const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows));
  const bottom = EYE_HEIGHT - totalHeight / 2;
  const top = EYE_HEIGHT + totalHeight / 2;
  if (bottom < 0.35) return false;
  if (top > WALL_HEIGHT - 0.45) return false;
  return true;
}

function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) {
  const count = paintings.length;
  const limit = Math.max(1, Math.min(count, maxRows));
  for (let rows = 1; rows <= limit; rows++) {
    if (fitsOnWall(paintings, span, rows)) return rows;
  }
  return limit;
}

function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) {
  const count = paintings.length;
  if (count === 0) return MIN_HALL_SIZE;

  let best = Infinity;
  const rowLimit = Math.max(1, Math.min(count, maxRows));
  for (let rows = 1; rows <= rowLimit; rows++) {
    const perRow = Math.ceil(count / rows);
    const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE);
    if (fitsOnWall(paintings, spanNeeded, rows)) {
      best = Math.min(best, spanNeeded);
    }
  }

  if (best === Infinity) {
    const perRow = Math.ceil(count / rowLimit);
    best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded;
  }

  return Math.max(MIN_HALL_SIZE, best);
}

/**
 * U-shaped visit order:
 *   left wall (first, near entrance) → far/end wall → right wall (last, near entrance).
 * Fewer than 3 works keep the old left/right split so first stays left and last stays right.
 * The far wall is the solid wall ahead when entering (code side `'back'`).
 */
function distributePaintingsAcrossWalls(paintings: Painting[]) {
  const ordered = [...paintings].sort(comparePaintingsChronological);
  const n = ordered.length;
  if (n < 3) {
    const mid = Math.ceil(n / 2);
    return [[] as Painting[], ordered.slice(0, mid), ordered.slice(mid)];
  }
  const q = Math.floor(n / 3);
  const r = n % 3;
  const leftCount = q + (r > 0 ? 1 : 0);
  const backCount = q + (r > 1 ? 1 : 0);
  return [
    ordered.slice(leftCount, leftCount + backCount),
    ordered.slice(0, leftCount),
    ordered.slice(leftCount + backCount),
  ];
}

function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
  const periodIds = new Set(
    wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0)
  );
  const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name);
  const hasUnassigned = wallPaintings.some((p) => !p.period_id);
  if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`;
  if (names.length > 0) return names.join(' · ');
  if (hasUnassigned) return 'Other Works';
  return wallPaintings.length > 0 ? 'Works' : '';
}

function layoutWallSlots(
  paintings: Painting[],
  span: number,
  side: WallSide,
  halfW: number,
  halfD: number,
  inset: number,
  maxRows: number = paintings.length
): FrameSlot[] {
  const count = paintings.length;
  if (count === 0) return [];

  const rows = rowCountForWall(paintings, span, maxRows);
  const perRow = Math.ceil(count / rows);
  const slots: FrameSlot[] = [];

  const rowLayouts = [];
  for (let r = 0; r < rows; r++) {
    const rowStart = r * perRow;
    const inRow = Math.min(perRow, count - rowStart);
    rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span));
  }

  const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
  const totalHeight = wallStackHeight(rowHeights);
  let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2;

  for (let r = 0; r < rows; r++) {
    const { slots: rowSlots } = rowLayouts[r];
    const rowFrameH = rowHeights[r];

    for (let i = 0; i < rowSlots.length; i++) {
      const s = rowSlots[i];
      if (side === 'back') {
        slots.push({
          maxW: s.maxW,
          maxH: s.maxH,
          rotationY: 0,
          side,
          position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
        });
      } else if (side === 'left') {
        // Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
        slots.push({
          maxW: s.maxW,
          maxH: s.maxH,
          rotationY: Math.PI / 2,
          side,
          position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
        });
      } else {
        slots.push({
          maxW: s.maxW,
          maxH: s.maxH,
          rotationY: -Math.PI / 2,
          side,
          position: [halfW - inset - WALL_STANDOFF, y, s.offset],
        });
      }
    }

    y += rowFrameH + ROW_GAP;
  }

  return slots;
}

function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
  const walls: WallSide[] = ['back', 'left', 'right'];
  const wallPaintings = distributePaintingsAcrossWalls(paintings);
  const [backPaintings, leftPaintings, rightPaintings] = wallPaintings;

  let width = minSpanForWall(backPaintings, MAX_WALL_ROWS);
  let depth = Math.max(
    minSpanForWall(leftPaintings, MAX_WALL_ROWS),
    minSpanForWall(rightPaintings, MAX_WALL_ROWS)
  );

  for (let i = 0; i < 24; i++) {
    const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS);
    const nextDepth = Math.max(
      minSpanForWall(leftPaintings, MAX_WALL_ROWS),
      minSpanForWall(rightPaintings, MAX_WALL_ROWS)
    );
    if (nextWidth === width && nextDepth === depth) break;
    width = nextWidth;
    depth = nextDepth;
  }

  width = Math.max(width, MIN_HALL_SIZE);
  depth = Math.max(depth, MIN_HALL_SIZE);

  const halfW = width / 2;
  const halfD = depth / 2;
  const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;

  const segments: WallSegment[] = walls.map((side, i) => ({
    side,
    label: wallLabelForPaintings(wallPaintings[i], periods),
    paintings: wallPaintings[i],
    slots: layoutWallSlots(
      wallPaintings[i],
      side === 'back' ? width : depth,
      side,
      halfW,
      halfD,
      inset,
      MAX_WALL_ROWS
    ),
  }));

  return { width, depth, segments };
}

function computeFrameSize(aspect: number, maxW: number, maxH: number) {
  let w = maxW;
  let h = w / aspect;
  if (h > maxH) {
    h = maxH;
    w = h * aspect;
  }
  return { width: w, height: h };
}

function paintingHasGalleryImage(painting: Painting) {
  return !!(painting.thumbnail_path || painting.image_path);
}

let canvasWeaveTexture: THREE.CanvasTexture | null = null;

function getCanvasWeaveTexture() {
  if (canvasWeaveTexture) return canvasWeaveTexture;

  const size = 256;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (ctx) {
    ctx.fillStyle = '#ddd0b8';
    ctx.fillRect(0, 0, size, size);
    for (let y = 0; y < size; y += 4) {
      for (let x = 0; x < size; x += 4) {
        ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8';
        ctx.fillRect(x, y, 4, 4);
      }
    }
    ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)';
    ctx.lineWidth = 1;
    for (let i = 0; i <= size; i += 8) {
      ctx.beginPath();
      ctx.moveTo(i, 0);
      ctx.lineTo(i, size);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(0, i);
      ctx.lineTo(size, i);
      ctx.stroke();
    }
  }

  canvasWeaveTexture = new THREE.CanvasTexture(canvas);
  canvasWeaveTexture.wrapS = THREE.RepeatWrapping;
  canvasWeaveTexture.wrapT = THREE.RepeatWrapping;
  canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace;
  return canvasWeaveTexture;
}

function frameFinish(reviewed: boolean, hovered: boolean) {
  if (reviewed) {
    return {
      color: hovered ? '#ffe566' : '#ffd700',
      roughness: 0.22,
      metalness: 0.78,
      emissive: hovered ? '#ffcc00' : '#daa520',
      emissiveIntensity: hovered ? 0.35 : 0.18,
    };
  }
  return {
    color: hovered ? '#2a2a2a' : '#0a0a0a',
    roughness: 0.28,
    metalness: 0.72,
    emissive: '#000000',
    emissiveIntensity: 0,
  };
}

function CanvasCover({
  width,
  height,
  frameDepth,
  faceZ,
  matBorder,
  hovered,
}: {
  width: number;
  height: number;
  frameDepth: number;
  faceZ: number;
  matBorder: number;
  hovered: boolean;
}) {
  const weave = useMemo(() => {
    const tex = getCanvasWeaveTexture().clone();
    tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5));
    return tex;
  }, [width, height]);

  useEffect(() => () => weave.dispose(), [weave]);

  const cloth = hovered ? '#ddd3bc' : '#c4b494';
  const z = frameDepth + faceZ;

  return (
    <group renderOrder={20}>
      <mesh position={[0, 0, z]}>
        <planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
        <meshBasicMaterial
          map={weave}
          color={cloth}
          toneMapped={false}
          depthWrite
          polygonOffset
          polygonOffsetFactor={-6}
          polygonOffsetUnits={-6}
        />
      </mesh>
      <mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
        <planeGeometry args={[width * 0.48, height * 0.98]} />
        <meshBasicMaterial
          map={weave}
          color={hovered ? '#a89478' : '#958470'}
          toneMapped={false}
          transparent
          opacity={0.55}
          depthWrite={false}
          polygonOffset
          polygonOffsetFactor={-5}
          polygonOffsetUnits={-5}
        />
      </mesh>
    </group>
  );
}

function usePaintingTexture(url: string | null) {
  const [texture, setTexture] = useState<THREE.Texture | null>(null);
  const [failed, setFailed] = useState(!url);
  const textureLoad = useContext(GalleryTextureLoadContext);

  useEffect(() => {
    if (!url) {
      setTexture(null);
      setFailed(true);
      return;
    }

    setFailed(false);
    let disposed = false;
    let loaded: THREE.Texture | null = null;
    let settled = false;
    const loader = new THREE.TextureLoader();
    loader.setCrossOrigin('anonymous');

    const finish = () => {
      if (settled) return;
      settled = true;
      textureLoad?.end();
    };

    textureLoad?.begin();
    loader.load(
      url,
      (tex) => {
        finish();
        if (disposed) {
          tex.dispose();
          return;
        }
        const img = tex.image as HTMLImageElement | undefined;
        if (!img || img.width < 4 || img.height < 4) {
          tex.dispose();
          setFailed(true);
          return;
        }
        loaded = tex;
        tex.colorSpace = THREE.SRGBColorSpace;
        tex.anisotropy = 4;
        setTexture(tex);
      },
      undefined,
      () => {
        finish();
        if (!disposed) setFailed(true);
      }
    );

    return () => {
      disposed = true;
      finish();
      loaded?.dispose();
      setTexture(null);
    };
  }, [url, textureLoad]);

  return { texture, failed };
}

function InfluencePictureLamp({
  frameHeight,
  matBorder,
  frameDepth,
  highlighted,
}: {
  frameHeight: number;
  matBorder: number;
  frameDepth: number;
  highlighted: boolean;
}) {
  const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME;
  const glow = highlighted ? 1.6 : 1.15;
  const scale = 1.35;

  return (
    <group position={[0, mountY, frameDepth * 0.55]} scale={scale}>
      <mesh position={[0, 0.04, -0.05]} castShadow renderOrder={30}>
        <boxGeometry args={[0.11, 0.05, 0.05]} />
        <meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
      </mesh>
      <mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} castShadow renderOrder={30}>
        <boxGeometry args={[0.04, 0.08, 0.035]} />
        <meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
      </mesh>
      <group position={[0, -0.02, 0.03]} rotation={[-0.55, 0, 0]}>
        <mesh position={[0, 0, 0.04]} rotation={[Math.PI, 0, 0]} renderOrder={31}>
          <coneGeometry args={[0.09, 0.1, 16, 1, true]} />
          <meshStandardMaterial
            color={highlighted ? '#f0d050' : '#d4af37'}
            emissive={highlighted ? '#c89400' : '#8a6500'}
            emissiveIntensity={0.75 * glow}
            metalness={0.45}
            roughness={0.35}
            side={THREE.DoubleSide}
          />
        </mesh>
        <mesh position={[0, -0.04, 0.04]} renderOrder={32}>
          <sphereGeometry args={[0.022, 10, 10]} />
          <meshStandardMaterial
            color="#fff8e0"
            emissive="#ffcc55"
            emissiveIntensity={1.4 * glow}
            toneMapped={false}
          />
        </mesh>
        <pointLight
          position={[0, -0.03, 0.05]}
          intensity={1.8 * glow}
          distance={4}
          color="#ffe4a8"
          decay={2}
        />
        <spotLight
          position={[0, -0.03, 0.05]}
          angle={0.75}
          penumbra={0.95}
          intensity={2.2 * glow}
          distance={5}
          color="#fff0c8"
          castShadow={false}
        />
      </group>
    </group>
  );
}

function CuratorNotesPlate({
  frameWidth,
  frameHeight,
  matBorder,
  rail,
  frameDepth,
  faceZ,
  highlighted,
}: {
  frameWidth: number;
  frameHeight: number;
  matBorder: number;
  rail: number;
  frameDepth: number;
  faceZ: number;
  highlighted: boolean;
}) {
  const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42));
  const plateH = 0.038;
  const plateD = 0.012;
  const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028;
  const z = frameDepth + faceZ + 0.01;
  const brass = highlighted ? '#e8c76a' : '#d4af37';
  const rim = highlighted ? '#a07828' : '#8a6820';

  return (
    <group position={[0, y, z]}>
      {/* Slightly darker rim so the plate reads as a cast metal plaque */}
      <mesh position={[0, 0, -0.001]} castShadow renderOrder={28}>
        <boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
        <meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
      </mesh>
      <mesh castShadow renderOrder={29}>
        <boxGeometry args={[plateW, plateH, plateD]} />
        <meshStandardMaterial
          color={brass}
          metalness={0.9}
          roughness={0.22}
          emissive={highlighted ? '#6a5010' : '#3a2a08'}
          emissiveIntensity={highlighted ? 0.35 : 0.12}
        />
      </mesh>
      {/* Soft engraved center band */}
      <mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
        <planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
        <meshStandardMaterial
          color={highlighted ? '#b8943a' : '#9a7828'}
          metalness={0.7}
          roughness={0.4}
        />
      </mesh>
    </group>
  );
}

function PaintingFrame({
  painting,
  position,
  rotationY,
  maxWidth,
  maxHeight,
  wallSide,
  imageRevision,
  caption,
  onClick,
}: {
  painting: Painting;
  position: [number, number, number];
  rotationY: number;
  maxWidth: number;
  maxHeight: number;
  wallSide: WallSide;
  imageRevision?: number;
  caption?: string;
  onClick: () => void;
}) {
  const [hovered, setHovered] = useState(false);
  const [aspect, setAspect] = useState(1.33);
  const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
  const reviewed = paintingIsReviewed(painting);
  const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
  const hasImage = paintingHasGalleryImage(painting);
  const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
  const { texture, failed } = usePaintingTexture(url);
  const showImage = hasImage && !failed && !!texture;
  const showCanvas = !showImage;
  const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
  const hasCuratorNotes = paintingHasCuratorNotes(painting);
  const finish = frameFinish(reviewed, hovered);
  const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);

  useEffect(() => {
    if (!showImage) return;
    const img = texture?.image as HTMLImageElement | undefined;
    if (img?.width && img.height) {
      setAspect(img.width / img.height);
    }
  }, [texture, showImage]);

  const captionY =
    -height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);

  return (
    <group position={position} rotation={[0, rotationY, 0]}>
      <spotLight
        position={[0, height / 2 + 0.25, 0.3]}
        angle={0.5}
        penumbra={0.75}
        intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
        distance={4.5}
        color={showCanvas ? '#e8dcc8' : '#fff8ee'}
      />

      <mesh
        position={[0, 0, frameDepth / 2]}
        castShadow
        renderOrder={1}
        onClick={(e) => {
          e.stopPropagation();
          onClick();
        }}
        onPointerOver={() => setHovered(true)}
        onPointerOut={() => setHovered(false)}
      >
        <boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
        <meshStandardMaterial
          color={finish.color}
          roughness={finish.roughness}
          metalness={finish.metalness}
          emissive={finish.emissive}
          emissiveIntensity={finish.emissiveIntensity}
        />
      </mesh>

      {!showCanvas && (
        <mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
          <boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
          <meshStandardMaterial
            color={reviewed ? '#fff6cc' : '#f5f0e6'}
            roughness={reviewed ? 0.75 : 0.95}
            metalness={reviewed ? 0.15 : 0}
          />
        </mesh>
      )}

      {showCanvas ? (
        <CanvasCover
          width={width}
          height={height}
          frameDepth={frameDepth}
          faceZ={faceZ}
          matBorder={matBorder}
          hovered={hovered}
        />
      ) : (
        <mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
          <planeGeometry args={[width, height]} />
          <meshBasicMaterial
            map={texture}
            color="#ffffff"
            toneMapped={false}
            polygonOffset
            polygonOffsetFactor={-4}
            polygonOffsetUnits={-4}
          />
        </mesh>
      )}

      {hasInfluenceLinks && (
        <InfluencePictureLamp
          frameHeight={height}
          matBorder={matBorder}
          frameDepth={frameDepth}
          highlighted={hovered}
        />
      )}

      {hasCuratorNotes && (
        <CuratorNotesPlate
          frameWidth={width}
          frameHeight={height}
          matBorder={matBorder}
          rail={rail}
          frameDepth={frameDepth}
          faceZ={faceZ}
          highlighted={hovered}
        />
      )}

      {caption && (
        <Text
          position={[0, captionY, frameDepth + faceZ + 0.02]}
          fontSize={0.085}
          maxWidth={Math.max(width + matBorder * 2, 0.55)}
          color="#4a3828"
          anchorX="center"
          anchorY="top"
          textAlign="center"
        >
          {caption}
        </Text>
      )}
    </group>
  );
}

function GalleryWall({
  position,
  size,
  rotation = [0, 0, 0],
  color = '#ebe4d8',
}: {
  position: [number, number, number];
  size: [number, number];
  rotation?: [number, number, number];
  color?: string;
}) {
  const [w, h] = size;
  return (
    <mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
      <boxGeometry args={[w, h, WALL_THICKNESS]} />
      <meshStandardMaterial color={color} roughness={0.92} />
    </mesh>
  );
}

function ExitPortal({
  position,
  active,
  onActivate,
  wallColor = '#f0ebe3',
  trimColor = '#ddd5c8',
  doorWood,
}: {
  position: [number, number, number];
  active: boolean;
  onActivate: () => void;
  wallColor?: string;
  trimColor?: string;
  doorWood?: [string, string, string];
}) {
  const [hovered, setHovered] = useState(false);
  const highlight = active || hovered;

  const openingW = DOOR_WIDTH;
  const openingH = DOOR_HEIGHT;
  const jamb = EXIT_JAMB;
  const header = EXIT_HEADER;
  const transomH = EXIT_TRANSOM;
  const surround = EXIT_SURROUND;
  const frameW = openingW + jamb * 2;
  const frameFullH = openingH + header + transomH;
  const leafW = (openingW - 0.025) / 2;
  const leafH = openingH - 0.08;
  const leafY = 0.04 + leafH / 2;
  const faceZ = -0.04;

  const woodDark = doorWood?.[0] ?? '#261a10';
  const woodMid = doorWood?.[1] ?? '#3d2818';
  const woodGrain = doorWood?.[2] ?? '#4e3624';
  const stone = wallColor;
  const stoneDark = trimColor;
  const brass = highlight ? '#d4af37' : '#a08050';
  const brassEmissive = highlight ? '#5a4010' : '#000000';
  const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0';

  const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
    e.stopPropagation();
    onActivate();
  };

  const setHover = (on: boolean) => () => setHovered(on);

  const doorLeaf = (side: 'left' | 'right') => {
    const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006;
    const panelInset = 0.06;
    return (
      <group key={side} position={[x, leafY, faceZ]}>
        <mesh castShadow>
          <boxGeometry args={[leafW, leafH, 0.055]} />
          <meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
        </mesh>
        {/* Raised panel */}
        <mesh position={[0, 0.08, 0.03]}>
          <boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
          <meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
        </mesh>
        <mesh position={[0, -leafH * 0.22, 0.03]}>
          <boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
          <meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
        </mesh>
        {/* Stile edges */}
        <mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
          <boxGeometry args={[0.05, leafH, 0.008]} />
          <meshStandardMaterial color={woodDark} roughness={0.7} />
        </mesh>
        {/* Brass handle */}
        <mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
          <boxGeometry args={[0.04, 0.22, 0.025]} />
          <meshStandardMaterial
            color={brass}
            roughness={0.22}
            metalness={0.88}
            emissive={brassEmissive}
            emissiveIntensity={highlight ? 0.15 : 0}
          />
        </mesh>
      </group>
    );
  };

  return (
    <group position={position}>
      {/* Wall reveal — depth into opening */}
      <mesh position={[0, frameFullH / 2, 0.02]}>
        <boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
        <meshStandardMaterial color="#1a1410" roughness={0.95} />
      </mesh>

      {/* Warm vestibule glow beyond doors */}
      <pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
      <mesh position={[0, openingH * 0.55, 0.1]}>
        <planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
        <meshStandardMaterial
          color="#fff4e8"
          emissive="#ffdcb0"
          emissiveIntensity={highlight ? 0.35 : 0.18}
          roughness={0.9}
        />
      </mesh>

      {/* Marble threshold */}
      <mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow>
        <boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
        <meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
      </mesh>
      <mesh position={[0, 0.052, faceZ + 0.05]}>
        <boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
        <meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
      </mesh>

      {/* Door leaves */}
      {doorLeaf('left')}
      {doorLeaf('right')}

      {/* Center meeting stile / push bar */}
      <mesh position={[0, leafY, faceZ + 0.042]}>
        <boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
        <meshStandardMaterial
          color={brass}
          roughness={0.2}
          metalness={0.9}
          emissive={brassEmissive}
          emissiveIntensity={highlight ? 0.2 : 0}
        />
      </mesh>

      {/* Side jambs */}
      {([-1, 1] as const).map((sign) => (
        <mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow>
          <boxGeometry args={[jamb, frameFullH, 0.1]} />
          <meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
        </mesh>
      ))}

      {/* Header lintel */}
      <mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow>
        <boxGeometry args={[frameW, header, 0.1]} />
        <meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
      </mesh>

      {/* Transom — frosted museum glass */}
      <mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
        <boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
        <meshStandardMaterial
          color="#f5f0e8"
          emissive={highlight ? '#fff0d8' : '#ffeacc'}
          emissiveIntensity={highlight ? 0.25 : 0.12}
          roughness={0.15}
          metalness={0.05}
          transparent
          opacity={0.88}
        />
      </mesh>
      {/* Transom mullions */}
      {[-0.35, 0, 0.35].map((ox) => (
        <mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
          <boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
          <meshStandardMaterial color={woodDark} roughness={0.6} />
        </mesh>
      ))}

      {/* Limestone surround */}
      {([-1, 1] as const).map((sign) => (
        <mesh
          key={`sur-${sign}`}
          position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
          castShadow
        >
          <boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
          <meshStandardMaterial color={stone} roughness={0.88} />
        </mesh>
      ))}
      <mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
        <boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
        <meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
      </mesh>

      {/* Crown on surround */}
      <mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
        <boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
        <meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
      </mesh>

      {/* Brass EXIT plaque */}
      <group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
        <mesh
          onClick={handleActivate}
          onPointerOver={setHover(true)}
          onPointerOut={setHover(false)}
        >
          <boxGeometry args={[0.55, 0.14, 0.018]} />
          <meshStandardMaterial
            color={brass}
            roughness={0.25}
            metalness={0.85}
            emissive={brassEmissive}
            emissiveIntensity={highlight ? 0.25 : 0.05}
          />
        </mesh>
        <Text
          position={[0, 0, -0.012]}
          rotation={[0, Math.PI, 0]}
          fontSize={0.09}
          color={highlight ? '#fff8e8' : '#3d2818'}
          anchorX="center"
          anchorY="middle"
          onClick={handleActivate}
          onPointerOver={setHover(true)}
          onPointerOut={setHover(false)}
        >
          EXIT
        </Text>
      </group>

      {/* Wall sconces */}
      {([-1, 1] as const).map((sign) => (
        <group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
          <mesh>
            <boxGeometry args={[0.08, 0.22, 0.1]} />
            <meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
          </mesh>
          <mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
            <coneGeometry args={[0.07, 0.12, 8, 1, true]} />
            <meshStandardMaterial
              color="#fff4e0"
              emissive="#ffdcb0"
              emissiveIntensity={highlight ? 0.5 : 0.28}
              roughness={0.4}
              side={THREE.DoubleSide}
            />
          </mesh>
          <pointLight
            position={[0, -0.12, -0.06]}
            intensity={highlight ? 0.45 : 0.22}
            distance={2.2}
            color="#fff0d8"
          />
        </group>
      ))}

      {/* Hinges */}
      {([-1, 1] as const).flatMap((side) =>
        [0.35, 0.85, 1.35].map((hy) => (
          <mesh
            key={`hinge-${side}-${hy}`}
            position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
            rotation={[0, 0, side === -1 ? 0 : Math.PI]}
          >
            <boxGeometry args={[0.025, 0.1, 0.04]} />
            <meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
          </mesh>
        ))
      )}

      {/* Large invisible click target */}
      <mesh
        position={[0, frameFullH / 2, faceZ - 0.15]}
        rotation={[0, Math.PI, 0]}
        onClick={handleActivate}
        onPointerOver={setHover(true)}
        onPointerOut={setHover(false)}
      >
        <planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
        <meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
      </mesh>
    </group>
  );
}

function TexturedWall({
  kind,
  tint,
  position,
  size,
  rotation = [0, 0, 0],
}: {
  kind: MovementInteriorStyle['surfaces']['wall'];
  tint: string;
  position: [number, number, number];
  size: [number, number, number];
  rotation?: [number, number, number];
}) {
  const [w, h, d] = size;
  const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
  return (
    <mesh position={position} rotation={rotation} receiveShadow material={mat} renderOrder={0}>
      <boxGeometry args={size} />
    </mesh>
  );
}

function TexturedCeiling({
  kind,
  tint,
  width,
  depth,
}: {
  kind: MovementInteriorStyle['surfaces']['ceiling'];
  tint: string;
  width: number;
  depth: number;
}) {
  const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
  return (
    <mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} receiveShadow material={mat}>
      <planeGeometry args={[width + 0.4, depth + 0.4]} />
    </mesh>
  );
}

function GalleryFloor({
  width,
  depth,
  interiorStyle,
}: {
  width: number;
  depth: number;
  interiorStyle: MovementInteriorStyle;
}) {
  const floorW = width + 0.4;
  const floorD = depth + 0.4;
  const texture = useMemo(() => {
    const surf = interiorStyle.surfaces.floor;
    const meters = getSurfaceTexture(surf).metersPerRepeat;
    return cloneSurfaceTexture(surf, floorW / meters, floorD / meters);
  }, [interiorStyle.surfaces.floor, floorW, floorD]);

  useEffect(
    () => () => {
      texture.map.dispose();
      texture.normalMap.dispose();
    },
    [texture]
  );

  return (
    <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
      <planeGeometry args={[floorW, floorD]} />
      <meshStandardMaterial
        map={texture.map}
        normalMap={texture.normalMap}
        normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
        roughness={texture.roughness}
        metalness={texture.metalness}
        envMapIntensity={0.65}
        color={interiorStyle.tints.floor}
      />
    </mesh>
  );
}

function ParquetFloor({ width, depth }: { width: number; depth: number }) {
  const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
  return (
    <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow material={mat}>
      <planeGeometry args={[width + 0.4, depth + 0.4]} />
    </mesh>
  );
}

function ArtistHall({
  layout,
  hallTitle,
  hallSubtitle,
  movementColor,
  interiorStyle,
  imageRevisions,
  showCaptions = false,
  onPaintingClick,
  onExitActivate,
  nearExit,
  movementMode,
  computedWindows,
  hasNextHall,
  onNextHall,
  nearPassage,
}: {
  layout: HallLayout | MovementHallLayout;
  hallTitle: string;
  hallSubtitle?: string;
  movementColor?: string;
  interiorStyle?: MovementInteriorStyle;
  imageRevisions?: Record<number, number>;
  showCaptions?: boolean;
  onPaintingClick: (id: number) => void;
  onExitActivate: () => void;
  nearExit: boolean;
  movementMode?: boolean;
  computedWindows?: GalleryWindowSpec[];
  hasNextHall?: boolean;
  onNextHall?: () => void;
  nearPassage?: boolean;
}) {
  const { width, depth, segments } = layout;
  const halfW = width / 2;
  const halfD = depth / 2;
  const walls = useMemo(
    () =>
      interiorStyle
        ? { main: interiorStyle.tints.wall, side: interiorStyle.tints.wallSide ?? interiorStyle.tints.wall, trim: interiorStyle.tints.trim }
        : galleryWallColors(movementColor),
    [interiorStyle, movementColor]
  );
  const titleColor = interiorStyle?.titleColor ?? '#4a3020';
  const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
  const wallRoughness = interiorStyle ? 0.75 : 0.92;
  const wallMetalness = interiorStyle ? 0.08 : 0.06;

  const wallMaterial = (color: string) => (
    <meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
  );

  return (
    <group>
      {interiorStyle ? (
        <>
          <GalleryFloor width={width} depth={depth} interiorStyle={interiorStyle} />
          <TexturedCeiling
            kind={interiorStyle.surfaces.ceiling}
            tint={interiorStyle.tints.ceiling}
            width={width}
            depth={depth}
          />
        </>
      ) : (
        <>
          <ParquetFloor width={width} depth={depth} />
          <mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
            <planeGeometry args={[width + 0.4, depth + 0.4]} />
            <meshStandardMaterial color="#2a2018" roughness={0.9} />
          </mesh>
        </>
      )}

      {interiorStyle ? (
        <>
          {!movementMode && (
            <TexturedWall
              kind={interiorStyle.surfaces.wall}
              tint={interiorStyle.tints.wall}
              position={[0, WALL_HEIGHT / 2, -halfD]}
              size={[width, WALL_HEIGHT, WALL_THICKNESS]}
            />
          )}
          <TexturedWall
            kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
            tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
            position={[-halfW, WALL_HEIGHT / 2, 0]}
            size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
            rotation={[0, Math.PI / 2, 0]}
          />
          <TexturedWall
            kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
            tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
            position={[halfW, WALL_HEIGHT / 2, 0]}
            size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
            rotation={[0, Math.PI / 2, 0]}
          />
        </>
      ) : (
        <>
          <mesh position={[0, WALL_HEIGHT / 2, -halfD]} receiveShadow renderOrder={0}>
            <boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
            {wallMaterial(walls.main)}
          </mesh>
          <mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}>
            <boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
            {wallMaterial(walls.side)}
          </mesh>
          <mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}>
            <boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
            {wallMaterial(walls.side)}
          </mesh>
        </>
      )}

      {/* Front wall — passage to next wing, or solid (artist exit / movement entrance) */}
      {movementMode && hasNextHall ? (
        <>
          <GalleryWall
            position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
            size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
            color={walls.main}
          />
          <GalleryWall
            position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
            size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
            color={walls.main}
          />
          <GalleryWall
            position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
            size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
            color={walls.main}
          />
          <HallPassage
            position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
            active={!!nearPassage}
            label="Next wing →"
            onActivate={() => onNextHall?.()}
            wallColor={walls.main}
            trimColor={walls.trim}
          />
        </>
      ) : movementMode ? (
        <>
          <GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
        </>
      ) : (
        <>
          <GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
          <GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
          <GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main} />
          <ExitPortal
            position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
            active={nearExit}
            onActivate={onExitActivate}
            wallColor={walls.main}
            trimColor={walls.trim}
            doorWood={interiorStyle?.doorWood}
          />
        </>
      )}

      {/* Back wall — movement exit / navigation, or solid with title */}
      {movementMode ? (
        <>
          <GalleryWall
            position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
            size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
            color={walls.main}
          />
          <GalleryWall
            position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
            size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
            color={walls.main}
          />
          <GalleryWall
            position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
            size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
            color={walls.main}
          />
          <group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
            <ExitPortal
              position={[0, 0, 0]}
              active={nearExit}
              onActivate={onExitActivate}
              wallColor={walls.main}
              trimColor={walls.trim}
              doorWood={interiorStyle?.doorWood}
            />
          </group>
        </>
      ) : null}

      {interiorStyle && computedWindows && computedWindows.length > 0 && (
        <>
          <GalleryWindows
            windows={computedWindows}
            halfW={halfW}
            halfD={halfD}
            trimColor={interiorStyle.tints.trim}
          />
          <GalleryTrackLights
            width={width}
            depth={depth}
            intensity={interiorStyle.trackLights}
            color={interiorStyle.warmLight}
          />
        </>
      )}

      {interiorStyle?.tints.wallSide && interiorStyle.surfaces.wall !== interiorStyle.surfaces.wallSide && (
        <>
          <mesh position={[0, 1.05, -halfD + WALL_THICKNESS / 2]}>
            <boxGeometry args={[width, 2.1, 0.04]} />
            <meshStandardMaterial color={interiorStyle.tints.trim} roughness={0.45} metalness={0.35} />
          </mesh>
        </>
      )}

      {/* Crown molding on back wall */}
      <mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
        <boxGeometry args={[width, 0.14, 0.1]} />
        <meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
      </mesh>

      {interiorStyle && (
        <MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
      )}

      <Text
        position={[0, WALL_HEIGHT - 0.45, -halfD + WALL_THICKNESS + 0.02]}
        fontSize={0.28}
        color={titleColor}
        anchorX="center"
      >
        {hallTitle.toUpperCase()}
      </Text>
      {hallSubtitle && (
        <Text
          position={[0, WALL_HEIGHT - 0.72, -halfD + WALL_THICKNESS + 0.02]}
          fontSize={0.12}
          color={titleColor}
          anchorX="center"
        >
          {hallSubtitle}
        </Text>
      )}

      {segments.map((seg) => (
        <group key={`${seg.side}-${seg.label}`}>
          {seg.paintings.map((painting, i) => (
            <PaintingFrame
              key={`${painting.id}-${imageRevisions?.[painting.id] ?? 0}`}
              painting={painting}
              position={seg.slots[i].position}
              rotationY={seg.slots[i].rotationY}
              maxWidth={seg.slots[i].maxW}
              maxHeight={seg.slots[i].maxH}
              wallSide={seg.slots[i].side}
              imageRevision={imageRevisions?.[painting.id]}
              caption={showCaptions ? paintingWallCaption(painting) : undefined}
              onClick={() => onPaintingClick(painting.id)}
            />
          ))}
          {seg.label && (
            <Text
              position={[
                seg.side === 'back' ? 0 : seg.side === 'left' ? -halfW + 0.15 : halfW - 0.15,
                WALL_HEIGHT - 0.85,
                seg.side === 'back' ? -halfD + 0.15 : 0,
              ]}
              rotation={[0, seg.side === 'left' ? Math.PI / 2 : seg.side === 'right' ? -Math.PI / 2 : 0, 0]}
              fontSize={0.14}
              color="#6b5344"
              anchorX="center"
            >
              {seg.label}
            </Text>
          )}
        </group>
      ))}

      <pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
      <pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={interiorStyle ? interiorStyle.ambient * 0.85 : 0.35} color={warmLight} distance={Math.max(12, depth * 0.75)} />
      {depth > 14 && (
        <pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
      )}
      {interiorStyle && (
        <directionalLight
          position={[0, 6, -halfD - 2]}
          intensity={interiorStyle.ambient * 0.9}
          color={interiorStyle.sunLight}
        />
      )}
    </group>
  );
}

function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
  pos.y = EYE_HEIGHT;
  target.y = EYE_HEIGHT;
}

function FrameloopSync({ active }: { active: boolean }) {
  const { invalidate } = useThree();
  useEffect(() => {
    if (active) invalidate();
  }, [active, invalidate]);
  return null;
}

function CameraController({
  position,
  target,
}: {
  position: THREE.Vector3;
  target: THREE.Vector3;
}) {
  const { camera } = useThree();
  useFrame(() => {
    levelHorizontalView(position, target);
    camera.position.lerp(position, 0.12);
    camera.lookAt(target.x, EYE_HEIGHT, target.z);
  });
  return null;
}

function MovementHallNavPanel({
  movementName,
  halls,
  currentIndex,
  onSelectHall,
  onExitTimeline,
  onClose,
}: {
  movementName: string;
  halls: MovementHallLayout[];
  currentIndex: number;
  onSelectHall: (index: number) => void;
  onExitTimeline: () => void;
  onClose: () => void;
}) {
  return (
    <div className="exit-nav-overlay" role="dialog" aria-modal="true">
      <div className="exit-nav-panel">
        <header className="exit-nav-header">
          <h2>{movementName} — gallery wings</h2>
          <button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
            ×
          </button>
        </header>
        <div className="exit-nav-columns" style={{ gridTemplateColumns: '1fr' }}>
          <div className="exit-nav-column">
            <h3>Choose a wing</h3>
            <ul className="movement-hall-nav-list">
              {halls.map((hall, i) => (
                <li key={i}>
                  <button
                    type="button"
                    className={i === currentIndex ? 'movement-hall-nav-active' : undefined}
                    onClick={() => onSelectHall(i)}
                  >
                    <span>
                      Wing {i + 1} of {hall.hallCount}
                      <small>
                        {hall.yearLabel} · {hall.paintingCount} works
                        {i === currentIndex ? ' · you are here' : ''}
                      </small>
                    </span>
                  </button>
                </li>
              ))}
            </ul>
            <button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
              Exit to Timeline
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function NavigationPanel({
  navigation,
  loading,
  onSelect,
  onClose,
}: {
  navigation: ArtistNavigation | null;
  loading: boolean;
  onSelect: (artistId: number) => void;
  onClose: () => void;
}) {
  const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
    <div className="exit-nav-column">
      <h3>{title}</h3>
      {loading && <p className="exit-nav-loading">Loading…</p>}
      {!loading && groups.length === 0 && <p className="exit-nav-empty">{emptyHint}</p>}
      {!loading &&
        groups.map((group) => (
          <div key={group.movement_id ?? group.movement_name} className="exit-nav-movement">
            <h4 style={{ borderColor: group.movement_color }}>{group.movement_name}</h4>
            <ul>
              {group.artists.map((artist) => (
                <li key={artist.id}>
                  <button type="button" onClick={() => onSelect(artist.id)}>
                    <img src={imageUrl(artist.portrait_path)} alt="" />
                    <span>
                      {artist.name}
                      {artist.birth_year && (
                        <small>
                          {artist.birth_year}
                          {artist.death_year ? `–${artist.death_year}` : ''}
                        </small>
                      )}
                    </span>
                  </button>
                </li>
              ))}
            </ul>
          </div>
        ))}
    </div>
  );

  return (
    <div className="exit-nav-overlay" role="dialog" aria-modal="true">
      <div className="exit-nav-panel">
        <header className="exit-nav-header">
          <h2>Choose your path</h2>
          <button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
            ×
          </button>
        </header>
        <div className="exit-nav-columns">
          {renderColumn(
            'Predecessors',
            navigation?.predecessors ?? [],
            'No documented predecessors via painting influences.'
          )}
          {renderColumn(
            'Successors',
            navigation?.successors ?? [],
            'No documented successors via painting influences.'
          )}
        </div>
      </div>
    </div>
  );
}

export default function VirtualGallery(props: Props) {
  const {
    imageRevisions,
    active = true,
    onPaintingClick,
    onBack,
  } = props;

  const isMovement = props.mode === 'movement';
  const isTour = props.mode === 'tour';
  const isWingedHall = isMovement || isTour;
  const hallKey = isTour
    ? props.data.tour.id
    : isMovement
      ? props.data.movement.id
      : props.data.artist.id;
  const hallTitle = isTour
    ? props.data.tour.title
    : isMovement
      ? props.data.movement.name
      : props.data.artist.name;
  const movementColor = isTour
    ? DEFAULT_MOVEMENT_COLOR
    : isMovement
      ? props.data.movement.color
      : props.data.artist.movement_color;
  const initialPaintings = useMemo(() => {
    if (props.mode === 'movement') {
      return [...props.data.paintings].sort(comparePaintingsChronological);
    }
    return props.data.paintings;
  }, [
    props.mode,
    props.mode === 'tour'
      ? props.data.tour.id
      : props.mode === 'movement'
        ? props.data.movement.id
        : props.data.artist.id,
    props.data.paintings,
  ]);
  const initialPeriods = props.mode === 'artist' ? props.data.periods : [];

  const [paintings, setPaintings] = useState(initialPaintings);
  const [periods, setPeriods] = useState(initialPeriods);
  const [syncStatus, setSyncStatus] = useState('');
  const [showExitNav, setShowExitNav] = useState(false);
  const [navigation, setNavigation] = useState<ArtistNavigation | null>(null);
  const [navLoading, setNavLoading] = useState(false);
  const [nearExit, setNearExit] = useState(false);
  const [nearPassage, setNearPassage] = useState(false);
  const [isLooking, setIsLooking] = useState(false);
  const [texturesPending, setTexturesPending] = useState(0);
  const [canvasReady, setCanvasReady] = useState(false);
  const [glEpoch, setGlEpoch] = useState(0);
  const [glLost, setGlLost] = useState(false);

  const textureLoad = useMemo(
    () => ({
      begin: () => setTexturesPending((n) => n + 1),
      end: () => setTexturesPending((n) => Math.max(0, n - 1)),
    }),
    []
  );

  const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
    const canvas = state.gl.domElement;
    // A freshly created canvas has a healthy context, so clear any lingering
    // "restoring" state from a previous loss/remount.
    setGlLost(false);
    setCanvasReady(true);
    const onLost = (event: Event) => {
      // Prevent the default so the browser can restore the context, and
      // force a clean remount to obtain a fresh WebGL context if it does not.
      event.preventDefault();
      setGlLost(true);
      window.setTimeout(() => {
        setGlLost((stillLost) => {
          if (stillLost) setGlEpoch((n) => n + 1);
          return stillLost;
        });
      }, 600);
    };
    const onRestored = () => {
      setGlLost(false);
      setGlEpoch((n) => n + 1);
    };
    canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
    canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
  }, []);

  useEffect(() => {
    setPaintings(initialPaintings);
    setPeriods(initialPeriods);
  }, [initialPaintings, initialPeriods, hallKey]);

  useEffect(() => {
    if (props.mode === 'movement') {
      const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
      setSyncStatus(
        withImg < initialPaintings.length
          ? `${withImg} of ${initialPaintings.length} works have images`
          : ''
      );
      return;
    }

    const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
    setSyncStatus(
      withImg < initialPaintings.length
        ? `${withImg} of ${initialPaintings.length} works have images`
        : ''
    );
  }, [hallKey, props.mode, initialPaintings]);

  const interiorStyle = useMemo(
    () => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
    [isMovement, isMovement ? props.data.movement : null]
  );

  const movementHalls = useMemo(
    () => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
    [isWingedHall, paintings]
  );

  const [hallIndex, setHallIndex] = useState(0);

  useEffect(() => {
    setHallIndex(0);
  }, [hallKey]);

  const layout = useMemo(() => {
    if (isWingedHall && movementHalls.length > 0) {
      return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
    }
    return buildHallLayout(paintings, periods);
  }, [isWingedHall, movementHalls, hallIndex, paintings, periods]);

  const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);

  const galleryLoadingMessage = !canvasReady
    ? 'Loading gallery…'
    : texturesPending > 0
      ? 'Loading paintings…'
      : 'Loading gallery…';

  const computedWindows = useMemo(() => {
    if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
    return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
  }, [isMovement, interiorStyle, layout]);

  const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;

  const halfW = layout.width / 2 - 0.55;
  const halfD = layout.depth / 2 - 0.35;
  const exitZ = layout.depth / 2 - 0.55;
  const fogFar = Math.max(55, layout.depth + 42);

  const initialPos = useMemo(
    () => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
    [layout.depth]
  );
  const initialTarget = useMemo(
    () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)),
    [layout.depth]
  );

  const [camPos, setCamPos] = useState(() => initialPos.clone());
  const [camTarget, setCamTarget] = useState(() => initialTarget.clone());
  const keysPressed = useRef<Set<string>>(new Set());
  const dragTurnActive = useRef(false);
  const dragStartRef = useRef<{ x: number; y: number } | null>(null);
  const camPosRef = useRef(camPos);
  const camTargetRef = useRef(camTarget);
  camPosRef.current = camPos;
  camTargetRef.current = camTarget;

  const goToHall = useCallback(
    (index: number, enterFrom: 'front' | 'back' | 'default' = 'default') => {
      const clamped = Math.max(0, Math.min(index, movementHalls.length - 1));
      setHallIndex(clamped);
      setShowExitNav(false);
      setNearExit(false);
      setNearPassage(false);
      const nextLayout = movementHalls[clamped];
      if (!nextLayout) return;
      const nextHalfD = nextLayout.depth / 2;
      if (enterFrom === 'back') {
        setCamPos(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD + 2.2));
        setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD * 0.25));
      } else {
        setCamPos(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD - 2.2));
        setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD * 0.25));
      }
    },
    [movementHalls]
  );

  const goToNextHall = useCallback(() => {
    if (hallIndex < movementHalls.length - 1) goToHall(hallIndex + 1, 'back');
  }, [hallIndex, movementHalls.length, goToHall]);

  useEffect(() => {
    setCamPos(initialPos.clone());
    setCamTarget(initialTarget.clone());
    setShowExitNav(false);
    setNearExit(false);
    setNearPassage(false);
  }, [hallKey, initialPos, initialTarget]);

  const openExitNav = useCallback(async () => {
    if (isWingedHall) {
      setShowExitNav(true);
      return;
    }
    setShowExitNav(true);
    setNavLoading(true);
    try {
      const nav = await api.getArtistNavigation(props.data.artist.id);
      setNavigation(nav);
    } catch {
      setNavigation({ predecessors: [], successors: [] });
    } finally {
      setNavLoading(false);
    }
  }, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);

  const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
  const frontPassageZ = layout.depth / 2 - 0.55;

  const moveCamera = useCallback(
    (forward: number, strafe: number, rotY: number) => {
      const pos = camPosRef.current.clone();
      const target = camTargetRef.current.clone();
      const angle = Math.atan2(target.x - pos.x, target.z - pos.z);

      if (rotY !== 0) {
        const newAngle = angle + rotY;
        const dist = pos.distanceTo(target);
        target.x = pos.x + Math.sin(newAngle) * dist;
        target.z = pos.z + Math.cos(newAngle) * dist;
      } else {
        const newAngle = angle;
        pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
        pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
        target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
        target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
      }

      pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
      target.x = Math.max(-halfW, Math.min(halfW, target.x));

      if (isWingedHall) {
        pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
        target.z = Math.max(-halfD, Math.min(halfD, target.z));
        const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
        const atFrontPassage =
          hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
        setNearExit(atBackExit);
        setNearPassage(!!atFrontPassage);
      } else {
        pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
        target.z = Math.max(-halfD, Math.min(exitZ, target.z));
        const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
        setNearExit(atExit);
      }

      levelHorizontalView(pos, target);

      setCamPos(pos);
      setCamTarget(target);
    },
    [halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
  );

  useEffect(() => {
    if (active) return;
    keysPressed.current.clear();
    dragTurnActive.current = false;
    dragStartRef.current = null;
    setIsLooking(false);
  }, [active]);

  useEffect(() => {
    if (!active) return;

    const onKeyDown = (e: KeyboardEvent) => {
      keysPressed.current.add(e.key);
      if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
        if (isWingedHall && nearPassage && hasNextHall) {
          goToNextHall();
        } else {
          openExitNav();
        }
      }
    };
    const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key);
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);

    const interval = setInterval(() => {
      const keys = keysPressed.current;
      if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0);
      if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0);
      if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) {
        moveCamera(0, 0, TURN_SPEED);
      }
      if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) {
        moveCamera(0, 0, -TURN_SPEED);
      }
    }, 16);

    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      clearInterval(interval);
    };
  }, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);

  const handleNavigate = (artistId: number) => {
    if (isWingedHall) return;
    setShowExitNav(false);
    props.onNavigateArtist(artistId);
  };

  const subtitle = isTour
    ? `Guided tour · ${paintings.length} works${
        movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
      }`
    : isMovement
    ? interiorStyle
      ? `${interiorStyle.subtitle} · ${paintings.length} works${
          movementHalls.length > 1
            ? ` · Wing ${hallIndex + 1}/${movementHalls.length} (${(layout as MovementHallLayout).yearLabel})`
            : ''
        }`
      : `Movement gallery · ${paintings.length} works · chronological`
    : `Personal hall · ${paintings.length} works on the walls`;

  const sceneBackground = interiorStyle?.background ?? '#0d0906';
  const sceneFog = interiorStyle?.fog ?? '#0d0906';
  const ambientIntensity = interiorStyle?.ambient ?? 0.42;

  const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!active || showExitNav || e.button !== 0) return;
    dragStartRef.current = { x: e.clientX, y: e.clientY };
  };

  const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!active || showExitNav || !dragStartRef.current) return;

    const dx = e.clientX - dragStartRef.current.x;
    const dy = e.clientY - dragStartRef.current.y;
    const distSq = dx * dx + dy * dy;

    if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) {
      dragTurnActive.current = true;
      setIsLooking(true);
      e.currentTarget.setPointerCapture(e.pointerId);
    }

    if (dragTurnActive.current && e.movementX !== 0) {
      moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY);
    }
  };

  const endCanvasDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    dragStartRef.current = null;
    dragTurnActive.current = false;
    setIsLooking(false);
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
  };

  const exitHint = isWingedHall ? (
    <>
      Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
      {movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
    </>
  ) : (
    <>Click the exit door, <kbd>E</kbd>, or <strong>Exit →</strong> above</>
  );

  const hallSubtitle =
    isWingedHall && 'yearLabel' in layout
      ? `Wing ${hallIndex + 1} of ${movementHalls.length}${
          isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
        }`
      : undefined;

  const instructionsTitle = isTour
    ? `${hallTitle} · Guided tour`
    : isMovement
    ? interiorStyle
      ? `${hallTitle} · ${interiorStyle.label}`
      : `${hallTitle} Gallery`
    : `${hallTitle}'s Hall`;

  return (
    <div className="virtual-gallery">
      <div className="gallery-header">
        <button className="gallery-back-btn" onClick={onBack}>← Back to Timeline</button>
        <div className="gallery-title-block">
          <h2>{hallTitle}</h2>
          <p className="gallery-career-path">{subtitle}</p>
        </div>
        <div className="gallery-header-meta">
          <button type="button" className="gallery-exit-btn" onClick={openExitNav}>
            {isWingedHall ? 'Wings / Exit →' : 'Exit →'}
          </button>
          {!isWingedHall && (
            <button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
          )}
        </div>
      </div>

      <div
        className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
        onPointerDown={handleCanvasPointerDown}
        onPointerMove={handleCanvasPointerMove}
        onPointerUp={endCanvasDrag}
        onPointerLeave={endCanvasDrag}
        onPointerCancel={endCanvasDrag}
      >
        {active && (gallerySceneLoading || glLost) && (
          <GalleryLoadingMarker
            overlay
            message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
          />
        )}
        {syncStatus && !gallerySceneLoading && !glLost && (
          <div className="gallery-loading-overlay gallery-sync-badge">
            <p>{syncStatus}</p>
          </div>
        )}
        {!showExitNav && (
          <div className="gallery-exit-hint">{exitHint}</div>
        )}
        <Canvas
          key={`${hallKey}-${glEpoch}`}
          shadows
          frameloop={active ? 'always' : 'never'}
          gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
          camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
          onCreated={handleCanvasCreated}
        >
          <FrameloopSync active={active} />
          <color attach="background" args={[sceneBackground]} />
          <fog attach="fog" args={[sceneFog, 18, fogFar]} />
          <ambientLight intensity={ambientIntensity} />
          <directionalLight
            position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
            intensity={interiorStyle ? 0.85 : 0.65}
            color={interiorStyle?.sunLight ?? '#fff8f0'}
            castShadow
            shadow-mapSize={[1024, 1024]}
          />
          <SceneErrorBoundary fallback={null}>
            <Suspense fallback={null}>
              <Environment
                preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
                environmentIntensity={interiorStyle ? 0.35 : 0.15}
              />
            </Suspense>
          </SceneErrorBoundary>
          <GalleryTextureLoadContext.Provider value={textureLoad}>
            <ArtistHall
            layout={layout}
            hallTitle={hallTitle}
            hallSubtitle={hallSubtitle}
            movementColor={movementColor}
            interiorStyle={interiorStyle}
            imageRevisions={imageRevisions}
            showCaptions={isWingedHall}
            onPaintingClick={onPaintingClick}
            onExitActivate={openExitNav}
            nearExit={nearExit}
            movementMode={isWingedHall}
            computedWindows={computedWindows}
            hasNextHall={hasNextHall}
            onNextHall={goToNextHall}
            nearPassage={nearPassage}
          />
          </GalleryTextureLoadContext.Provider>
          <CameraController position={camPos} target={camTarget} />
        </Canvas>
      </div>

      {!isWingedHall && showExitNav && (
        <NavigationPanel
          navigation={navigation}
          loading={navLoading}
          onSelect={handleNavigate}
          onClose={() => setShowExitNav(false)}
        />
      )}

      {isWingedHall && showExitNav && (
        <MovementHallNavPanel
          movementName={hallTitle}
          halls={movementHalls}
          currentIndex={hallIndex}
          onSelectHall={(i) => goToHall(i)}
          onExitTimeline={onBack}
          onClose={() => setShowExitNav(false)}
        />
      )}

      <div className="gallery-controls">
        <div className="control-pad">
          <button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward">▲</button>
          <div className="control-row">
            <button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left">↺</button>
            <button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back">▼</button>
            <button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right">↻</button>
          </div>
        </div>
        <div className="gallery-instructions">
          <h4>{instructionsTitle}</h4>
          <ul>
            <li><kbd>W</kbd> / <kbd>↑</kbd> Walk forward</li>
            <li><kbd>S</kbd> / <kbd>↓</kbd> Walk back</li>
            <li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
            <li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
            <li>Drag on the view to look around</li>
            <li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
            {isWingedHall ? (
              <>
                <li>Date and artist labels appear below each frame</li>
                <li>Works hang on left &amp; right walls — up to ~55 per wing</li>
                <li>Back door: wing navigator &amp; exit to timeline</li>
                {movementHalls.length > 1 && (
                  <li>Front archway: walk to the next chronological wing</li>
                )}
              </>
            ) : (
              <>
                <li>Golden lamps mark works linked in the influence graph</li>
                <li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
              </>
            )}
          </ul>
        </div>
      </div>
    </div>
  );
}
