/**
 * The reading workstation.
 *
 * Slice navigation, window/level with per-modality presets, zoom, pan, invert,
 * cine, a Hounsfield probe, and the expert-segmentation overlay.
 *
 * Two canvases are stacked: cornerstone owns the lower one and draws the pixel
 * data; this component owns the upper one and draws the segmentation. They stay
 * aligned because the overlay is positioned with `cornerstone.pixelToCanvas`,
 * which already accounts for the current scale and translation, and is redrawn on
 * cornerstone's own `cornerstoneimagerendered` event rather than on a guess about
 * when the viewport changed.
 */

// Each text/babel script shares one top-level scope, so every module here is
// wrapped in an IIFE — otherwise `const { useState } = React` in two files is a
// redeclaration and the second file silently fails to evaluate.
(function () {
'use strict';

const { useState, useEffect, useRef, useCallback, useMemo } = React;

const CINE_FPS = 12;
// Drag distance, in CSS pixels, that moves the stack by one slice.
const SCROLL_PX = 8;
// any-pointer, not pointer: a touch laptop or a tablet with a trackpad reports
// the mouse as its primary pointer but can still be swiped.
const IS_TOUCH = Boolean(window.matchMedia && window.matchMedia('(any-pointer: coarse)').matches);

/**
 * Window/level broadcast between linked viewports.
 *
 * Deliberately a plain pub/sub rather than lifted state: a viewer's window is
 * otherwise entirely its own business, and threading it up through the lesson
 * player just so two panes can agree would couple every scan step to a feature
 * only the comparison view uses.
 */
const windowSync = {
  channels: new Map(),

  subscribe(channel, fn) {
    if (!this.channels.has(channel)) this.channels.set(channel, new Set());
    this.channels.get(channel).add(fn);
    return () => {
      const set = this.channels.get(channel);
      if (set) set.delete(fn);
    };
  },

  publish(channel, sender, voi) {
    const set = this.channels.get(channel);
    if (!set) return;
    set.forEach((fn) => fn(sender, voi));
  }
};

const PLANE_CODE = {
  axial: 'AX',
  sagittal: 'SAG',
  coronal: 'COR',
  volume: '3D'
};

function ViewerHud({ tag, plane, index, total, viewport, sampled, isColor }) {
  const ww = viewport ? Math.round(viewport.voi.windowWidth) : 0;
  const wl = viewport ? Math.round(viewport.voi.windowCenter) : 0;
  const zoom = viewport ? viewport.scale.toFixed(2) : '1.00';

  return (
    <div className="viewer__hud" aria-hidden="true">
      {tag && (
        <div className="viewer__hud-row">
          <span className="viewer__hud-key">SER</span>
          <span>{tag}</span>
        </div>
      )}
      {plane && (
        <div className="viewer__hud-row">
          <span className="viewer__hud-key">PLANE</span>
          <span>{PLANE_CODE[plane] || plane.toUpperCase()}</span>
        </div>
      )}
      <div className="viewer__hud-row">
        <span className="viewer__hud-key">IMG</span>
        <span>
          {index + 1}/{total}
          {sampled ? '*' : ''}
        </span>
      </div>
      {/* A volume rendering has no window to report — its pixels are colours. */}
      {!isColor && (
        <div className="viewer__hud-row">
          <span className="viewer__hud-key">W/L</span>
          <span>
            {ww}/{wl}
          </span>
        </div>
      )}
      <div className="viewer__hud-row">
        <span className="viewer__hud-key">ZOOM</span>
        <span>{zoom}x</span>
      </div>
      {viewport && (normDeg(viewport.rotation) !== 0 || Boolean(viewport.hflip) !== Boolean(viewport.__baseH) || Boolean(viewport.vflip) !== Boolean(viewport.__baseV)) && (
        <div className="viewer__hud-row">
          <span className="viewer__hud-key">ROT</span>
          <span>
            {normDeg(viewport.rotation)}°{Boolean(viewport.hflip) !== Boolean(viewport.__baseH) ? ' ↔' : ''}{Boolean(viewport.vflip) !== Boolean(viewport.__baseV) ? ' ↕' : ''}
          </span>
        </div>
      )}
    </div>
  );
}

/** Rotation in whole degrees, 0..359, clockwise. */
/**
 * A fresh viewport for a series, flipped into the radiological convention
 * from the slice's ImageOrientationPatient (lib/orientation.js). The base
 * flips are remembered so the HUD only reports turns the reader made.
 */
function orientedDefault(el, image, slice) {
  const vp = window.cornerstone.getDefaultViewportForImage(el, image);
  const flips = window.Orientation
    ? window.Orientation.displayFlips(slice && slice.orientation)
    : { hflip: false, vflip: false };
  vp.hflip = flips.hflip;
  vp.vflip = flips.vflip;
  vp.__baseH = flips.hflip;
  vp.__baseV = flips.vflip;
  return vp;
}

function normDeg(deg) {
  return ((Math.round(deg || 0) % 360) + 360) % 360;
}

function SegmentLegend({ segments, visible, onToggle, onJump, revealed }) {
  if (!segments || !segments.length) return null;

  return (
    <div className="segment-legend">
      <div style={{ fontWeight: 700, color: 'var(--text-strong)' }}>
        סימון מומחה מתוך האוסף
      </div>
      {segments.map((seg) => {
        const locked = seg.role === 'finding' && !revealed;
        return (
          <div className="segment-legend__row" key={seg.number}>
            <span
              className="segment-legend__swatch"
              style={{ background: window.Anatomy.rgba(seg.color, 0.85) }}
            />
            <label className="segment-legend__name" style={{ cursor: locked ? 'default' : 'pointer' }}>
              <input
                type="checkbox"
                checked={Boolean(visible[seg.number]) && !locked}
                disabled={locked}
                onChange={() => onToggle(seg.number)}
                style={{ marginInlineEnd: 6 }}
              />
              {seg.displayName}
              {!seg.known && seg.originalLabel ? ` (${seg.originalLabel})` : ''}
            </label>
            <span className="segment-legend__count">{seg.sliceCount} חתכים</span>
            {!locked && (
              <button
                type="button"
                className="btn btn--ghost btn--sm"
                onClick={() => onJump(seg.number)}
                title="קפוץ לחתך שבו המקטע נראה בבירור"
              >
                מצא
              </button>
            )}
          </div>
        );
      })}
      {!revealed && segments.some((s) => s.role === 'finding') && (
        <div style={{ color: 'var(--text-faint)' }}>
          מקטעי הממצא נעולים עד שתסמן את תשובתך.
        </div>
      )}
    </div>
  );
}

function Attribution({ metadata, stack, segmentation }) {
  if (!metadata) return null;

  return (
    <div className="attribution">
      <strong>מקור הנתונים:</strong> {metadata.sourceName || 'The Cancer Imaging Archive'} — אוסף{' '}
      <strong>{metadata.collection}</strong>
      {metadata.patientId ? `, נבדק ${metadata.patientId}` : ''} ({metadata.modality}
      {metadata.bodyPart ? `, ${metadata.bodyPart}` : ''}
      {metadata.manufacturer ? `, ${metadata.manufacturer}` : ''}). מזהי הנבדקים הם מזהי המחקר של האוסף.
      <br />
      <strong>רישיון:</strong>{' '}
      {metadata.licenseUrl ? (
        <a href={metadata.licenseUrl} target="_blank" rel="noopener noreferrer">
          {metadata.licenseName}
        </a>
      ) : (
        metadata.licenseName || 'ראה דף האוסף'
      )}
      {metadata.collectionUrl && (
        <>
          {' · '}
          <a href={metadata.collectionUrl} target="_blank" rel="noopener noreferrer">
            דף המקור של האוסף
          </a>
        </>
      )}
      {stack.sampled && (
        <>
          <br />
          <strong style={{ color: 'var(--caution)' }}>דגימה:</strong> מוצגים{' '}
          {stack.loadedCount} חתכים מתוך {stack.totalAvailable}, בפריסה אחידה לאורך
          כל הסדרה — הגלילה עוברת על כל האיבר אך בצעדים גסים יותר.
        </>
      )}
      {segmentation && segmentation.error && (
        <>
          <br />
          <strong style={{ color: 'var(--disease)' }}>סימון המומחה לא נטען:</strong>{' '}
          {segmentation.error}
        </>
      )}
    </div>
  );
}

function DicomViewer({
  entry,
  onReady,
  onIndexChange,
  externalIndex = null,
  syncWindowTo = null,
  revealed = false,
  initialPreset = null,
  showSegmentation = true
}) {
  const elementRef = useRef(null);
  const overlayRef = useRef(null);
  const enabledRef = useRef(false);
  const displaySeq = useRef(0);
  const dragRef = useRef(null);
  // Every finger currently on the image, by pointerId. Two of them is a pinch.
  const pointersRef = useRef(new Map());
  const pinchRef = useRef(null);
  // Vertical drag distance not yet turned into a whole slice step.
  const scrollAccRef = useRef(0);
  const maskCacheRef = useRef(new Map());
  // Survive observer recreation when drawOverlay changes (slice or visibility):
  // a new ResizeObserver reports the current size at once, and a forced
  // resize on every slice step would discard the reader's zoom and pan.
  const lastResizeSizeRef = useRef(null);

  const [study, setStudy] = useState(null);
  const [index, setIndex] = useState(0);
  // Mirrors `index` for reads from inside the load callbacks, which close over
  // the render they were created in and would otherwise see a stale value.
  const indexRef = useRef(0);
  const sliceCountRef = useRef(0);
  // The last index this viewer took from a linked partner rather than from the
  // learner. Reporting it back would let a pane still on its shorter preview
  // stack clamp the value and pull its partner backwards.
  const externallySetRef = useRef(null);
  const [progress, setProgress] = useState({ done: 0, total: 0 });
  const [phase, setPhase] = useState('series');
  const [error, setError] = useState(null);
  const [viewport, setViewport] = useState(null);
  // On a touch screen the first thing a learner does is page through slices,
  // and there is no wheel, so scrolling is the default tool there.
  const [tool, setTool] = useState(() =>
    IS_TOUCH ? 'scroll' : 'window'
  );
  const [probe, setProbe] = useState(null);
  const [cine, setCine] = useState(false);
  const [segVisible, setSegVisible] = useState({});
  const [outlineMode, setOutlineMode] = useState(true);

  const seriesUid = entry.seriesInstanceUID || entry.id;

  // State updates land a render late, so during the render right after a lesson
  // step changes, `study` still holds the previous series alongside the new UID.
  // Pinning the study to its own UID keeps the old scan from flashing up.
  const current = study && (study.entry.seriesInstanceUID || study.entry.id) === seriesUid ? study : null;
  const isReady = Boolean(current);

  // ── Load ──────────────────────────────────────────────────────────────
  // The cancellation flag has to be local to each effect run. A shared ref that
  // is reset on every step change lets a still-in-flight request from the
  // previous lesson believe it is current and overwrite the new series.
  useEffect(() => {
    let cancelled = false;

    setStudy(null);
    setError(null);
    setIndex(0);
    setProbe(null);
    setCine(false);
    setProgress({ done: 0, total: 0 });
    setPhase('series');
    maskCacheRef.current = new Map();

    window.StudyLoader.loadStudy(entry, {
      onProgress: (done, total) => {
        if (!cancelled) setProgress({ done, total });
      },
      onPhase: (name) => {
        if (!cancelled) setPhase(name);
      },
      // The coarse first pass goes straight on screen. Nothing else is wired up
      // from it — no onReady, no segment visibility — because it is a preview of
      // the same series, not a different study.
      onPartial: (partial) => {
        if (!cancelled) setStudy(partial);
      },
      isCancelled: () => cancelled
    })
      .then((result) => {
        if (cancelled || !result) return;

        // The learner may already be looking at a slice from the preview. The
        // full stack is denser, so that slice sits at a different index —
        // remapping by SOP UID keeps the image under their cursor unchanged
        // instead of jumping them somewhere else mid-review.
        setStudy((prev) => {
          const previousSop =
            prev && prev.stack && !prev.stack.complete
              ? prev.stack.sopInstanceUIDs[indexRef.current]
              : null;

          if (previousSop) {
            const next = result.stack.sopInstanceUIDs.indexOf(previousSop);
            if (next >= 0) setIndex(next);
          }

          return result;
        });

        const initial = {};
        if (result.segmentation && result.segmentation.segments) {
          result.segmentation.segments.forEach((seg) => {
            initial[seg.number] = seg.role !== 'finding';
          });
        }
        setSegVisible(initial);

        if (onReady) onReady(result);
      })
      .catch((err) => {
        if (cancelled) return;
        console.error('study load failed', err);
        setError(err.message || 'טעינת הסדרה נכשלה');
      });

    return () => {
      cancelled = true;
    };
    // `entry` is a stable catalogue object; the UID identifies it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [seriesUid]);

  useEffect(() => {
    indexRef.current = index;
  }, [index]);

  // A linked pane drives this viewer's slice. Clamped, because the partner
  // series can be shorter — linking is only offered for equal-length stacks,
  // but a preview pass is shorter than its own full stack.
  useEffect(() => {
    if (externalIndex === null || externalIndex === undefined) return;
    if (!sliceCountRef.current) return;
    const clamped = Math.min(sliceCountRef.current - 1, Math.max(0, externalIndex));
    setIndex((prev) => {
      if (prev === clamped) return prev;
      externallySetRef.current = clamped;
      return clamped;
    });
  }, [externalIndex]);

  // Lets a find-exercise score the slice the learner stopped on, and a linked
  // partner follow it. An index that arrived from the partner is swallowed here
  // rather than echoed back.
  useEffect(() => {
    if (!onIndexChange || !current) return;

    if (externallySetRef.current === index) {
      externallySetRef.current = null;
      return;
    }

    onIndexChange(index, current);
  }, [index, current, onIndexChange]);

  // ── Enable the cornerstone element exactly once ────────────────────────
  useEffect(() => {
    const el = elementRef.current;
    if (!el || !isReady || enabledRef.current) return;
    window.cornerstone.enable(el);
    enabledRef.current = true;
  }, [isReady]);

  useEffect(() => {
    const el = elementRef.current;
    return () => {
      if (!enabledRef.current || !el) return;
      try {
        window.cornerstone.disable(el);
      } catch (err) {
        /* element already torn down */
      }
      enabledRef.current = false;
    };
  }, []);

  // ── Display the current slice ─────────────────────────────────────────
  useEffect(() => {
    const el = elementRef.current;
    if (!el || !current || !enabledRef.current) return;

    const seq = ++displaySeq.current;
    const imageId = current.stack.imageIds[index];
    if (!imageId) return;

    window.cornerstone
      .loadAndCacheImage(imageId)
      .then((image) => {
        if (seq !== displaySeq.current) return;

        // Within one series the window is the reader's choice and must survive
        // scrolling. Across series it must be recomputed from the new series'
        // own tags, or a brain window (80/40) bleeds onto a radiograph whose
        // own window is 1023/511 and burns the image out. Passing an undefined
        // viewport *preserves* the current one, so the default is computed
        // explicitly instead.
        const existing = window.cornerstone.getViewport(el);
        const sameSeries = existing && existing.__seriesUid === seriesUid;

        const next = sameSeries
          ? existing
          : orientedDefault(el, image, current.stack.slices && current.stack.slices[index]);
        next.__seriesUid = seriesUid;

        window.cornerstone.displayImage(el, image, next);
        setViewport({ ...window.cornerstone.getViewport(el) });
      })
      .catch((err) => console.error('slice display failed', err));
  }, [current, index, seriesUid]);

  // ── Apply a requested preset once the series is up ────────────────────
  const isColorSeries = Boolean(entry.isColor);

  const presets = useMemo(() => {
    if (!current || !viewport) return [];
    return window.Windowing.presetsFor(
      current.entry.modality,
      { ww: viewport.voi.windowWidth, wl: viewport.voi.windowCenter },
      { isColor: isColorSeries }
    );
  }, [current, viewport, isColorSeries]);

  const appliedPresetRef = useRef(null);
  useEffect(() => {
    if (!initialPreset || !presets.length) return;
    if (appliedPresetRef.current === `${seriesUid}:${initialPreset}`) return;
    const preset = presets.find((p) => p.id === initialPreset);
    if (!preset) return;
    appliedPresetRef.current = `${seriesUid}:${initialPreset}`;
    applyPreset(preset);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [initialPreset, presets.length, seriesUid]);

  // ── Segmentation overlay ──────────────────────────────────────────────
  /** Renders one mask frame to an offscreen canvas, cached per slice+segment. */
  const maskCanvasFor = useCallback(
    (sop, seg) => {
      const segmentation = current && current.segmentation;
      if (!segmentation || segmentation.error) return null;

      const key = `${sop}|${seg.number}|${outlineMode ? 'o' : 'f'}`;
      const cache = maskCacheRef.current;
      if (cache.has(key)) return cache.get(key);

      const frame = segmentation.frameFor(sop, seg.number);
      if (!frame) {
        cache.set(key, null);
        return null;
      }

      const data = outlineMode
        ? window.DicomSeg.frameToOutlineData(segmentation.decoded, frame, seg.color, 0.95)
        : window.DicomSeg.frameToImageData(segmentation.decoded, frame, seg.color, 0.32);

      const off = document.createElement('canvas');
      off.width = segmentation.decoded.columns;
      off.height = segmentation.decoded.rows;
      off.getContext('2d').putImageData(data, 0, 0);

      cache.set(key, off);
      return off;
    },
    [current, outlineMode]
  );

  const drawOverlay = useCallback(() => {
    const el = elementRef.current;
    const canvas = overlayRef.current;
    if (!el || !canvas || !current) return;

    const rect = el.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;

    if (canvas.width !== Math.round(rect.width * dpr)) {
      canvas.width = Math.round(rect.width * dpr);
      canvas.height = Math.round(rect.height * dpr);
      canvas.style.width = `${rect.width}px`;
      canvas.style.height = `${rect.height}px`;
    }

    const ctx = canvas.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, rect.width, rect.height);

    const segmentation = current.segmentation;
    if (!showSegmentation || !segmentation || segmentation.error) return;

    const sop = current.stack.sopInstanceUIDs[index];
    if (!sop) return;

    // Three image corners give the full image-to-screen mapping, rotation and
    // flips included. Two corners (an axis-aligned box) would put the expert's
    // marking in the wrong place as soon as the image is rotated.
    const cols = segmentation.decoded.columns;
    const rows = segmentation.decoded.rows;
    let o;
    let xEnd;
    let yEnd;
    try {
      o = window.cornerstone.pixelToCanvas(el, { x: 0, y: 0 });
      xEnd = window.cornerstone.pixelToCanvas(el, { x: cols, y: 0 });
      yEnd = window.cornerstone.pixelToCanvas(el, { x: 0, y: rows });
    } catch (err) {
      return; // nothing displayed yet
    }
    if (![o, xEnd, yEnd].every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) return;

    // pixelToCanvas answers in the cornerstone canvas's own coordinates, but
    // the overlay covers the whole stage. When the two differ in size (the
    // canvas lags a layout change, and in RTL it then sits flush right), the
    // marking would land beside the anatomy, so shift by the canvas offset.
    const csCanvas = el.querySelector('canvas.cornerstone-canvas');
    const cRect = csCanvas ? csCanvas.getBoundingClientRect() : rect;
    const dx = cRect.left - rect.left;
    const dy = cRect.top - rect.top;

    ctx.imageSmoothingEnabled = false;
    ctx.save();
    ctx.translate(dx, dy);
    ctx.transform(
      (xEnd.x - o.x) / cols, (xEnd.y - o.y) / cols,
      (yEnd.x - o.x) / rows, (yEnd.y - o.y) / rows,
      o.x, o.y
    );

    segmentation.segments.forEach((seg) => {
      const locked = seg.role === 'finding' && !revealed;
      if (locked || !segVisible[seg.number]) return;
      const mask = maskCanvasFor(sop, seg);
      if (mask) ctx.drawImage(mask, 0, 0, cols, rows);
    });
    ctx.restore();
  }, [current, index, revealed, segVisible, showSegmentation, maskCanvasFor]);

  // Redraw on cornerstone's own render event: that fires after every viewport
  // change, so zoom, pan and window all stay in register without polling.
  useEffect(() => {
    const el = elementRef.current;
    if (!el || !isReady) return;

    const handler = () => drawOverlay();
    el.addEventListener('cornerstoneimagerendered', handler);
    drawOverlay();

    return () => el.removeEventListener('cornerstoneimagerendered', handler);
  }, [isReady, drawOverlay]);

  useEffect(() => {
    if (!isReady) return undefined;
    const onResize = () => {
      try {
        window.cornerstone.resize(elementRef.current, true);
      } catch (err) {
        /* not enabled yet */
      }
      drawOverlay();
    };
    window.addEventListener('resize', onResize);
    // The stage also changes size without the window doing so (the comparison
    // grid settling into two columns, a sidebar, the scrollbar appearing), and
    // cornerstone keeps the canvas size it had when it was enabled.
    let observer = null;
    if (typeof ResizeObserver === 'function' && elementRef.current) {
      observer = new ResizeObserver((entries) => {
        const box = entries[0] && entries[0].contentRect;
        if (!box) return;
        const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
        if (size === lastResizeSizeRef.current) return;
        lastResizeSizeRef.current = size;
        onResize();
      });
      observer.observe(elementRef.current);
    }
    return () => {
      window.removeEventListener('resize', onResize);
      if (observer) observer.disconnect();
    };
  }, [isReady, drawOverlay]);

  // ── Navigation ────────────────────────────────────────────────────────
  const sliceCount = current ? current.stack.imageIds.length : 0;

  useEffect(() => {
    sliceCountRef.current = sliceCount;
  }, [sliceCount]);

  // ── Window/level linking between comparison panes ──────────────────────
  useEffect(() => {
    if (!syncWindowTo || !isReady) return undefined;

    return windowSync.subscribe(syncWindowTo, (sender, voi) => {
      if (sender === seriesUid) return; // our own broadcast
      const el = elementRef.current;
      if (!el || !enabledRef.current) return;
      const vp = window.cornerstone.getViewport(el);
      vp.voi.windowWidth = voi.ww;
      vp.voi.windowCenter = voi.wl;
      window.cornerstone.setViewport(el, vp);
      setViewport({ ...window.cornerstone.getViewport(el) });
    });
  }, [syncWindowTo, isReady, seriesUid]);

  const step = useCallback(
    (delta) => {
      setIndex((i) => Math.min(sliceCount - 1, Math.max(0, i + delta)));
    },
    [sliceCount]
  );

  useEffect(() => {
    const el = elementRef.current;
    if (!el || !current) return undefined;

    const onWheel = (event) => {
      event.preventDefault();
      step(event.deltaY > 0 ? 1 : -1);
    };

    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [current, step]);

  useEffect(() => {
    if (!cine || sliceCount < 2) return undefined;
    const id = setInterval(() => {
      setIndex((i) => (i + 1) % sliceCount);
    }, 1000 / CINE_FPS);
    return () => clearInterval(id);
  }, [cine, sliceCount]);

  const onKeyDown = (event) => {
    if (!current) return;
    const keys = {
      ArrowUp: () => step(-1),
      ArrowDown: () => step(1),
      ArrowRight: () => step(-1),
      ArrowLeft: () => step(1),
      Home: () => setIndex(0),
      End: () => setIndex(sliceCount - 1),
      PageUp: () => step(-10),
      PageDown: () => step(10)
    };
    const handler = keys[event.key];
    if (handler) {
      event.preventDefault();
      handler();
    }
  };

  // ── Viewport manipulation ─────────────────────────────────────────────
  function commitViewport(mutate, { broadcast = true } = {}) {
    const el = elementRef.current;
    if (!el || !enabledRef.current) return;
    const vp = window.cornerstone.getViewport(el);
    const before = { ww: vp.voi.windowWidth, wl: vp.voi.windowCenter };
    mutate(vp);
    window.cornerstone.setViewport(el, vp);
    const next = window.cornerstone.getViewport(el);
    setViewport({ ...next });

    // Only the window travels between linked panes. Zoom and pan stay local:
    // the two series can differ in matrix size and field of view, so a shared
    // scale would frame them differently rather than identically.
    const changed =
      next.voi.windowWidth !== before.ww || next.voi.windowCenter !== before.wl;

    if (broadcast && syncWindowTo && changed) {
      windowSync.publish(syncWindowTo, seriesUid, {
        ww: next.voi.windowWidth,
        wl: next.voi.windowCenter
      });
    }
  }

  /** Quarter turn on screen: +90 clockwise, -90 counter-clockwise. */
  function rotateBy(screenDeg) {
    commitViewport((vp) => {
      // Go to the next quarter in the pressed direction, so after a free twist
      // (say 120°) ↺ lands on 90° and ↻ on 180°, never past a quarter.
      // cornerstone rotates after flipping, so rotation is already the
      // on-screen angle whatever the flips are.
      const r = vp.rotation || 0;
      const eps = 1e-6;
      const target = screenDeg > 0
        ? Math.floor(r / 90 + eps) * 90 + 90
        : Math.ceil(r / 90 - eps) * 90 - 90;
      vp.rotation = target;
    }, { broadcast: false });
  }

  function applyPreset(preset) {
    commitViewport((vp) => {
      vp.voi.windowWidth = preset.ww;
      vp.voi.windowCenter = preset.wl;
    });
  }

  function resetViewport() {
    const el = elementRef.current;
    if (!el || !current) return;
    window.cornerstone.loadAndCacheImage(current.stack.imageIds[index]).then((image) => {
      const fresh = orientedDefault(el, image, current.stack.slices && current.stack.slices[index]);
      fresh.__seriesUid = seriesUid;
      window.cornerstone.setViewport(el, fresh);
      setViewport({ ...window.cornerstone.getViewport(el) });
    });
  }

  function onPointerDown(event) {
    if (!current) return;
    try {
      event.currentTarget.setPointerCapture(event.pointerId);
    } catch (err) {
      /* the pointer is already gone, e.g. a very short tap */
    }
    pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
    dragRef.current = { x: event.clientX, y: event.clientY };
    scrollAccRef.current = 0;

    if (pointersRef.current.size === 2) {
      pinchRef.current = pinchGeometry();
    }
    // No hover on a touch screen, so a tap is what reads the density.
    if (event.pointerType !== 'mouse' && pointersRef.current.size === 1) {
      updateProbe(event);
    }
  }

  function onPointerUp(event) {
    pointersRef.current.delete(event.pointerId);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    pinchRef.current = null;

    // Lifting one finger of a pinch leaves the other on the glass. Re-anchor
    // the drag on it, or the next move jumps by the whole pinch distance.
    const remaining = [...pointersRef.current.values()];
    dragRef.current = remaining.length === 1 ? { ...remaining[0] } : null;
    scrollAccRef.current = 0;
  }

  /** Distance and midpoint between the two fingers on the image. */
  function pinchGeometry() {
    const [a, b] = [...pointersRef.current.values()];
    return {
      dist: Math.hypot(b.x - a.x, b.y - a.y),
      angle: Math.atan2(b.y - a.y, b.x - a.x),
      mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
    };
  }

  function onPointerMove(event) {
    if (!current) return;

    if (!pointersRef.current.has(event.pointerId)) {
      if (event.pointerType === 'mouse') updateProbe(event);
      return;
    }
    pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });

    // Two fingers: pinch to zoom and move together to pan, whatever tool is
    // selected. This is what every phone image viewer does, so it needs no
    // teaching.
    if (pointersRef.current.size >= 2) {
      const prev = pinchRef.current;
      const next = pinchGeometry();
      pinchRef.current = next;
      if (!prev || prev.dist < 1) return;
      setProbe(null);
      const ratio = next.dist / prev.dist;
      // Twisting two fingers turns the image, like any phone photo viewer.
      let twist = ((next.angle - prev.angle) * 180) / Math.PI;
      if (twist > 180) twist -= 360;
      if (twist < -180) twist += 360;
      commitViewport((vp) => {
        vp.rotation = (vp.rotation || 0) + twist;
        vp.scale = Math.max(0.2, Math.min(8, vp.scale * ratio));
        vp.translation.x += (next.mid.x - prev.mid.x) / vp.scale;
        vp.translation.y += (next.mid.y - prev.mid.y) / vp.scale;
      });
      return;
    }

    if (!dragRef.current) return;

    const dx = event.clientX - dragRef.current.x;
    const dy = event.clientY - dragRef.current.y;
    dragRef.current = { x: event.clientX, y: event.clientY };

    // A tap reads the density; a drag changes the slice or the view, so the
    // reading taken at touch-down no longer describes what is on screen.
    if (event.pointerType !== 'mouse' && (dx !== 0 || dy !== 0)) setProbe(null);

    if (tool === 'scroll') {
      // One slice per SCROLL_PX of drag, so a long stack can still be crossed
      // in a few swipes and a short one is not skipped through by a twitch.
      scrollAccRef.current += dy;
      const steps = Math.trunc(scrollAccRef.current / SCROLL_PX);
      if (steps !== 0) {
        scrollAccRef.current -= steps * SCROLL_PX;
        step(steps);
      }
    } else if (tool === 'window') {
      commitViewport((vp) => {
        vp.voi.windowWidth = Math.max(1, vp.voi.windowWidth + dx * 2);
        vp.voi.windowCenter += dy * 2;
      });
    } else if (tool === 'pan') {
      commitViewport((vp) => {
        vp.translation.x += dx / vp.scale;
        vp.translation.y += dy / vp.scale;
      });
    } else if (tool === 'zoom') {
      commitViewport((vp) => {
        vp.scale = Math.max(0.2, Math.min(8, vp.scale - dy * 0.01));
      });
    } else if (tool === 'rotate') {
      // Drag right turns clockwise, left counter-clockwise: half a degree per pixel.
      commitViewport((vp) => {
        vp.rotation = (vp.rotation || 0) + dx * 0.5;
      });
    }
  }

  /** Reads the density under the cursor, so HU stops being an abstraction. */
  function updateProbe(event) {
    const el = elementRef.current;
    if (!el || !current || !enabledRef.current) return;

    // A volume rendering holds display colours, not densities. Probing it would
    // report one RGB channel as though it were a measurement.
    if (isColorSeries) return;

    try {
      const enabled = window.cornerstone.getEnabledElement(el);
      const image = enabled.image;
      if (!image) return;

      const pt = window.cornerstone.pageToPixel(el, event.pageX, event.pageY);
      const x = Math.floor(pt.x);
      const y = Math.floor(pt.y);

      if (x < 0 || y < 0 || x >= image.columns || y >= image.rows) {
        setProbe(null);
        return;
      }

      const pixels = image.getPixelData();
      const stored = pixels[y * image.columns + x];
      const value = stored * (image.slope || 1) + (image.intercept || 0);

      setProbe({
        x,
        y,
        value: Math.round(value),
        isHu: (current.entry.modality || '').toUpperCase() === 'CT'
      });
    } catch (err) {
      setProbe(null);
    }
  }

  function toggleSegment(number) {
    setSegVisible((prev) => ({ ...prev, [number]: !prev[number] }));
  }

  function jumpToSegment(number) {
    const segmentation = current && current.segmentation;
    if (!segmentation || segmentation.error) return;
    const target = segmentation.peakSliceFor(number);
    if (target !== null && target !== undefined) {
      setIndex(target);
      setSegVisible((prev) => ({ ...prev, [number]: true }));
    }
  }

  // ── Render ────────────────────────────────────────────────────────────
  if (error) {
    return (
      <div className="viewer__stage">
        <div className="viewer__error">
          <div style={{ fontSize: '1.5rem' }}>⚠</div>
          <div>{error}</div>
          <div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-faint)' }}>
            סדרה {entry.id} — {entry.collection}
          </div>
        </div>
      </div>
    );
  }

  const percent = progress.total
    ? Math.round((progress.done / progress.total) * 100)
    : 0;

  const phaseLabel =
    phase === 'volume' ? 'טוען נפח MRI של מתנדב בריא מ-IXI…' : phase === 'segmentation'
      ? 'מוריד את סימון המומחה…'
      : `מוריד סדרת DICOM מ-TCIA… ${progress.total ? `${progress.done}/${progress.total} חתכים` : ''}`;

  return (
    <div className="viewer">
      <div className="viewer__stage">
        {isReady && !current.stack.complete && (
          <div className="viewer__densifying" role="status">
            <span className="breathe">
              מציג סקירה גסה של {current.stack.loadedCount} חתכים לאורך כל הסדרה —
              שאר החתכים נטענים ברקע
              {progress.total ? ` (${progress.done}/${progress.total})` : ''}
            </span>
          </div>
        )}

        {!isReady && (
          <div className="viewer__loading">
            <div className="breathe">{phaseLabel}</div>
            {progress.total > 0 && (
              <div
                className="viewer__progress-track"
                role="progressbar"
                aria-valuenow={percent}
                aria-valuemin={0}
                aria-valuemax={100}
              >
                <div className="viewer__progress-fill" style={{ width: `${percent}%` }} />
              </div>
            )}
          </div>
        )}

        <div style={{ display: isReady ? 'block' : 'none', position: 'relative' }}>
          <ViewerHud
            tag={current ? current.entry.description : null}
            plane={current ? current.entry.plane : null}
            index={index}
            total={sliceCount}
            viewport={viewport}
            sampled={current ? current.stack.sampled : false}
            isColor={isColorSeries}
          />

          <div
            ref={elementRef}
            className="viewer__canvas"
            role="img"
            tabIndex={0}
            aria-label={`סדרת ${entry.modality} מאוסף ${entry.collection}, חתך ${index + 1} מתוך ${sliceCount}`}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            onPointerCancel={onPointerUp}
            onPointerLeave={(e) => {
              // Pointer capture keeps a dragging finger's events here even off
              // the edge; only a hovering mouse really leaves.
              if (e.pointerType === 'mouse') {
                onPointerUp(e);
                setProbe(null);
              }
            }}
            onKeyDown={onKeyDown}
            style={{ cursor: tool === 'pan' ? 'grab' : tool === 'zoom' || tool === 'scroll' ? 'ns-resize' : tool === 'rotate' ? 'ew-resize' : 'crosshair' }}
          />

          <canvas ref={overlayRef} className="viewer__overlay-canvas" aria-hidden="true" />

          {probe && (
            <div className="viewer__probe">
              {probe.isHu ? (
                <>
                  {probe.value} HU
                  {window.Windowing.describeHu(probe.value)
                    ? ` · ${window.Windowing.describeHu(probe.value)}`
                    : ''}
                </>
              ) : (
                <>signal {probe.value}</>
              )}
              {` · (${probe.x}, ${probe.y})`}
            </div>
          )}
        </div>
      </div>

      {isReady && (
        <>
          {sliceCount > 1 && (
            <input
              type="range"
              className="slice-slider"
              min={0}
              max={sliceCount - 1}
              value={index}
              onChange={(e) => setIndex(Number(e.target.value))}
              aria-label="בחירת חתך"
            />
          )}

          <div className="toolbar">
            <div className="toolbar__group">
              <span className="toolbar__label">כלי</span>
              {[
                { id: 'scroll', label: 'גלילה' },
                { id: 'window', label: 'חלון' },
                { id: 'pan', label: 'הזזה' },
                { id: 'zoom', label: 'זום' },
                { id: 'rotate', label: 'סיבוב' }
              ].map((t) => (
                <button
                  key={t.id}
                  type="button"
                  className="preset-btn"
                  aria-pressed={tool === t.id}
                  onClick={() => setTool(t.id)}
                >
                  {t.label}
                </button>
              ))}
            </div>

            <div className="toolbar__group" role="group" aria-label="סיבוב והיפוך התמונה">
              <span className="toolbar__label">סיבוב</span>
              <button type="button" className="preset-btn" onClick={() => rotateBy(-90)} title="סיבוב 90° נגד כיוון השעון" aria-label="סיבוב 90 מעלות נגד כיוון השעון">
                ↺ 90°
              </button>
              <button type="button" className="preset-btn" onClick={() => rotateBy(90)} title="סיבוב 90° עם כיוון השעון" aria-label="סיבוב 90 מעלות עם כיוון השעון">
                ↻ 90°
              </button>
              <button
                type="button"
                className="preset-btn"
                aria-pressed={viewport ? Boolean(viewport.hflip) !== Boolean(viewport.__baseH) : false}
                onClick={() => commitViewport((vp) => { vp.hflip = !vp.hflip; }, { broadcast: false })}
                title="היפוך ימין–שמאל"
              >
                ↔ היפוך
              </button>
              <button
                type="button"
                className="preset-btn"
                aria-pressed={viewport ? Boolean(viewport.vflip) !== Boolean(viewport.__baseV) : false}
                onClick={() => commitViewport((vp) => { vp.vflip = !vp.vflip; }, { broadcast: false })}
                title="היפוך למעלה–למטה"
              >
                ↕ היפוך
              </button>
            </div>

            {presets.length > 0 && (
            <div className="toolbar__group">
              <span className="toolbar__label">חלון צפייה</span>
              {presets.map((preset) => (
                <button
                  key={preset.id}
                  type="button"
                  className="preset-btn"
                  aria-pressed={window.Windowing.matchesPreset(viewport, preset)}
                  onClick={() => applyPreset(preset)}
                  title={preset.why}
                >
                  {preset.name}
                  {preset.absolute ? ` ${preset.ww}/${preset.wl}` : ''}
                </button>
              ))}
            </div>
            )}

            <div className="toolbar__group">
              {sliceCount > 1 && (
                <button
                  type="button"
                  className="preset-btn"
                  aria-pressed={cine}
                  onClick={() => setCine((c) => !c)}
                >
                  {cine ? '⏸ עצור' : '▶ סינה'}
                </button>
              )}
              <button
                type="button"
                className="preset-btn"
                aria-pressed={viewport ? Boolean(viewport.invert) : false}
                onClick={() => commitViewport((vp) => { vp.invert = !vp.invert; })}
              >
                היפוך
              </button>
              <button type="button" className="preset-btn" onClick={resetViewport}>
                איפוס
              </button>
            </div>

            {current.segmentation && !current.segmentation.error && (
              <div className="toolbar__group">
                <span className="toolbar__label">סימון</span>
                <button
                  type="button"
                  className="preset-btn"
                  aria-pressed={outlineMode}
                  onClick={() => {
                    maskCacheRef.current = new Map();
                    setOutlineMode((o) => !o);
                  }}
                >
                  {outlineMode ? 'קו מתאר' : 'מילוי'}
                </button>
              </div>
            )}
          </div>

          {IS_TOUCH && (
            <p className="viewer__touch-hint">
              אצבע אחת מפעילה את הכלי שנבחר · שתי אצבעות: צביטה לזום, הזזה, וסיבוב בתנועת סיבוב · נגיעה מציגה את הערך בנקודה
            </p>
          )}

          {current.segmentation && !current.segmentation.error && showSegmentation && (
            <SegmentLegend
              segments={current.segmentation.segments}
              visible={segVisible}
              onToggle={toggleSegment}
              onJump={jumpToSegment}
              revealed={revealed}
            />
          )}

          <Attribution
            metadata={current.stack.metadata}
            stack={current.stack}
            segmentation={current.segmentation}
          />
        </>
      )}
    </div>
  );
}

window.DicomViewer = DicomViewer;

})();
