/* =========================================================================
   CISCO ARENA — Question Type Renderers
   12 renderer components + registry.

   Each renderer receives standardized props:
     spec      — type-specific data (options, pairs, steps, etc.)
     value     — current answer state (lifted to parent)
     onChange  — callback to update answer
     locked    — true after submission (no interaction)
     graded    — true when showing correct/incorrect feedback

   The registry maps type string → { Component, isValid, getDefaultValue }
   ========================================================================= */

const { useState: _uS, useEffect: _uE, useRef: _uR, useCallback: _uC } = React;

/* =========================================================================
   01 · SINGLE SELECT — radio list, one of N
   ========================================================================= */
function SingleSelectRenderer({ spec, value, onChange, locked, graded }) {
  const correct = spec.correct;
  return (
    <div className="opt-list">
      {spec.options.map(o => {
        const isSel = value === o.id;
        const isRight = graded && o.id === correct;
        const isWrong = graded && isSel && o.id !== correct;
        const cls = `opt radio${isSel && !graded ? " on" : ""}${isRight ? " right" : ""}${isWrong ? " wrong" : ""}`;
        return (
          <div key={o.id} className={cls}
            role="button"
            tabIndex={locked ? -1 : 0}
            aria-pressed={isSel}
            onClick={() => !locked && onChange(o.id)}
            onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onChange(o.id); } }}
          >
            <div className="mk">{isSel ? "●" : o.id}</div>
            <div className="txt">
              {o.label}
              {o.sub && <small>{o.id} · {o.sub}</small>}
              {!o.sub && <small>{o.id}</small>}
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* =========================================================================
   02 · MULTI SELECT — checkbox list, k of N
   ========================================================================= */
function MultiSelectRenderer({ spec, value, onChange, locked, graded }) {
  // value = string[] of selected option ids
  const sel = Array.isArray(value) ? value : [];
  const correct = spec.correct || [];
  const max = spec.maxPick || spec.options.length;

  const toggle = (id) => {
    if (locked) return;
    if (sel.includes(id)) {
      onChange(sel.filter(x => x !== id));
    } else if (sel.length < max) {
      onChange([...sel, id]);
    }
  };

  return (
    <>
      <div className="ms-head">
        <span>Selection · min {spec.minPick || 1} · max {max}</span>
        <span className="ct">{sel.length} of {max} selected</span>
      </div>
      <div className="opt-list">
        {spec.options.map(o => {
          const isSel = sel.includes(o.id);
          const isRight = graded && correct.includes(o.id);
          const isWrong = graded && isSel && !correct.includes(o.id);
          const cls = `opt${isSel && !graded ? " on" : ""}${isRight ? " right" : ""}${isWrong ? " wrong" : ""}`;
          return (
            <div key={o.id} className={cls}
              role="button"
              tabIndex={locked ? -1 : 0}
              aria-pressed={isSel}
              onClick={() => toggle(o.id)}
              onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggle(o.id); } }}
            >
              <div className="mk">{isSel ? "✓" : o.id}</div>
              <div className="txt">
                {o.label}
                {o.sub && <small>{o.sub}</small>}
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
}

/* =========================================================================
   03 · TRUE / FALSE — two large buttons
   ========================================================================= */
function TrueFalseRenderer({ spec, value, onChange, locked, graded }) {
  const correct = spec.correct;

  _uE(() => {
    if (locked) return;
    const handler = (e) => {
      if (e.key === "t" || e.key === "T") onChange(true);
      if (e.key === "f" || e.key === "F") onChange(false);
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [locked, onChange]);

  const cls = (val) => {
    const isSel = value === val;
    const isRight = graded && val === correct;
    const isWrong = graded && isSel && val !== correct;
    return `tf${isSel && !graded ? " on" : ""}${isRight ? " right" : ""}${isWrong ? " wrong" : ""}`;
  };

  return (
    <div className="tf-row">
      <div className={cls(true)}
        role="button"
        tabIndex={locked ? -1 : 0}
        aria-pressed={value === true}
        onClick={() => !locked && onChange(true)}
        onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onChange(true); } }}
      >
        TRUE<small>Press T</small>
      </div>
      <div className={cls(false)}
        role="button"
        tabIndex={locked ? -1 : 0}
        aria-pressed={value === false}
        onClick={() => !locked && onChange(false)}
        onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onChange(false); } }}
      >
        FALSE<small>Press F</small>
      </div>
    </div>
  );
}

/* =========================================================================
   04 · FREE TEXT / CLI — textarea input
   ========================================================================= */
function FreeTextRenderer({ spec, value, onChange, locked, graded }) {
  const maxLen = spec.maxLength || 800;
  const text = value || "";

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <textarea
        className="cli-textarea"
        value={text}
        onChange={e => !locked && onChange(e.target.value.slice(0, maxLen))}
        placeholder={spec.placeholder || "Type your answer here..."}
        disabled={locked}
        rows={6}
        spellCheck={false}
      />
      <div style={{
        display: "flex", justifyContent: "space-between",
        fontFamily: "var(--f-mono)", fontSize: 10, color: "var(--ink-muted)",
        letterSpacing: ".06em",
      }}>
        <span>{spec.scoringMode === "proctor" ? "PROCTOR SCORED" : "AUTO-GRADED"}</span>
        <span>{text.length} / {maxLen}</span>
      </div>
      {spec.rubric && (
        <div style={{ fontSize: 12, color: "var(--ink-muted)", lineHeight: 1.6, marginTop: 4 }}>
          <div style={{ fontFamily: "var(--f-mono)", fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: 4 }}>
            Rubric
          </div>
          <ul style={{ paddingLeft: 16, margin: 0 }}>
            {spec.rubric.map((r, i) => <li key={i}>{r}</li>)}
          </ul>
        </div>
      )}
    </div>
  );
}

/* =========================================================================
   05 · DRAG & MATCH — click-to-pair left items to right slots
   ========================================================================= */
function DragMatchRenderer({ spec, value, onChange, locked, graded }) {
  // value = { leftId: rightId | null }
  const matches = value || {};
  const [activeLeft, setActiveLeft] = _uS(null);
  const [dragOverSlot, setDragOverSlot] = _uS(null);

  const pairs = spec.pairs || [];
  const lefts = pairs.map(p => ({ id: p.leftId, label: p.left }));
  const rights = pairs.map(p => ({ id: p.rightId, label: p.right }));

  // Shuffle rights for display (but keep consistent)
  const [shuffledRights] = _uS(() => {
    return [...rights].sort(() => Math.random() - 0.5);
  });

  const selectLeft = (leftId) => {
    if (locked) return;
    setActiveLeft(activeLeft === leftId ? null : leftId);
  };

  const getMatchedLeft = (rightId) => {
    return Object.entries(matches).find(([, r]) => r === rightId)?.[0];
  };

  // Place leftId into rightId slot, evicting any previous occupant
  const placeMatch = (leftId, rightId) => {
    const newMatches = { ...matches };
    // Evict previous occupant of this right slot (if any other left item was here)
    const previousLeft = getMatchedLeft(rightId);
    if (previousLeft && previousLeft !== leftId) {
      delete newMatches[previousLeft];
    }
    newMatches[leftId] = rightId;
    onChange(newMatches);
  };

  // Clear a specific left item's match
  const clearMatch = (leftId) => {
    if (locked) return;
    const newMatches = { ...matches };
    delete newMatches[leftId];
    onChange(newMatches);
    setActiveLeft(null);
  };

  const selectRight = (rightId) => {
    if (locked) return;
    // If no left item selected, clicking a filled slot selects its occupant for reassignment
    if (!activeLeft) {
      const occupant = getMatchedLeft(rightId);
      if (occupant) setActiveLeft(occupant);
      return;
    }
    placeMatch(activeLeft, rightId);
    setActiveLeft(null);
  };

  const handleDragStart = (e, leftId) => {
    e.dataTransfer.setData('text/plain', leftId);
    e.dataTransfer.effectAllowed = 'move';
  };

  const handleDrop = (e, rightId) => {
    e.preventDefault();
    const leftId = e.dataTransfer.getData('text/plain');
    if (!leftId || locked) return;
    placeMatch(leftId, rightId);
    setDragOverSlot(null);
  };

  return (
    <div className="dnd">
      <div className="dnd-col">
        <h5>Items</h5>
        {lefts.map(l => {
          const isMatched = matches[l.id] != null;
          const isActive = activeLeft === l.id;
          return (
            <div key={l.id}
              className={`dnd-item${isActive ? " on" : ""}${isMatched && !isActive ? " matched" : ""}`}
              role="button"
              tabIndex={locked ? -1 : 0}
              aria-label={`Item: ${l.label}${isMatched ? ' (matched — click to reassign)' : ''}${isActive ? ' (selected, click a match)' : ''}`}
              aria-pressed={isActive}
              draggable={!locked}
              onClick={() => selectLeft(l.id)}
              onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectLeft(l.id); } }}
              onDragStart={e => handleDragStart(e, l.id)}
              style={{ ...(locked ? {} : { cursor: 'grab' }) }}
            >
              <span className="grip" title="Drag to match">⋮⋮</span>
              {l.label}
              {isMatched && !isActive && <span style={{ fontSize: 10, marginLeft: 'auto', opacity: 0.5 }}>✓</span>}
            </div>
          );
        })}
      </div>
      <div className="dnd-mid">
        <h5 style={{ visibility: "hidden" }}>·</h5>
        {lefts.map((_, i) => <span key={i}>→</span>)}
      </div>
      <div className="dnd-col">
        <h5>Matches</h5>
        {shuffledRights.map(r => {
          const matchedLeftId = getMatchedLeft(r.id);
          const matchedLeftLabel = matchedLeftId ? lefts.find(l => l.id === matchedLeftId)?.label : null;
          const isFilled = matchedLeftId != null;
          const isDragOver = dragOverSlot === r.id;
          // Graded feedback: determine if this slot's match is correct
          const correctLeftForThisRight = graded ? pairs.find(p => p.rightId === r.id)?.leftId : null;
          const correctRightForMatchedLeft = graded && matchedLeftId ? pairs.find(p => p.leftId === matchedLeftId)?.rightId : null;
          const isGradedRight = graded && isFilled && correctRightForMatchedLeft === r.id;
          const isGradedWrong = graded && isFilled && correctRightForMatchedLeft !== r.id;
          const gradedStyle = isGradedRight
            ? { borderColor: 'var(--success)', background: 'oklch(from var(--success) l c h / 0.08)' }
            : isGradedWrong
            ? { borderColor: 'var(--danger)', background: 'oklch(from var(--danger) l c h / 0.08)' }
            : isDragOver ? { outline: '2px solid var(--accent)', background: 'var(--qt-brand-soft)' }
            : {};
          return (
            <div key={r.id}
              className={`dnd-slot${isFilled ? " filled" : ""}${isDragOver ? " drag-over" : ""}${isGradedRight ? " right" : ""}${isGradedWrong ? " wrong" : ""}`}
              role="button"
              tabIndex={locked ? -1 : 0}
              aria-label={`Match slot: ${r.label}${isFilled ? `, matched with ${matchedLeftLabel}` : ''}${activeLeft ? ' (click to place)' : ''}`}
              onClick={() => selectRight(r.id)}
              onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectRight(r.id); } }}
              onDragOver={e => e.preventDefault()}
              onDragEnter={e => { e.preventDefault(); if (!locked) setDragOverSlot(r.id); }}
              onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverSlot(null); }}
              onDrop={e => handleDrop(e, r.id)}
              style={gradedStyle}
            >
              {r.label}
              {isFilled && (
                <span className={`pill${isGradedRight ? " b" : isGradedWrong ? " d" : ""}`}>
                  {isGradedRight && <span style={{ marginRight: 3 }}>✓</span>}
                  {isGradedWrong && <span style={{ marginRight: 3 }}>✗</span>}
                  {matchedLeftLabel}
                  {!locked && <span className="pill-clear" onClick={e => { e.stopPropagation(); clearMatch(matchedLeftId); }} title="Remove match">×</span>}
                </span>
              )}
              {/* After grading show the correct left item for wrong slots */}
              {isGradedWrong && correctLeftForThisRight && (
                <span style={{ fontSize: 10, color: 'var(--success)', display: 'block', marginTop: 4, fontFamily: 'var(--f-mono)', letterSpacing: '.04em' }}>
                  ✓ {lefts.find(l => l.id === correctLeftForThisRight)?.label}
                </span>
              )}
              {!isFilled && activeLeft && <span style={{ color: "var(--accent)", fontFamily: "var(--f-mono)", fontSize: 10, marginLeft: "auto" }}>click to place</span>}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* =========================================================================
   06 · GROUP SORT — drag chips into buckets (click-to-place)
   ========================================================================= */
function GroupSortRenderer({ spec, value, onChange, locked, graded }) {
  // value = { bucketId: [itemId, ...] }
  const buckets = spec.buckets || [];
  const items = spec.items || [];
  const assignments = value || {};
  const [activeItem, setActiveItem] = _uS(null);
  const [dragOverBucket, setDragOverBucket] = _uS(null);

  const assignedItems = new Set(Object.values(assignments).flat());
  const poolItems = items.filter(i => !assignedItems.has(i.id));

  const selectItem = (itemId) => {
    if (locked) return;
    setActiveItem(activeItem === itemId ? null : itemId);
  };

  const placeInBucket = (bucketId) => {
    if (locked || !activeItem) return;
    // Remove from any existing bucket
    const newAssign = {};
    for (const bId of buckets.map(b => b.id)) {
      newAssign[bId] = (assignments[bId] || []).filter(x => x !== activeItem);
    }
    // Add to target bucket
    newAssign[bucketId] = [...(newAssign[bucketId] || []), activeItem];
    onChange(newAssign);
    setActiveItem(null);
  };

  const removeFromBucket = (bucketId, itemId) => {
    if (locked) return;
    const newAssign = { ...assignments };
    newAssign[bucketId] = (newAssign[bucketId] || []).filter(x => x !== itemId);
    onChange(newAssign);
  };

  const handleDragStart = (e, itemId) => {
    e.dataTransfer.setData('text/plain', itemId);
    e.dataTransfer.effectAllowed = 'move';
    e.stopPropagation();
  };

  const handleBucketDrop = (e, bucketId) => {
    e.preventDefault();
    const itemId = e.dataTransfer.getData('text/plain');
    if (!itemId || locked) return;
    const newAssign = {};
    for (const bId of buckets.map(b => b.id)) {
      newAssign[bId] = (assignments[bId] || []).filter(x => x !== itemId);
    }
    newAssign[bucketId] = [...(newAssign[bucketId] || []), itemId];
    onChange(newAssign);
    setDragOverBucket(null);
  };

  return (
    <>
      <div className="groups">
        {buckets.map(b => {
          const bItems = (assignments[b.id] || []).map(id => items.find(i => i.id === id)).filter(Boolean);
          const isDragOver = dragOverBucket === b.id;
          return (
            <div key={b.id}
              className={`group${activeItem ? " focus" : ""}${isDragOver ? " drag-over" : ""}`}
              role="button"
              tabIndex={locked ? -1 : 0}
              aria-label={`Bucket: ${b.label}, ${bItems.length} item(s)${activeItem ? '. Press Enter to place selected item here.' : ''}`}
              onClick={() => placeInBucket(b.id)}
              onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); placeInBucket(b.id); } }}
              onDragOver={e => e.preventDefault()}
              onDragEnter={e => { e.preventDefault(); if (!locked) setDragOverBucket(b.id); }}
              onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverBucket(null); }}
              onDrop={e => handleBucketDrop(e, b.id)}
              style={isDragOver ? { outline: '2px solid var(--accent)', background: 'var(--qt-brand-soft)' } : {}}
            >
              <h5>{b.label} <span className="ct">{bItems.length}</span></h5>
              {bItems.map(i => (
                <div key={i.id} className="group-item"
                  role="button"
                  tabIndex={locked ? -1 : 0}
                  aria-label={`Remove ${i.label} from ${b.label}`}
                  draggable={!locked}
                  onClick={(e) => { e.stopPropagation(); removeFromBucket(b.id, i.id); }}
                  onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); e.stopPropagation(); removeFromBucket(b.id, i.id); } }}
                  onDragStart={e => handleDragStart(e, i.id)}
                  style={locked ? {} : { cursor: 'grab' }}
                >
                  <span className="grip" title="Drag to move">⋮⋮</span>{i.label}
                </div>
              ))}
              {bItems.length === 0 && (
                <div style={{ fontSize: 11, color: "var(--qt-ink-4)", fontStyle: "italic", marginTop: "auto" }}>
                  drop items here
                </div>
              )}
            </div>
          );
        })}
      </div>
      {poolItems.length > 0 && (
        <div className="groups-pool">
          {poolItems.map(i => (
            <span key={i.id}
              className={`pool-chip${activeItem === i.id ? " on" : ""}`}
              role="button"
              tabIndex={locked ? -1 : 0}
              aria-label={`Item: ${i.label}${activeItem === i.id ? ' (selected, click a bucket to place)' : ''}`}
              aria-pressed={activeItem === i.id}
              draggable={!locked}
              onClick={() => selectItem(i.id)}
              onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectItem(i.id); } }}
              onDragStart={e => handleDragStart(e, i.id)}
              style={{ ...(activeItem === i.id ? { borderColor: "var(--accent)", background: "var(--qt-brand-soft)" } : {}), ...(locked ? {} : { cursor: 'grab' }) }}
            >
              {i.label}
            </span>
          ))}
        </div>
      )}
    </>
  );
}

/* =========================================================================
   07 · RANK ORDER — reorder list (click arrows or drag)
   ========================================================================= */
function RankOrderRenderer({ spec, value, onChange, locked, graded }) {
  // value = [itemId, ...] in player's order
  const items = spec.items || [];
  const order = value || items.map(i => i.id);
  const [movingIdx, setMovingIdx] = _uS(null);
  const [dragIdx, setDragIdx] = _uS(null);
  const [dragOverIdx, setDragOverIdx] = _uS(null);

  const swap = (i, j) => {
    if (locked || j < 0 || j >= order.length) return;
    const next = [...order];
    [next[i], next[j]] = [next[j], next[i]];
    onChange(next);
    setMovingIdx(j);
    setTimeout(() => setMovingIdx(null), 300);
  };

  const handleDragStart = (e, idx) => {
    e.dataTransfer.setData('text/plain', String(idx));
    e.dataTransfer.effectAllowed = 'move';
    setDragIdx(idx);
  };

  const handleDragEnd = () => {
    setDragIdx(null);
    setDragOverIdx(null);
  };

  const handleDrop = (e, targetIdx) => {
    e.preventDefault();
    const srcIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
    if (isNaN(srcIdx) || srcIdx === targetIdx || locked) return;
    const next = [...order];
    const [moved] = next.splice(srcIdx, 1);
    next.splice(targetIdx, 0, moved);
    onChange(next);
    setDragIdx(null);
    setDragOverIdx(null);
  };

  return (
    <div className="rank-list">
      {order.map((id, idx) => {
        const item = items.find(i => i.id === id);
        if (!item) return null;
        const isBeingDragged = dragIdx === idx;
        const isDropTarget = dragOverIdx === idx && dragIdx !== idx;
        return (
          <div key={id}
            className={`rank-item${movingIdx === idx ? " moving" : ""}`}
            draggable={!locked}
            onDragStart={e => handleDragStart(e, idx)}
            onDragEnd={handleDragEnd}
            onDragOver={e => e.preventDefault()}
            onDragEnter={e => { e.preventDefault(); if (!locked) setDragOverIdx(idx); }}
            onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverIdx(null); }}
            onDrop={e => handleDrop(e, idx)}
            style={{
              ...(isBeingDragged ? { opacity: 0.4, cursor: 'grabbing' } : {}),
              ...(isDropTarget ? { outline: '2px solid var(--accent)', background: 'var(--qt-brand-soft)' } : {}),
              ...(!locked && !isBeingDragged ? { cursor: 'grab' } : {}),
            }}
          >
            <span className="pos">{idx + 1}</span>
            <span>{item.label}</span>
            <span className="handle" style={{ display: "flex", gap: 4 }}>
              {!locked && idx > 0 && (
                <button onClick={() => swap(idx, idx - 1)} style={{ padding: "2px 6px", background: "var(--bg-elev-2)", border: "1px solid var(--qt-line-soft)", borderRadius: "var(--r-sm)", cursor: "pointer", color: "var(--ink-muted)", fontSize: 12 }}>▲</button>
              )}
              {!locked && idx < order.length - 1 && (
                <button onClick={() => swap(idx, idx + 1)} style={{ padding: "2px 6px", background: "var(--bg-elev-2)", border: "1px solid var(--qt-line-soft)", borderRadius: "var(--r-sm)", cursor: "pointer", color: "var(--ink-muted)", fontSize: 12 }}>▼</button>
              )}
              {!locked && <span style={{ fontFamily: "var(--f-mono)", color: "var(--qt-ink-4)", userSelect: 'none' }} title="Drag to reorder">⋮⋮</span>}
              {locked && <span style={{ fontFamily: "var(--f-mono)", color: "var(--qt-ink-4)" }}>⋮⋮</span>}
            </span>
          </div>
        );
      })}
    </div>
  );
}

/* =========================================================================
   08 · CASCADE — sequential steps, each unlocks the next
   ========================================================================= */
function CascadeRenderer({ spec, value, onChange, locked, graded }) {
  // value = { stepId: selectedOptionId }
  const answers = value || {};
  const steps = spec.steps || [];

  const currentStepIdx = steps.findIndex(s => !answers[s.id]);
  const failureMode = spec.failureMode || "lock-and-show";

  const selectOption = (stepId, optId) => {
    if (locked) return;
    const step = steps.find(s => s.id === stepId);
    if (!step) return;
    const stepIdx = steps.indexOf(step);
    if (stepIdx > currentStepIdx) return; // locked step
    const newAnswers = { ...answers, [stepId]: optId };
    onChange(newAnswers);
  };

  // Allow user to undo a step before grading — clears that step + all later
  // answers (cascade is sequential, downstream picks depend on this one).
  const clearFromStep = (stepIdx) => {
    if (locked) return;
    const next = {};
    for (let i = 0; i < stepIdx; i++) {
      const sid = steps[i].id;
      if (answers[sid] != null) next[sid] = answers[sid];
    }
    onChange(next);
  };

  return (
    <>
      <div className="cascade">
        {steps.map((step, idx) => {
        const answer = answers[step.id];
        const isDone = answer != null;
        const isCurrent = idx === currentStepIdx;
        const isLocked = idx > currentStepIdx;
        const isCorrect = isDone && answer === step.correct;
        const stepCls = isDone ? "done" : isCurrent ? "current" : "locked";

        return (
          <div key={step.id} className={`casc-step ${stepCls}`}>
            <div className="casc-num">{isDone && isCorrect ? "✓" : idx + 1}</div>
            <div>
              <div className="casc-q">{step.prompt}</div>
              {isDone && (
                <div className="casc-ans">
                  {step.options.find(o => o.id === answer)?.label}
                  {!locked && !graded && (
                    <button
                      type="button"
                      onClick={() => clearFromStep(idx)}
                      style={{
                        marginLeft: 10,
                        background: 'transparent',
                        border: '1px solid var(--line-soft, rgba(255,255,255,0.18))',
                        color: 'var(--ink-dim, #aaa)',
                        fontSize: 11,
                        fontFamily: 'var(--f-mono, monospace)',
                        letterSpacing: '.04em',
                        padding: '3px 8px',
                        borderRadius: 6,
                        cursor: 'pointer',
                        textTransform: 'uppercase'
                      }}
                      aria-label={`Change answer for step ${idx + 1}`}
                    >
                      ↺ Change
                    </button>
                  )}
                </div>
              )}
              {(isCurrent || (isDone && !isCorrect && failureMode === "let-continue")) && !isDone && (
                <div className="casc-opts">
                  {step.options.map(o => (
                    <span key={o.id}
                      className={`casc-chip${answer === o.id ? " on" : ""}`}
                      role="button"
                      tabIndex={isLocked || locked ? -1 : 0}
                      aria-pressed={answer === o.id}
                      onClick={() => selectOption(step.id, o.id)}
                      onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectOption(step.id, o.id); } }}
                    >
                      {o.label}
                    </span>
                  ))}
                </div>
              )}
              {isLocked && !isDone && (
                <div className="casc-opts" style={{ opacity: 0.4 }}>
                  {step.options.map(o => (
                    <span key={o.id} className="casc-chip">{o.label}</span>
                  ))}
                </div>
              )}
              {idx < steps.length - 1 && <div className="casc-trail" />}
            </div>
          </div>
        );
      })}
      </div>
      {Object.keys(answers).length === steps.length && spec.explanation && (
        <div style={{ marginTop: 12, padding: "10px 14px", background: "var(--qt-brand-soft)", borderRadius: "var(--r-md)", fontSize: 13, color: "var(--ink-dim)", lineHeight: 1.5 }}>
          <strong style={{ color: "var(--accent)", fontSize: 11, fontFamily: "var(--f-mono)", letterSpacing: ".05em" }}>EXPLANATION</strong>
          <div style={{ marginTop: 4 }}>{spec.explanation}</div>
        </div>
      )}
    </>
  );
}

/* =========================================================================
   09 · HOTSPOT — click to place pins on an image/topology
   ========================================================================= */
function HotspotRenderer({ spec, value, onChange, locked, graded }) {
  // value = [{x, y}, ...] normalized 0..1
  const pins = value || [];
  const maxPins = spec.maxPins || 3;
  const containerRef = _uR(null);

  const handleClick = (e) => {
    if (locked || pins.length >= maxPins) return;
    const rect = containerRef.current.getBoundingClientRect();
    const x = (e.clientX - rect.left) / rect.width;
    const y = (e.clientY - rect.top) / rect.height;
    onChange([...pins, { x: Math.round(x * 1000) / 1000, y: Math.round(y * 1000) / 1000 }]);
  };

  const removePin = (idx, e) => {
    e.stopPropagation();
    if (locked) return;
    onChange(pins.filter((_, i) => i !== idx));
  };

  const getPinClass = (pin, idx) => {
    if (!graded) return "";
    const targets = spec.targets || [];
    const closest = targets.reduce((best, t) => {
      const d = Math.hypot(pin.x - t.x, pin.y - t.y);
      return d < best.d ? { d, t } : best;
    }, { d: Infinity, t: null });
    return closest.d <= (closest.t?.radius || 0.08) ? "right" : "wrong";
  };

  return (
    <>
      <div className="hotspot" ref={containerRef}
        role="img"
        aria-label={`Network topology diagram. Click to place pins (${pins.length} of ${maxPins} placed).`}
        tabIndex={locked ? -1 : 0}
        onClick={handleClick}
      >
        {(() => {
          const topology = spec.svgTopology || 'default';
          const topologies = {
            'default': (
              <svg viewBox="0 0 640 360">
                <defs>
                  <linearGradient id="cableG2" x1="0" x2="1"><stop offset="0" stopColor="oklch(56% .12 230)" /><stop offset="1" stopColor="oklch(56% .12 230)" /></linearGradient>
                </defs>
                <ellipse cx="90" cy="70" rx="60" ry="28" fill="oklch(26% .012 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="90" y="75" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(70% .01 250)">internet</text>
                <rect x="260" y="48" width="100" height="44" rx="6" fill="oklch(24% .03 230)" stroke="var(--accent)" strokeWidth="1.2" />
                <text x="310" y="75" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(82% .05 230)">edge FTD</text>
                <rect x="260" y="150" width="100" height="44" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="310" y="177" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(72% .01 250)">core switch</text>
                <rect x="170" y="252" width="100" height="38" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="220" y="276" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .01 250)">access sw</text>
                <rect x="350" y="252" width="100" height="38" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="400" y="276" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .01 250)">access sw</text>
                <circle cx="150" cy="320" r="10" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <circle cx="190" cy="320" r="10" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <circle cx="230" cy="320" r="10" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <circle cx="390" cy="320" r="10" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <circle cx="430" cy="320" r="10" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <path d="M 150 70 L 260 70" stroke="url(#cableG2)" strokeWidth="1.5" strokeDasharray="3 3" />
                <path d="M 310 92 L 310 150" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 310 194 L 220 252" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 310 194 L 400 252" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 220 290 L 150 320" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <path d="M 220 290 L 190 320" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <path d="M 220 290 L 230 320" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <path d="M 400 290 L 390 320" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <path d="M 400 290 L 430 320" stroke="oklch(40% .01 250)" strokeWidth="1" />
              </svg>
            ),
            'ise-deployment': (
              <svg viewBox="0 0 640 360">
                {/* ISE Deployment: PAN(primary) — PAN(secondary) at top, MnT nodes middle, 3 PSN nodes bottom */}
                <rect x="180" y="20" width="120" height="44" rx="6" fill="oklch(24% .04 230)" stroke="var(--accent)" strokeWidth="2" />
                <text x="240" y="47" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(90% .05 230)">PAN (primary)</text>
                <rect x="340" y="20" width="120" height="44" rx="6" fill="oklch(24% .03 230)" stroke="oklch(50% .08 230)" strokeWidth="1.2" />
                <text x="400" y="47" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(75% .04 230)">PAN (secondary)</text>
                <rect x="100" y="140" width="110" height="40" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="155" y="165" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .01 250)">MnT (primary)</text>
                <rect x="420" y="140" width="110" height="40" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="475" y="165" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .01 250)">MnT (secondary)</text>
                <rect x="80" y="260" width="100" height="40" rx="6" fill="oklch(22% .02 170)" stroke="oklch(45% .08 170)" strokeWidth="1" />
                <text x="130" y="285" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .04 170)">PSN-1</text>
                <rect x="260" y="260" width="100" height="40" rx="6" fill="oklch(22% .02 170)" stroke="oklch(45% .08 170)" strokeWidth="1" />
                <text x="310" y="285" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .04 170)">PSN-2</text>
                <rect x="440" y="260" width="100" height="40" rx="6" fill="oklch(22% .02 170)" stroke="oklch(45% .08 170)" strokeWidth="1" />
                <text x="490" y="285" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .04 170)">PSN-3</text>
                <path d="M 240 64 L 240 140" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 400 64 L 400 140" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 240 64 L 400 64" stroke="oklch(40% .01 250)" strokeWidth="1" strokeDasharray="4 4" />
                <path d="M 155 180 L 130 260" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <path d="M 240 42 L 310 260" stroke="oklch(36% .01 250)" strokeWidth="1" strokeDasharray="3 3" />
                <path d="M 475 180 L 490 260" stroke="oklch(40% .01 250)" strokeWidth="1" />
              </svg>
            ),
            'ise-profiling': (
              <svg viewBox="0 0 640 360">
                {/* ISE Profiling: PAN with profiling engine at center, PSN probes feeding in */}
                <rect x="230" y="130" width="180" height="60" rx="8" fill="oklch(24% .04 230)" stroke="var(--accent)" strokeWidth="2" />
                <text x="320" y="155" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(90% .05 230)">PAN</text>
                <text x="320" y="175" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(70% .06 195)">Profiling Policy Engine</text>
                <rect x="50" y="30" width="90" height="32" rx="4" fill="oklch(22% .015 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="95" y="51" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">RADIUS</text>
                <rect x="170" y="30" width="90" height="32" rx="4" fill="oklch(22% .015 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="215" y="51" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">DHCP</text>
                <rect x="290" y="30" width="90" height="32" rx="4" fill="oklch(22% .015 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="335" y="51" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">HTTP</text>
                <rect x="410" y="30" width="90" height="32" rx="4" fill="oklch(22% .015 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="455" y="51" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">SNMP</text>
                <rect x="530" y="30" width="80" height="32" rx="4" fill="oklch(22% .015 250)" stroke="oklch(40% .01 250)" strokeWidth="1" />
                <text x="570" y="51" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">NetFlow</text>
                <rect x="100" y="270" width="100" height="36" rx="4" fill="oklch(22% .02 170)" stroke="oklch(45% .08 170)" strokeWidth="1" />
                <text x="150" y="293" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .04 170)">PSN-1</text>
                <rect x="420" y="270" width="100" height="36" rx="4" fill="oklch(22% .02 170)" stroke="oklch(45% .08 170)" strokeWidth="1" />
                <text x="470" y="293" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .04 170)">PSN-2</text>
                {[95,215,335,455,570].map((x,i) => <path key={i} d={`M ${x} 62 L 320 130`} stroke="oklch(40% .03 195)" strokeWidth="1" strokeDasharray="3 3" />)}
                <path d="M 150 270 L 280 190" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 470 270 L 360 190" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <circle cx="150" cy="340" r="8" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" /><circle cx="180" cy="340" r="8" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <circle cx="470" cy="340" r="8" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" /><circle cx="500" cy="340" r="8" fill="oklch(24% .015 250)" stroke="oklch(40% .01 250)" />
                <path d="M 150 306 L 150 332" stroke="oklch(36% .01 250)" strokeWidth="1" /><path d="M 180 306 L 180 332" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <path d="M 470 306 L 470 332" stroke="oklch(36% .01 250)" strokeWidth="1" /><path d="M 500 306 L 500 332" stroke="oklch(36% .01 250)" strokeWidth="1" />
              </svg>
            ),
            'ise-trustsec': (
              <svg viewBox="0 0 640 360">
                {/* TrustSec: ISE at top, ingress switch (SGT tagging), fabric, egress switch (SGACL) */}
                <rect x="250" y="20" width="140" height="44" rx="6" fill="oklch(24% .04 230)" stroke="var(--accent)" strokeWidth="1.5" />
                <text x="320" y="47" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(82% .05 230)">ISE (SGT Matrix)</text>
                <rect x="60" y="150" width="130" height="44" rx="6" fill="oklch(22% .02 170)" stroke="oklch(50% .08 170)" strokeWidth="1.2" />
                <text x="125" y="170" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .04 170)">Ingress Switch</text>
                <text x="125" y="185" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(60% .06 50)">SGT classification</text>
                <rect x="265" y="150" width="110" height="44" rx="6" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="320" y="177" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(72% .01 250)">SXP / Fabric</text>
                <rect x="450" y="150" width="130" height="44" rx="6" fill="oklch(24% .03 27)" stroke="oklch(55% .12 27)" strokeWidth="1.5" />
                <text x="515" y="170" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="10" fill="oklch(80% .08 27)">DC Access Switch</text>
                <text x="515" y="185" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(65% .1 27)">SGACL enforcement</text>
                <circle cx="125" cy="280" r="14" fill="oklch(24% .015 250)" stroke="oklch(45% .01 250)" />
                <text x="125" y="284" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(65% .01 250)">user</text>
                <rect x="470" y="260" width="90" height="36" rx="4" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="515" y="283" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">server</text>
                <path d="M 125 266 L 125 194" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 190 172 L 265 172" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 375 172 L 450 172" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 515 194 L 515 260" stroke="oklch(40% .01 250)" strokeWidth="1.5" />
                <path d="M 320 64 L 125 150" stroke="oklch(40% .03 230)" strokeWidth="1" strokeDasharray="4 3" />
                <path d="M 320 64 L 515 150" stroke="oklch(40% .03 230)" strokeWidth="1" strokeDasharray="4 3" />
                <text x="80" y="145" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(60% .08 50)">SGT=10</text>
                <text x="395" y="145" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(60% .1 27)">SGACL ▼</text>
              </svg>
            ),
            'ise-pxgrid': (
              <svg viewBox="0 0 640 360">
                {/* pxGrid: ISE + pxGrid controller center, 3rd party tools around */}
                <rect x="230" y="120" width="180" height="70" rx="8" fill="oklch(24% .04 230)" stroke="var(--accent)" strokeWidth="2" />
                <text x="320" y="148" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="11" fill="oklch(90% .05 230)">ISE</text>
                <text x="320" y="168" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(72% .06 195)">pxGrid Controller</text>
                <rect x="40" y="30" width="120" height="36" rx="4" fill="oklch(22% .02 27)" stroke="oklch(45% .08 27)" strokeWidth="1" />
                <text x="100" y="53" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .06 27)">Firepower</text>
                <rect x="480" y="30" width="120" height="36" rx="4" fill="oklch(22% .02 300)" stroke="oklch(45% .08 300)" strokeWidth="1" />
                <text x="540" y="53" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .06 300)">Stealthwatch</text>
                <rect x="40" y="260" width="120" height="36" rx="4" fill="oklch(22% .02 80)" stroke="oklch(45% .08 80)" strokeWidth="1" />
                <text x="100" y="283" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .06 80)">SIEM</text>
                <rect x="480" y="260" width="120" height="36" rx="4" fill="oklch(22% .02 155)" stroke="oklch(45% .08 155)" strokeWidth="1" />
                <text x="540" y="283" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .06 155)">Rapid7 / Qualys</text>
                <rect x="250" y="290" width="140" height="36" rx="4" fill="oklch(22% .015 250)" stroke="oklch(36% .01 250)" strokeWidth="1" />
                <text x="320" y="313" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="9" fill="oklch(68% .01 250)">DNA Center</text>
                <path d="M 160 48 L 230 140" stroke="oklch(42% .05 195)" strokeWidth="1.5" strokeDasharray="4 3" />
                <path d="M 480 48 L 410 140" stroke="oklch(42% .05 195)" strokeWidth="1.5" strokeDasharray="4 3" />
                <path d="M 160 278 L 230 180" stroke="oklch(42% .05 195)" strokeWidth="1.5" strokeDasharray="4 3" />
                <path d="M 480 278 L 410 180" stroke="oklch(42% .05 195)" strokeWidth="1.5" strokeDasharray="4 3" />
                <path d="M 320 290 L 320 190" stroke="oklch(42% .05 195)" strokeWidth="1.5" strokeDasharray="4 3" />
                <text x="320" y="240" textAnchor="middle" fontFamily="'JetBrains Mono',monospace" fontSize="8" fill="oklch(55% .06 195)">pxGrid pub/sub</text>
              </svg>
            ),
          };
          return topologies[topology] || topologies['default'];
        })()}
        {pins.map((p, i) => (
          <div key={i}
            className={`pin ${getPinClass(p, i)}`}
            style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }}
            role="button"
            tabIndex={locked ? -1 : 0}
            aria-label={`Pin ${i + 1} at ${(p.x * 100).toFixed(0)}%, ${(p.y * 100).toFixed(0)}%. Press Enter or Space to remove.`}
            onClick={(e) => removePin(i, e)}
            onKeyDown={e => { if (!locked && (e.key === 'Enter' || e.key === ' ' || e.key === 'Delete')) { e.preventDefault(); e.stopPropagation(); removePin(i, e); } }}
          >
            {i + 1}
          </div>
        ))}
      </div>
      <div className="hotspot-labels">
        {pins.map((p, i) => {
          const pinCls = graded ? getPinClass(p, i) : "";
          const chipCls = pinCls === "right" ? "b" : pinCls === "wrong" ? "d" : "";
          return (
            <span key={i} className={`chip ${chipCls}`}>
              Pin {i + 1} · ({(p.x * 100).toFixed(0)}%, {(p.y * 100).toFixed(0)}%)
            </span>
          );
        })}
        {!locked && pins.length < maxPins && (
          <span className="chip" style={{ cursor: "pointer" }}>+ pin</span>
        )}
      </div>
      {graded && spec.explanation && (
        <div style={{ marginTop: 12, padding: "10px 14px", background: "var(--qt-brand-soft)", borderRadius: "var(--r-md)", fontSize: 13, color: "var(--ink-dim)", lineHeight: 1.5 }}>
          <strong style={{ color: "var(--accent)", fontSize: 11, fontFamily: "var(--f-mono)", letterSpacing: ".05em" }}>EXPLANATION</strong>
          <div style={{ marginTop: 4 }}>{spec.explanation}</div>
        </div>
      )}
    </>
  );
}

/* =========================================================================
   10 · NUMERIC SLIDER — drag thumb or click track
   ========================================================================= */
function NumericSliderRenderer({ spec, value, onChange, locked, graded }) {
  const min = spec.min || 0;
  const max = spec.max || 100;
  const step = spec.step || 1;
  const current = value != null ? value : Math.round((min + max) / 2);
  const pct = ((current - min) / (max - min)) * 100;
  const trackRef = _uR(null);
  const dragging = _uR(false);

  const targetPct = spec.target != null ? ((spec.target - min) / (max - min)) * 100 : null;

  const setFromEvent = (e) => {
    if (locked || !trackRef.current) return;
    const rect = trackRef.current.getBoundingClientRect();
    const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const raw = min + x * (max - min);
    const snapped = Math.round(raw / step) * step;
    onChange(Math.max(min, Math.min(max, snapped)));
  };

  const onMouseDown = (e) => {
    if (locked) return;
    dragging.current = true;
    setFromEvent(e);
    const move = (ev) => { if (dragging.current) setFromEvent(ev); };
    const up = () => { dragging.current = false; window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
    window.addEventListener("mousemove", move);
    window.addEventListener("mouseup", up);
  };

  const formatValue = (v) => {
    if (v >= 1000000) return `~${(v / 1000000).toFixed(1)}M`;
    if (v >= 1000) return `~${Math.round(v / 1000)}K`;
    return `${v}`;
  };

  return (
    <div className="slider">
      <div className="slider-track" ref={trackRef}
        role="slider"
        tabIndex={locked ? -1 : 0}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={current}
        aria-label={`${spec.label || 'Value'}: ${current}${spec.unit ? ' ' + spec.unit : ''}`}
        onMouseDown={onMouseDown}
        onKeyDown={e => {
          if (locked) return;
          if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
            e.preventDefault();
            onChange(Math.min(max, current + step));
          } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
            e.preventDefault();
            onChange(Math.max(min, current - step));
          } else if (e.key === 'Home') {
            e.preventDefault();
            onChange(min);
          } else if (e.key === 'End') {
            e.preventDefault();
            onChange(max);
          }
        }}
      >
        {targetPct != null && <span className="range" style={{ left: `${Math.min(pct, targetPct)}%`, right: `${100 - Math.max(pct, targetPct)}%` }} />}
        {graded && targetPct != null && <span className="target" style={{ left: `${targetPct}%` }} />}
        <span className="thumb" style={{ left: `${pct}%` }} />
      </div>
      <div className="slider-ticks">
        {(spec.ticks || []).map((t, i) => <span key={i}>{t}</span>)}
      </div>
      <div className="slider-value">
        {formatValue(current)}<small>{spec.unit || ""}</small>
      </div>
    </div>
  );
}

/* =========================================================================
   11 · BUZZER — big red button, fastest first
   ========================================================================= */
function BuzzerRenderer({ spec, value, onChange, locked, graded }) {
  // value = timestamp when buzzed, or null
  const [pressed, setPressed] = _uS(false);
  const [latency, setLatency] = _uS(null);

  const buzz = () => {
    if (locked || value != null) return;
    const ts = Date.now();
    setPressed(true);
    setLatency(Math.floor(Math.random() * 80 + 20)); // simulated latency
    onChange(ts);
    setTimeout(() => setPressed(false), 200);
  };

  _uE(() => {
    if (locked || value != null) return;
    const handler = (e) => { if (e.code === "Space") { e.preventDefault(); buzz(); } };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [locked, value]);

  const standings = spec.demoStandings || [];

  return (
    <div className="buzzer">
      <button className={`buzz-btn${pressed ? " pressed" : ""}`} onClick={buzz} disabled={value != null}>
        {value != null ? "BUZZED" : "BUZZ"}
      </button>
      <div className="buzz-meta">
        <span>HOLD SPACE</span>
        <span>latency · {latency != null ? `${latency} ms` : "—"}</span>
      </div>
      {standings.length > 0 && (
        <div className="buzz-standings">
          {standings.map((s, i) => (
            <div key={i} className={`buzz-row${i === 0 ? " first" : ""}`}>
              <span>{s.rank}</span>
              <span>{s.name}</span>
              <span className="t">{s.time}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

/* =========================================================================
   12 · STRUCTURED FORM / BOM — editable table
   ========================================================================= */
function StructuredFormRenderer({ spec, value, onChange, locked, graded }) {
  // value = [{ sku, desc, qty, license }, ...]
  const columns = spec.columns || [];
  const rows = value || [...(spec.prefilled || [])];

  const updateRow = (idx, colId, val) => {
    if (locked) return;
    const next = rows.map((r, i) => i === idx ? { ...r, [colId]: val } : r);
    onChange(next);
  };

  const addRow = () => {
    if (locked) return;
    const empty = {};
    columns.forEach(c => { empty[c.id] = c.type === "number" ? 0 : ""; });
    onChange([...rows, empty]);
  };

  const removeRow = (idx) => {
    if (locked) return;
    onChange(rows.filter((_, i) => i !== idx));
  };

  const required = spec.required || [];
  const missingSkus = required.filter(r => {
    if (!r.sku) return false;
    const pattern = r.sku.replace("*", "");
    return !rows.some(row => row.sku && row.sku.includes(pattern));
  });

  return (
    <div style={{ overflowX: "auto" }}>
      <table className="bom">
        <thead>
          <tr>
            {columns.map(c => <th key={c.id}>{c.label}</th>)}
            {!locked && <th style={{ width: 40 }}></th>}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, idx) => (
            <tr key={idx}>
              {columns.map(c => (
                <td key={c.id}>
                  {locked ? (
                    c.id === "sku" ? <code>{row[c.id]}</code> : row[c.id]
                  ) : (
                    <input
                      type={c.type === "number" ? "number" : "text"}
                      value={row[c.id] ?? ""}
                      onChange={e => updateRow(idx, c.id, c.type === "number" ? Number(e.target.value) : e.target.value)}
                      placeholder={c.label}
                    />
                  )}
                </td>
              ))}
              {!locked && (
                <td
                  style={{ textAlign: "center", cursor: "pointer", color: "var(--danger)", fontSize: 14 }}
                  role="button"
                  tabIndex={0}
                  aria-label={`Remove row ${idx + 1}`}
                  onClick={() => removeRow(idx)}
                  onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ' || e.key === 'Delete') { e.preventDefault(); removeRow(idx); } }}
                >
                  ×
                </td>
              )}
            </tr>
          ))}
          {missingSkus.map((r, i) => (
            <tr key={`miss-${i}`} className="miss">
              {columns.map((c, ci) => (
                <td key={c.id}>{ci === 0 ? <code>?</code> : (r[c.id] || '—')}</td>
              ))}
              {!locked && <td></td>}
            </tr>
          ))}
          {!locked && (
            <tr>
              <td className="add" colSpan={columns.length + 1}
                role="button"
                tabIndex={0}
                aria-label="Add a new row"
                onClick={addRow}
                onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); addRow(); } }}
              >
                ＋ add row
              </td>
            </tr>
          )}
        </tbody>
      </table>
    </div>
  );
}


/* =========================================================================
   RENDERER REGISTRY
   Maps type string → { Component, isValid, getDefaultValue }
   ========================================================================= */
window.RENDERERS = {
  single_select: {
    Component: SingleSelectRenderer,
    isValid: (v) => v != null,
    getDefaultValue: () => null,
  },
  multi_select: {
    Component: MultiSelectRenderer,
    isValid: (v, spec) => {
      const min = spec.minPick || 1;
      return Array.isArray(v) && v.length >= min;
    },
    getDefaultValue: () => [],
  },
  true_false: {
    Component: TrueFalseRenderer,
    isValid: (v) => v != null,
    getDefaultValue: () => null,
  },
  free_text: {
    Component: FreeTextRenderer,
    isValid: (v) => typeof v === "string" && v.trim().length >= 1,
    getDefaultValue: () => "",
  },
  drag_match: {
    Component: DragMatchRenderer,
    isValid: (v, spec) => {
      if (!v) return false;
      const filled = Object.values(v).filter(x => x != null).length;
      return filled >= (spec.pairs || []).length;
    },
    getDefaultValue: () => ({}),
  },
  group_sort: {
    Component: GroupSortRenderer,
    isValid: (v, spec) => {
      if (!v) return false;
      const placed = Object.values(v).flat().length;
      return placed >= (spec.items || []).length;
    },
    getDefaultValue: () => ({}),
  },
  rank_order: {
    Component: RankOrderRenderer,
    isValid: (v) => Array.isArray(v) && v.length > 0,
    getDefaultValue: (spec) => (spec.items || []).map(i => i.id),
  },
  cascade: {
    Component: CascadeRenderer,
    isValid: (v, spec) => {
      if (!v) return false;
      return (spec.steps || []).every(s => v[s.id] != null);
    },
    getDefaultValue: () => ({}),
  },
  hotspot: {
    Component: HotspotRenderer,
    isValid: (v) => Array.isArray(v) && v.length > 0,
    getDefaultValue: () => [],
  },
  numeric_slider: {
    Component: NumericSliderRenderer,
    isValid: (v) => v != null,
    getDefaultValue: (spec) => Math.round(((spec.min || 0) + (spec.max || 100)) / 2),
  },
  buzzer: {
    Component: BuzzerRenderer,
    isValid: (v) => v != null,
    getDefaultValue: () => null,
  },
  structured_form: {
    Component: StructuredFormRenderer,
    isValid: (v, spec) => {
      if (!Array.isArray(v)) return false;
      const minRows = spec.minRows || 1;
      return v.length >= minRows && v.some(r => {
        const cols = spec.columns || [];
        return cols.filter(c => c.required).every(c => r[c.id] != null && r[c.id] !== "");
      });
    },
    getDefaultValue: (spec) => [...(spec.prefilled || [])],
  },
};

/* Export components to window for gallery page */
window.SingleSelectRenderer = SingleSelectRenderer;
window.MultiSelectRenderer = MultiSelectRenderer;
window.TrueFalseRenderer = TrueFalseRenderer;
window.FreeTextRenderer = FreeTextRenderer;
window.DragMatchRenderer = DragMatchRenderer;
window.GroupSortRenderer = GroupSortRenderer;
window.RankOrderRenderer = RankOrderRenderer;
window.CascadeRenderer = CascadeRenderer;
window.HotspotRenderer = HotspotRenderer;
window.NumericSliderRenderer = NumericSliderRenderer;
window.BuzzerRenderer = BuzzerRenderer;
window.StructuredFormRenderer = StructuredFormRenderer;
