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

const STORE = window.V3SessionStore;
const CONTENT = window.V3PrototypeContent;
const MODEL = window.V3ScenarioModel;
const GAME_MODEL = window.V3GameModel || null;

const SELECTABLE_SCENARIO_IDS = [
  "solar-demo-v1",
  "leo-pass-demo-v1",
  "rfi-scan-demo-v1",
];

const FALLBACK_V3_GUIDES = {
  "solar-demo-v1": {
    title: "太阳信号罗盘",
    discovery: {
      label: "大图发现",
      visual: "光圈对准 + 整体高低",
      action: "孩子先指方向，再在曲线整体升高处留星。",
      evidence: "看整体高低，不追单个尖峰。",
    },
    detective: {
      label: "证据侦探",
      visual: "同轴双曲线 + 平均位置",
      action: "让孩子比较背景与太阳方向的同尺度曲线。",
      evidence: "区分整体更强与偶然最高点。",
    },
    replay: "回放只重画本轮固定模拟曲线和儿童证据点，不会再次触发模拟转向。",
    boundary: "只能说明这组模拟里太阳方向整体更强。",
  },
  "leo-pass-demo-v1": {
    title: "追星三拍",
    discovery: {
      label: "轨迹发现",
      visual: "轨迹弧线 + 出现·最强·离开",
      action: "孩子看卫星移动，在三个关键时刻留下证据星。",
      evidence: "把证据按时间顺序讲成过境故事。",
    },
    detective: {
      label: "时序侦探",
      visual: "时间轴包络 + 证据窗口",
      action: "比较过境前、最强附近与离开后的信号。",
      evidence: "功率包络支持‘短暂过境’，不支持确认卫星身份。",
    },
    replay: "若孩子错过关键时刻，使用证据回放重看包络；不以速度或命中率评分。",
    boundary: "信号先强再弱不等于已经识别了具体卫星。",
  },
  "rfi-scan-demo-v1": {
    title: "L 波段找不同",
    discovery: {
      label: "频谱探照灯",
      visual: "安静/活动色块 + 窄峰",
      action: "孩子在模拟频谱中找到变亮的一段或窄尖峰。",
      evidence: "先说看到的形状，暂不猜来源。",
    },
    detective: {
      label: "A/B/A 侦探",
      visual: "安静·活动·安静 + 频宽/同步",
      action: "重复比较活动时间窗与频谱局部增强是否同步。",
      evidence: "把‘同时变强’与‘已证明来源’分开。",
    },
    replay: "回放只展示频率、时间、强弱与儿童标记，不解调、不播放通信内容。",
    boundary: "功率谱可提示相关性，不能仅凭一张图确认某台设备。",
  },
};

function safeGameModelCall(name, ...args) {
  if (!GAME_MODEL || typeof GAME_MODEL[name] !== "function") return null;
  try {
    return GAME_MODEL[name](...args);
  } catch (error) {
    return null;
  }
}

function getV3GameBrief(state, scenario) {
  const ageBand = state.config.ageBand === "9-12" ? "9-12" : "6-8";
  const fallbackMode = ageBand === "9-12" ? "detective" : "discovery";
  const requestedMode = state.visualization && state.visualization.mode;
  const modelDefault = safeGameModelCall("getDefaultVisualMode", ageBand);
  const mode = requestedMode || modelDefault || fallbackMode;
  const fallback = FALLBACK_V3_GUIDES[scenario.id] ||
    FALLBACK_V3_GUIDES["solar-demo-v1"];
  const fallbackModeGuide = fallback[mode] || fallback[fallbackMode];
  const config = safeGameModelCall("getTaskVisualConfig", scenario.id, mode) ||
    {};
  const view = config.view && typeof config.view === "object"
    ? config.view
    : {};
  const modelRoles = safeGameModelCall("getRoleOptions", scenario.id);
  const scenarioRoles = Array.isArray(scenario.roles) ? scenario.roles : [];
  const roleIds = [
    "navigator",
    "signal_detective",
    "evidence_keeper",
    "storyteller",
  ];
  const roles =
    (Array.isArray(modelRoles) && modelRoles.length
      ? modelRoles
      : scenarioRoles).map((role, index) => ({
        id: role.id || roleIds[index] || `role_${index + 1}`,
        name: role.name || `角色 ${index + 1}`,
        detail: role.detail || "跟随老师完成合作观测",
        asset: role.asset ||
          (scenarioRoles[index] && scenarioRoles[index].asset) ||
          scenario.targetAsset,
      }));
  const modeGuide = config.modeGuide || config.guide || view;
  const boundaryKey =
    scenario.kind === "rfi_scan" || scenario.kind === "satellite_pass"
      ? "identity"
      : "comparison";
  const scienceBoundary = safeGameModelCall(
    "getScienceBoundary",
    scenario.id,
    boundaryKey,
  );

  return {
    title: config.title || config.gameTitle || fallback.title,
    mode,
    modeLabel: config.modeLabel || modeGuide.label || fallbackModeGuide.label,
    visual: config.visual || config.visualLanguage || modeGuide.instruction ||
      modeGuide.visual || fallbackModeGuide.visual,
    action: config.action || config.childAction || config.missionPrompt ||
      modeGuide.action || fallbackModeGuide.action,
    evidence: config.evidence || config.evidenceFocus || modeGuide.evidence ||
      fallbackModeGuide.evidence,
    replay: config.replay || config.replayCue || fallback.replay,
    boundary: typeof scienceBoundary === "string"
      ? scienceBoundary
      : scienceBoundary && (scienceBoundary.summary || scienceBoundary.teacher)
      ? scienceBoundary.summary || scienceBoundary.teacher
      : fallback.boundary,
    roles,
    ageBand,
    modelAvailable: Boolean(GAME_MODEL),
  };
}

function describeV3Marker(scenario, marker, index) {
  const described = safeGameModelCall(
    "describeMarker",
    scenario.id,
    marker,
    index,
  );
  if (typeof described === "string" && described) return described;
  if (described && typeof described === "object") {
    return described.teacher || described.label || described.summary ||
      marker.label;
  }
  return marker.label || `证据标记 ${index + 1}`;
}

const RUN_LABELS = {
  idle: "等待开始",
  active: "教学进行中",
  paused: "安全暂停",
  estop: "急停锁定",
  complete: "本轮完成",
};

const CAPTURE_STEPS = [
  {
    status: "scanning",
    label: "扫描瞄准",
    roleId: "navigator",
    cue: "看到值得研究的变化，就把扫描停下来",
  },
  {
    status: "frozen",
    label: "信号冻结",
    roleId: "signal_detective",
    cue: "选一张形状卡，只说看到什么，暂不猜来源",
  },
  {
    status: "reasoned",
    label: "理由已选",
    roleId: "evidence_keeper",
    cue: "把频率、强弱和形状理由一起锁上去",
  },
  {
    status: "locked",
    label: "星星锁定",
    roleId: "storyteller",
    cue: "邀请同伴回应，再完成这颗证据星",
  },
  {
    status: "confirm",
    label: "小队确认",
    roleId: "storyteller",
    cue: "证据星已保存，提醒大家：它仍不能自动证明来源",
  },
];

function normalizedCapture(state) {
  const capture = state && state.capture && typeof state.capture === "object"
    ? state.capture
    : {};
  const status = CAPTURE_STEPS.some((step) => step.status === capture.status)
    ? capture.status
    : "scanning";
  return { ...capture, status };
}

function captureFeedbackText(capture) {
  const feedback = capture && capture.feedback;
  if (typeof feedback === "string" && feedback.trim()) return feedback;
  if (feedback && typeof feedback === "object") {
    return feedback.message || feedback.label || feedback.text ||
      "等待孩子继续";
  }
  return "等待孩子继续";
}

function captureReasonLabel(scenario, capture) {
  if (!capture || !capture.selectedReasonId) return "尚未选择";
  const reason = safeGameModelCall(
    "getEvidenceReason",
    scenario.id,
    capture.selectedReasonId,
  );
  return reason && reason.label ? reason.label : capture.selectedReasonId;
}

function markerReasonLabel(marker) {
  return marker && typeof marker.reasonLabel === "string" && marker.reasonLabel
    ? marker.reasonLabel
    : "未记录形状理由";
}

function markerSourceBoundary(marker) {
  return marker && marker.sourceCertainty === "unconfirmed"
    ? "来源待查"
    : "来源未标注";
}

const PREFLIGHT_ITEMS = [
  {
    key: "zoneClear",
    title: "运动区域无人和障碍物",
    detail: "查看真实设备周围，不以屏幕画面代替现场确认。",
  },
  {
    key: "childrenBehindLine",
    title: "孩子全部位于安全线后",
    detail: "由老师确认人数与站位，孩子不靠近机械结构。",
  },
  {
    key: "routeReviewed",
    title: "老师已说明转向方向",
    detail: "先指给孩子看设备会朝哪一边运动。",
  },
  {
    key: "mockModeAcknowledged",
    title: "确认当前仅为教学模拟",
    detail: "本原型不连接硬件，也不显示历史或实时数据。",
  },
];

function useSessionState() {
  const [state, setState] = useState(() => STORE.getState());

  useEffect(() => STORE.subscribe(setState), []);
  return state;
}

function getActiveScenario(state) {
  return MODEL.getScenario(state.runScenarioId || state.config.scenarioId);
}

function getScenarioPhaseCopy(state, phase = state.phase) {
  const scenario = getActiveScenario(state);
  return MODEL.getPhaseCopy(scenario.id, phase) || CONTENT.phases[phase];
}

function displayAnswer(state, kind, value) {
  if (!value) return "等待儿童屏提交";
  const scenario = getActiveScenario(state);
  return MODEL.getAnswerLabel(scenario.id, kind, state.config.ageBand, value) ||
    value;
}

function getPreflightItems(state) {
  const scenario = getActiveScenario(state);
  return PREFLIGHT_ITEMS.map((item) => {
    if (item.key !== "routeReviewed") return item;
    if (scenario.preflightRoute) {
      return {
        ...item,
        title: scenario.preflightRoute.title,
        detail: scenario.preflightRoute.detail,
      };
    }
    return {
      ...item,
      title: `老师已说明${scenario.targetName}的观测路径`,
      detail:
        `先向孩子指出${scenario.targetName}从哪里出现，以及模拟指向会怎样移动。`,
    };
  });
}

function getMotionCopy(scenario) {
  return {
    modeLabel: scenario.motion && scenario.motion.modeLabel
      ? scenario.motion.modeLabel
      : "模拟转向",
    progressLabel: scenario.motion && scenario.motion.progressLabel
      ? scenario.motion.progressLabel
      : "模拟转向",
    holdStart: scenario.motion && scenario.motion.holdStart
      ? scenario.motion.holdStart
      : `按住 0.6 秒后寻找${scenario.targetName}`,
    holding: scenario.motion && scenario.motion.holding
      ? scenario.motion.holding
      : `正在模拟指向${scenario.targetName} · 保持按住`,
    arrived: scenario.motion && scenario.motion.arrived
      ? scenario.motion.arrived
      : `已到达${scenario.targetName}观测位置 · 等待老师推进`,
    dockWaiting: scenario.motion && scenario.motion.dockWaiting
      ? scenario.motion.dockWaiting
      : `请在上方持续按住寻找${scenario.targetName}`,
    dockArrived: scenario.motion && scenario.motion.dockArrived
      ? scenario.motion.dockArrived
      : `确认到达${scenario.targetName} · 开始采集`,
    gateArrived: scenario.motion && scenario.motion.gateArrived
      ? scenario.motion.gateArrived
      : "已抵达，但不会自动进入采集",
  };
}

function observeEvidenceReady(scenario, markers) {
  const required = Math.max(1, Number(scenario.observe.minMarkers) || 1);
  if (markers.length < required) return false;
  const separation = Math.max(
    0,
    Number(scenario.observe.minMarkerSeparation) || 0,
  );
  if (!separation) return true;
  const sorted = markers.map((marker) => Number(marker.sampleT)).filter(
    Number.isFinite,
  ).sort((a, b) => a - b);
  return sorted.filter((sampleT, index) =>
    index === 0 || sampleT - sorted[index - 1] >= separation
  ).length >= required;
}

function formatTime(timestamp) {
  if (!timestamp) return "—";
  return new Intl.DateTimeFormat("zh-CN", {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
  }).format(new Date(timestamp));
}

function phaseIndex(phase) {
  return Math.max(0, CONTENT.phaseOrder.indexOf(phase));
}

function preflightComplete(state) {
  return PREFLIGHT_ITEMS.every((item) =>
    state.safety.preflight.checks[item.key]
  );
}

function isAuthorizationValid(state, now) {
  return Boolean(
    state.safety.preflight.confirmedAt &&
      state.safety.preflight.validUntil &&
      state.safety.preflight.validUntil > now,
  );
}

function currentChildSignal(state) {
  const scenario = getActiveScenario(state);
  switch (state.phase) {
    case "prep":
      return `儿童屏正在等候「${scenario.taskTitle}」观测小队`;
    case "welcome":
      return state.responses.teamReady
        ? "观测小队已确认就绪"
        : "等待团队确认就绪";
    case "predict":
      return displayAnswer(state, "prediction", state.responses.prediction);
    case "preflight":
      return `儿童屏只读显示${scenario.targetName}任务的预检进度`;
    case "slew": {
      const motion = getMotionCopy(scenario);
      return state.movement.arrived
        ? `儿童屏显示：${motion.arrived}`
        : `儿童屏显示：${motion.progressLabel} · ${
          Math.round(state.movement.progress)
        }%`;
    }
    case "observe": {
      const capture = normalizedCapture(state);
      const step =
        CAPTURE_STEPS.find((item) => item.status === capture.status) ||
        CAPTURE_STEPS[0];
      return `${scenario.observe.signalLabel} · ${step.label} · 已保存 ${state.responses.markers.length} 个儿童证据标记`;
    }
    case "compare":
      return displayAnswer(state, "comparison", state.responses.comparison);
    case "conclude":
      return displayAnswer(state, "conclusion", state.responses.conclusion);
    case "result":
      return `儿童屏正在展示：${scenario.result.finding}`;
    case "complete":
      return `儿童屏正在感谢完成${scenario.shortLabel}的小小射电员`;
    default:
      return "双屏已同步";
  }
}

function useStrictHold(state, now) {
  const [pressStage, setPressStage] = useState("idle");
  const stateRef = useRef(state);
  const armTimerRef = useRef(null);
  const tickTimerRef = useRef(null);
  const startedRef = useRef(false);
  const pointerIdRef = useRef(null);

  stateRef.current = state;

  const clearTimers = useCallback(() => {
    if (armTimerRef.current) window.clearTimeout(armTimerRef.current);
    if (tickTimerRef.current) window.clearInterval(tickTimerRef.current);
    armTimerRef.current = null;
    tickTimerRef.current = null;
  }, []);

  const endHold = useCallback(
    (kind) => {
      const hadStarted = startedRef.current;
      startedRef.current = false;
      pointerIdRef.current = null;
      clearTimers();
      setPressStage("idle");
      if (hadStarted) {
        STORE.dispatch({
          type: "HOLD_END",
          payload: { kind },
          actor: "teacher",
        });
      }
    },
    [clearTimers],
  );

  const beginAfterIntentDelay = useCallback(() => {
    const latest = stateRef.current;
    const authorizationValid = isAuthorizationValid(latest, Date.now());
    if (
      latest.phase !== "slew" ||
      latest.runState !== "active" ||
      latest.movement.arrived ||
      !authorizationValid
    ) {
      setPressStage("idle");
      pointerIdRef.current = null;
      return;
    }

    const next = STORE.dispatch({ type: "HOLD_START", actor: "teacher" });
    if (!next.movement.holdActive) {
      setPressStage("idle");
      pointerIdRef.current = null;
      return;
    }

    startedRef.current = true;
    setPressStage("holding");
    const startedAt = performance.now();
    const startProgress = next.movement.progress;
    const remainingDuration = Math.max(900, (100 - startProgress) * 36);

    tickTimerRef.current = window.setInterval(() => {
      if (!startedRef.current) return;
      const elapsed = performance.now() - startedAt;
      const progress = Math.min(
        100,
        startProgress + (elapsed / remainingDuration) * (100 - startProgress),
      );
      STORE.dispatch({
        type: "HOLD_TICK",
        payload: { progress },
        actor: "teacher",
      });
      if (progress >= 100) {
        startedRef.current = false;
        clearTimers();
        pointerIdRef.current = null;
        setPressStage("arrived");
      }
    }, 80);
  }, [clearTimers]);

  const armHold = useCallback(
    (pointerId) => {
      if (pressStage !== "idle" || startedRef.current || armTimerRef.current) {
        return;
      }
      const latest = stateRef.current;
      if (
        latest.phase !== "slew" ||
        latest.runState !== "active" ||
        latest.movement.arrived ||
        !isAuthorizationValid(latest, Date.now())
      ) {
        return;
      }
      pointerIdRef.current = pointerId;
      setPressStage("arming");
      armTimerRef.current = window.setTimeout(() => {
        armTimerRef.current = null;
        beginAfterIntentDelay();
      }, 600);
    },
    [beginAfterIntentDelay, pressStage],
  );

  useEffect(() => {
    const onBlur = () => endHold("window-blur");
    const onVisibilityChange = () => {
      if (document.visibilityState === "hidden") endHold("page-hidden");
    };
    window.addEventListener("blur", onBlur);
    document.addEventListener("visibilitychange", onVisibilityChange);
    return () => {
      window.removeEventListener("blur", onBlur);
      document.removeEventListener("visibilitychange", onVisibilityChange);
      clearTimers();
    };
  }, [clearTimers, endHold]);

  useEffect(() => {
    if (
      state.phase !== "slew" || state.runState !== "active" ||
      state.movement.arrived
    ) {
      clearTimers();
      startedRef.current = false;
      pointerIdRef.current = null;
      setPressStage(state.movement.arrived ? "arrived" : "idle");
    }
  }, [state.phase, state.runState, state.movement.arrived, clearTimers]);

  const canHold = state.phase === "slew" &&
    state.runState === "active" &&
    !state.movement.arrived &&
    isAuthorizationValid(state, now);

  return {
    pressStage,
    canHold,
    pointerHandlers: {
      onPointerDown(event) {
        if (!canHold || event.button !== 0) return;
        event.preventDefault();
        try {
          event.currentTarget.setPointerCapture(event.pointerId);
        } catch (error) {
          return;
        }
        armHold(event.pointerId);
      },
      onPointerUp(event) {
        event.preventDefault();
        endHold("released");
        try {
          if (event.currentTarget.hasPointerCapture(event.pointerId)) {
            event.currentTarget.releasePointerCapture(event.pointerId);
          }
        } catch (error) {
          // Lost capture is handled by the same fail-safe path.
        }
      },
      onPointerCancel() {
        endHold("pointer-cancel");
      },
      onLostPointerCapture() {
        endHold("lost-capture");
      },
      onPointerLeave() {
        if (pointerIdRef.current !== null) endHold("pointer-left-button");
      },
      onKeyDown(event) {
        if ((event.key === " " || event.key === "Enter") && !event.repeat) {
          event.preventDefault();
          armHold("keyboard");
        }
      },
      onKeyUp(event) {
        if (event.key === " " || event.key === "Enter") {
          event.preventDefault();
          endHold("key-released");
        }
      },
    },
  };
}

function SafetyBar({ state }) {
  const phase = CONTENT.phases[state.phase];
  const runTone = state.runState === "estop"
    ? "danger"
    : state.runState === "paused"
    ? "warning"
    : "safe";
  const dataIsMock = state.config.dataMode === "fixed_mock";

  return (
    <header className="teacher-safety-bar">
      <div className="teacher-brand">
        <img src="assets/logo.png" alt="宇宙来电项目标志" />
        <div>
          <div className="teacher-brand-title-row">
            <strong>宇宙来电 · 教学导演台</strong>
            <b className="v3-alpha-chip">V3.3 · ALPHA</b>
          </div>
          <span>合作式证据捕捉实验版 · 不连接真实设备</span>
        </div>
      </div>

      <div className="safety-statuses" aria-label="当前安全状态">
        <span className="safety-pill safe">演示隔离</span>
        <span className={`safety-pill ${runTone}`}>
          {RUN_LABELS[state.runState] || state.runState}
        </span>
        <span className="safety-pill warning">{phase.short}</span>
        <span className={`safety-pill ${dataIsMock ? "safe" : "danger"}`}>
          {dataIsMock ? "教学模拟数据" : "未接入的数据模式"}
        </span>
        <span className="safety-pill safe">儿童只做观察与判断</span>
      </div>

      <div className="teacher-safety-actions">
        <button
          type="button"
          className="pause-button"
          disabled={["idle", "paused", "estop", "complete"].includes(
            state.runState,
          )}
          onClick={() =>
            STORE.dispatch({
              type: "PAUSE",
              payload: { reason: "老师从导演台暂停了教学流程" },
              actor: "teacher",
            })}
        >
          安全暂停
        </button>
        <button
          type="button"
          className="estop-button"
          disabled={state.safety.estop.latched}
          onClick={() =>
            STORE.dispatch({
              type: "ESTOP",
              payload: { reason: "老师启动原型急停" },
              actor: "teacher",
            })}
        >
          急停
        </button>
      </div>
    </header>
  );
}

function V3ModeCard({ state, gameBrief }) {
  const modes = [
    {
      id: "discovery",
      label: "发现模式",
      detail: "6–8 岁推荐 · 大图、高低与顺序",
    },
    {
      id: "detective",
      label: "侦探模式",
      detail: "9–12 岁推荐 · 坐标、对照与证据边界",
    },
  ];
  const recommended = state.config.ageBand === "9-12"
    ? "detective"
    : "discovery";
  return (
    <section className="v3-mode-card" aria-label="V3.3 年龄与数据可视化模式">
      <div className="v3-card-head">
        <div>
          <span>V3.3 VISUAL MODE · 数据怎么玩</span>
          <strong>{gameBrief.title} · {gameBrief.modeLabel}</strong>
          <small>
            年龄脚手架决定默认模式；老师可在开始前覆盖，这不会获得任何设备权限。
          </small>
        </div>
        <span className="v3-mode-tag">
          {gameBrief.ageBand === "9-12" ? "9–12 岁" : "6–8 岁"}
        </span>
      </div>
      <div
        className="v3-role-grid"
        role="group"
        aria-label="选择数据可视化模式"
      >
        {modes.map((mode) => {
          const selected = gameBrief.mode === mode.id;
          return (
            <button
              type="button"
              className={`v3-role-card ${selected ? "claimed" : ""}`}
              aria-pressed={selected}
              key={mode.id}
              onClick={() =>
                STORE.dispatch({
                  type: "SET_VISUAL_MODE",
                  payload: { mode: mode.id },
                  actor: "teacher",
                })}
            >
              <span className="check-box" aria-hidden="true">
                {selected ? "✓" : "·"}
              </span>
              <span>
                <strong>{mode.label}</strong>
                <small>{mode.detail}</small>
              </span>
              <em className="v3-role-state">
                {mode.id === recommended
                  ? "年龄推荐"
                  : selected
                  ? "已选"
                  : "可选"}
              </em>
            </button>
          );
        })}
      </div>
      <div className="v3-mode-grid">
        <div>
          <span>儿童看到</span>
          <strong>{gameBrief.visual}</strong>
        </div>
        <div>
          <span>儿童行动</span>
          <strong>{gameBrief.action}</strong>
        </div>
        <div>
          <span>老师把关</span>
          <strong>{gameBrief.boundary}</strong>
        </div>
      </div>
    </section>
  );
}

function CooperativeRolePanel({ state, gameBrief }) {
  const claimed = new Set(
    state.responses && Array.isArray(state.responses.roleClaims)
      ? state.responses.roleClaims
      : [],
  );
  const roles = gameBrief.roles;
  const canAssign = state.phase === "welcome" && state.runState === "active";
  return (
    <section className="v3-role-panel" aria-label="V3.3 合作角色认领状态">
      <div className="v3-card-head">
        <div>
          <span>COOPERATIVE CREW · 四人合作解码</span>
          <strong>{claimed.size} / {roles.length} 个科学工作已认领</strong>
          <small>
            点按由老师代为记录口头分工；角色只影响课堂组织，不解锁设备控制。
          </small>
        </div>
        <span className="v3-mode-tag">{state.config.groupSize} 人小队</span>
      </div>
      <div className="v3-role-grid">
        {roles.map((role) => {
          const isClaimed = claimed.has(role.id);
          return (
            <button
              type="button"
              className={`v3-role-card ${isClaimed ? "claimed" : ""}`}
              aria-pressed={isClaimed}
              disabled={!canAssign || isClaimed}
              key={role.id}
              onClick={() =>
                STORE.dispatch({
                  type: "CLAIM_ROLE",
                  payload: { roleId: role.id },
                  actor: "teacher",
                })}
            >
              <img src={role.asset} alt="" aria-hidden="true" />
              <span>
                <strong>{role.name}</strong>
                <small>{role.detail}</small>
              </span>
              <em className="v3-role-state">
                {isClaimed ? "已认领" : "待认领"}
              </em>
            </button>
          );
        })}
      </div>
      <div className="v3-role-foot">
        <span>
          不足四人时可一人兼任，但团队只生成一份发现卡，不设个人分数或排名。
        </span>
        {claimed.size
          ? (
            <button
              type="button"
              className="teacher-button"
              disabled={!canAssign}
              onClick={() =>
                STORE.dispatch({ type: "CLEAR_ROLE_CLAIMS", actor: "teacher" })}
            >
              重新分配角色
            </button>
          )
          : null}
      </div>
    </section>
  );
}

function V3GameGuide({ state, gameBrief }) {
  const snapshots =
    state.visualization && Array.isArray(state.visualization.evidenceSnapshots)
      ? state.visualization.evidenceSnapshots
      : [];
  const replay = state.visualization && state.visualization.replay;
  const replayReady = Boolean(
    replay && (
      (Array.isArray(replay.frames) && replay.frames.length) ||
      (Array.isArray(replay.markerIds) && replay.markerIds.length) ||
      replay.ready ||
      replay.status === "ready"
    ),
  );
  return (
    <section className="v3-game-guide" aria-label="V3.3 数据可视化玩法导演说明">
      <div className="v3-card-head">
        <div>
          <span>DATA PLAYBOOK · 数据可视化玩法</span>
          <strong>{gameBrief.title} · {gameBrief.modeLabel}</strong>
          <small>先让孩子自己找变化，老师再用数据边界收口。</small>
        </div>
        <span className="v3-mode-tag">{snapshots.length} 个证据快照</span>
      </div>
      <div className="v3-guide-grid">
        <div>
          <span>先看什么</span>
          <strong>{gameBrief.visual}</strong>
        </div>
        <div>
          <span>如何留证据</span>
          <strong>{gameBrief.evidence}</strong>
        </div>
        <div>
          <span>最后怎么说</span>
          <strong>{gameBrief.boundary}</strong>
        </div>
      </div>
      <div className="v3-replay-note">
        <strong>{replayReady ? "证据回放已准备" : "证据回放引导"}</strong>
        <span>：{gameBrief.replay}</span>
      </div>
    </section>
  );
}

function CaptureDirectorPanel({ state, gameBrief, scenario }) {
  const capture = normalizedCapture(state);
  const currentIndex = Math.max(
    0,
    CAPTURE_STEPS.findIndex((step) => step.status === capture.status),
  );
  const currentStep = CAPTURE_STEPS[currentIndex];
  const currentRole = gameBrief.roles.find((role) =>
    role.id === currentStep.roleId
  );
  const reasonLabel = captureReasonLabel(scenario, capture);
  const draftActive = ["frozen", "reasoned", "locked"].includes(capture.status);

  return (
    <section
      className={`v31-capture-director is-${capture.status}`}
      aria-label="V3.3 证据捕捉导演状态"
    >
      <div className="v3-card-head">
        <div>
          <span>V3.3 CAPTURE DIRECTOR · 证据捕捉导演</span>
          <strong>
            {currentStep.label} · {currentRole ? currentRole.name : "小队协作"}
          </strong>
          <small>{currentStep.cue}</small>
        </div>
        <span className="v31-current-role">
          当前交棒：{currentRole ? currentRole.name : "全队"}
        </span>
      </div>

      <div
        className="v31-capture-steps"
        role="list"
        aria-label="五步证据捕捉进度"
      >
        {CAPTURE_STEPS.map((step, index) => {
          const role = gameBrief.roles.find((item) => item.id === step.roleId);
          const stepState = index < currentIndex
            ? "done"
            : index === currentIndex
            ? "current"
            : "pending";
          return (
            <div
              className={`v31-capture-step ${stepState}`}
              role="listitem"
              aria-current={stepState === "current" ? "step" : undefined}
              key={step.status}
            >
              <b>{stepState === "done" ? "✓" : index + 1}</b>
              <span>
                <strong>{step.label}</strong>
                <small>{role ? role.name : "小队"}</small>
              </span>
            </div>
          );
        })}
      </div>

      <div className="v31-capture-readout">
        <div>
          <span>儿童理由</span>
          <strong>{reasonLabel}</strong>
        </div>
        <div>
          <span>当前反馈</span>
          <strong>{captureFeedbackText(capture)}</strong>
        </div>
        <div>
          <span>来源结论</span>
          <strong>待查 · 不自动识别</strong>
        </div>
      </div>

      <div className="v31-capture-boundary">
        <div>
          <strong>不设跳过按钮</strong>
          <span>
            每颗星都要经过形状理由和同伴确认；误触时可取消本次草稿，回到扫描重新发现。
          </span>
        </div>
        {draftActive
          ? (
            <button
              type="button"
              className="teacher-button v31-cancel-capture"
              onClick={() =>
                STORE.dispatch({ type: "CANCEL_CAPTURE", actor: "teacher" })}
            >
              取消本次草稿
            </button>
          )
          : null}
      </div>
    </section>
  );
}

function Timeline({ state }) {
  const activeIndex = phaseIndex(state.phase);
  const scenario = getActiveScenario(state);
  return (
    <aside className="teacher-panel teacher-timeline" aria-label="教学时间线">
      <div className="timeline-head">
        <span>TEACHING RUNBOOK</span>
        <strong>10 步导演流程</strong>
      </div>
      <div className="timeline-list">
        {CONTENT.phaseOrder.map((phaseKey, index) => {
          const baseMeta = CONTENT.phases[phaseKey];
          const scenarioCopy = MODEL.getPhaseCopy(scenario.id, phaseKey) ||
            baseMeta;
          const stateClass = index < activeIndex
            ? "done"
            : index === activeIndex
            ? "current"
            : "";
          return (
            <div
              className={`timeline-step ${stateClass}`}
              key={phaseKey}
              aria-current={index === activeIndex ? "step" : undefined}
            >
              <span className="step-number">
                {String(index + 1).padStart(2, "0")}
              </span>
              <div>
                <strong>{baseMeta.short}</strong>
                <small>{scenarioCopy.teacherTitle}</small>
              </div>
            </div>
          );
        })}
      </div>
      <div className="timeline-foot">
        会话 {state.sessionId.slice(-8)}
        <br />
        同步版本 r{state.revision}
      </div>
    </aside>
  );
}

function SetupWorkspace({ state, gameBrief }) {
  const setConfig = (payload) =>
    STORE.dispatch({ type: "CONFIGURE", payload, actor: "teacher" });
  const scenario = getActiveScenario(state);
  const scenarioOptions = SELECTABLE_SCENARIO_IDS.map((scenarioId) =>
    MODEL.getScenario(scenarioId)
  );
  return (
    <>
      <div
        className="scenario-selector"
        role="group"
        aria-label="选择本轮观测任务"
      >
        {scenarioOptions.map((option) => {
          const selected = state.config.scenarioId === option.id;
          return (
            <button
              type="button"
              className={`scenario-option ${selected ? "selected" : ""}`}
              data-accent={option.accent}
              aria-pressed={selected}
              key={option.id}
              onClick={() => setConfig({ scenarioId: option.id })}
            >
              <span className="scenario-option-art" aria-hidden="true">
                <img src={option.targetAsset} alt="" />
              </span>
              <span className="scenario-option-copy">
                <small>OBSERVATION MISSION · {option.shortLabel}</small>
                <strong>{option.label}</strong>
                <span>{option.taskTitle}</span>
              </span>
              <em>{selected ? "本轮已选择" : "选择任务"}</em>
            </button>
          );
        })}
      </div>
      <div className="setup-grid">
        <div className="field-card">
          <label htmlFor="ageBand">年龄脚手架</label>
          <select
            id="ageBand"
            value={state.config.ageBand}
            onChange={(event) => setConfig({ ageBand: event.target.value })}
          >
            <option value="6-8">6–8 岁 · 发现模式</option>
            <option value="9-12">9–12 岁 · 探究模式</option>
          </select>
        </div>
        <div className="field-card">
          <label htmlFor="pace">课堂节奏</label>
          <select
            id="pace"
            value={state.config.pace}
            onChange={(event) => setConfig({ pace: event.target.value })}
          >
            <option value="quick8">快速体验 · 8 分钟</option>
            <option value="standard12">标准观测 · 12 分钟</option>
          </select>
        </div>
        <div className="field-card">
          <label htmlFor="dataMode">本轮数据来源</label>
          <select
            id="dataMode"
            value={state.config.dataMode}
            onChange={(event) => setConfig({ dataMode: event.target.value })}
          >
            <option value="fixed_mock">教学模拟 · 已启用</option>
            <option value="historical" disabled>真实历史数据 · 尚未接入</option>
            <option value="live_label" disabled>现场实时数据 · 尚未接入</option>
          </select>
        </div>
        <div className="field-card">
          <label htmlFor="groupSize">本组儿童人数</label>
          <input
            id="groupSize"
            type="number"
            min="1"
            max="8"
            value={state.config.groupSize}
            onChange={(event) =>
              setConfig({
                groupSize: Math.max(
                  1,
                  Math.min(8, Number(event.target.value) || 1),
                ),
              })}
          />
        </div>
      </div>
      <V3ModeCard state={state} gameBrief={gameBrief} />
      <div className="phase-brief">
        <span>本轮边界</span>
        <strong>
          {scenario.label} · 孩子参与科学判断，老师掌握所有流程推进
        </strong>
        <p>
          当前使用「{scenario.observe
            .sourceLabel}」。历史数据与实时数据尚未接入；三套任务各自使用独立的固定模拟数据、问题与答案，不能互相改名替代。
        </p>
      </div>
    </>
  );
}

function ResponseWorkspace({ state, gameBrief }) {
  const scenario = getActiveScenario(state);
  if (state.phase === "welcome") {
    return (
      <>
        <div className="response-panel">
          <div className="response-row">
            <span>儿童屏团队确认</span>
            <strong>{state.responses.teamReady ? "已就绪" : "等待确认"}</strong>
          </div>
          <div className="response-row">
            <span>四种合作工作</span>
            <strong>
              {gameBrief.roles.map((role) => role.name).join(" · ")}
            </strong>
          </div>
          <div className="response-row">
            <span>本组人数</span>
            <strong>{state.config.groupSize} 人</strong>
          </div>
        </div>
        <CooperativeRolePanel state={state} gameBrief={gameBrief} />
        <div className="status-note">
          孩子确认团队就绪，老师记录口头认领。角色状态不改变安全门禁，也不会产生设备指令。
        </div>
      </>
    );
  }

  if (state.phase === "predict") {
    return (
      <>
        <div className="response-panel">
          <div className="response-row">
            <span>儿童预测</span>
            <strong>
              {displayAnswer(state, "prediction", state.responses.prediction)}
            </strong>
          </div>
          <div className="response-row">
            <span>年龄脚手架</span>
            <strong>{CONTENT.ageModes[state.config.ageBand].label}</strong>
          </div>
          <div className="response-row">
            <span>导演提醒</span>
            <strong>先追问理由，再进入预检</strong>
          </div>
        </div>
        <div className="status-note">
          儿童提交预测后不会自动翻页。老师确认已经听到理由，再使用底部主操作推进。
        </div>
      </>
    );
  }

  return null;
}

function PreflightWorkspace({ state, now }) {
  const scenario = getActiveScenario(state);
  const authorizationNoun = scenario.kind === "rfi_scan"
    ? "模拟扫描"
    : "模拟运动";
  const allChecked = preflightComplete(state);
  const authorizationValid = isAuthorizationValid(state, now);
  const remaining = authorizationValid
    ? Math.max(0, Math.ceil((state.safety.preflight.validUntil - now) / 1000))
    : 0;
  const preflightItems = getPreflightItems(state);

  return (
    <>
      <div className="checklist">
        {preflightItems.map((item, index) => {
          const checked = Boolean(state.safety.preflight.checks[item.key]);
          return (
            <button
              type="button"
              className={`check-item ${checked ? "checked" : ""}`}
              key={item.key}
              aria-pressed={checked}
              onClick={() =>
                STORE.dispatch({
                  type: "SET_PREFLIGHT_CHECK",
                  payload: { key: item.key, value: !checked },
                  actor: "teacher",
                })}
            >
              <span className="check-box">{checked ? "✓" : index + 1}</span>
              <span>
                <strong>{item.title}</strong>
                <small>{item.detail}</small>
              </span>
              <em>{checked ? "已确认" : "待检查"}</em>
            </button>
          );
        })}
      </div>

      <div className="authorization-card">
        <strong>
          {authorizationValid
            ? `${authorizationNoun}授权有效 · ${remaining} 秒`
            : state.safety.preflight.confirmedAt
            ? "授权已过期 · 请重新确认"
            : allChecked
            ? "四项已完成 · 等待老师确认授权"
            : `尚未获得${authorizationNoun}授权`}
        </strong>
        <span>
          授权只在全部项目确认后生成，有效 30
          秒。暂停、急停、刷新或按压中断都会清除授权并返回预检。
        </span>
      </div>
    </>
  );
}

function SlewWorkspace({ state, now, hold }) {
  const scenario = getActiveScenario(state);
  const motion = getMotionCopy(scenario);
  const authorizationValid = isAuthorizationValid(state, now);
  const remaining = authorizationValid
    ? Math.max(0, Math.ceil((state.safety.preflight.validUntil - now) / 1000))
    : 0;
  let holdLabel = motion.holdStart;
  if (hold.pressStage === "arming") holdLabel = "继续按住 · 正在确认操作意图";
  if (hold.pressStage === "holding" || state.movement.holdActive) {
    holdLabel = motion.holding;
  }
  if (state.movement.arrived) holdLabel = motion.arrived;
  if (!authorizationValid && !state.movement.arrived) {
    holdLabel = "授权已失效 · 请返回重新预检";
  }

  return (
    <>
      <div className="hold-panel">
        <div className="hold-summary">
          <div>
            <span>模式</span>
            <strong>{motion.modeLabel}</strong>
          </div>
          <div>
            <span>授权</span>
            <strong>
              {authorizationValid ? `${remaining} 秒` : "无有效授权"}
            </strong>
          </div>
          <div>
            <span>进度</span>
            <strong>{Math.round(state.movement.progress)}%</strong>
          </div>
        </div>
        <div
          className="hold-progress"
          aria-label={`${motion.progressLabel}进度 ${
            Math.round(state.movement.progress)
          }%`}
        >
          <span style={{ "--hold-width": `${state.movement.progress}%` }} />
        </div>
        <button
          type="button"
          className={`hold-button ${
            hold.pressStage === "holding" || state.movement.holdActive
              ? "holding"
              : ""
          }`}
          disabled={!hold.canHold}
          aria-label={holdLabel}
          {...hold.pointerHandlers}
        >
          {holdLabel}
        </button>
      </div>
      <div className="status-note">
        {scenario.label} · {scenario.kind === "rfi_scan"
          ? "这不是接收机或云台控制"
          : "这不是设备控制"}：600 毫秒用于过滤误触；开始后约 3.6 秒完成{motion
          .progressLabel}。松手、取消指针捕获、移出按钮、窗口失焦或页面隐藏都会结束按压并锁定安全暂停。
      </div>
    </>
  );
}

function EvidenceWorkspace({ state, gameBrief }) {
  const scenario = getActiveScenario(state);
  if (state.phase === "observe") {
    const requiredMarkers = Math.max(
      1,
      Number(scenario.observe.minMarkers) || 1,
    );
    return (
      <>
        <CaptureDirectorPanel
          state={state}
          gameBrief={gameBrief}
          scenario={scenario}
        />
        <div className="response-panel">
          <div className="response-row">
            <span>数据来源</span>
            <strong>{scenario.observe.sourceLabel}</strong>
          </div>
          <div className="response-row">
            <span>当前儿童信号</span>
            <strong>{scenario.observe.signalLabel}</strong>
          </div>
          <div className="response-row">
            <span>儿童证据标记</span>
            <strong>{state.responses.markers.length} / 3</strong>
          </div>
          {state.responses.markers.map((marker, index) => (
            <div className="response-row" key={marker.id}>
              <span>证据星 {index + 1}</span>
              <strong>
                {describeV3Marker(scenario, marker, index)}{" "}
                · 理由：{markerReasonLabel(marker)} ·{" "}
                {markerSourceBoundary(marker)} · {formatTime(marker.at)}
              </strong>
            </div>
          ))}
        </div>
        {scenario.kind === "rfi_scan"
          ? (
            <div className="reference-band-list" aria-label="教师候选频段参考">
              {scenario.observe.referenceBands.map((band) => (
                <div key={band.label}>
                  <span>{band.range}</span>
                  <strong>{band.label}</strong>
                  <small>{band.note}</small>
                </div>
              ))}
            </div>
          )
          : null}
        <div className="status-note">
          数据标签始终是“教学模拟”。星星只记录位置、强弱、形状理由与小队确认，信号来源始终待查。至少在不同位置保存
          {" "}
          {requiredMarkers} 个儿童标记后，老师才可进入比较。
        </div>
        <V3GameGuide state={state} gameBrief={gameBrief} />
      </>
    );
  }

  if (state.phase === "compare") {
    return (
      <>
        <div className="response-panel">
          <div className="response-row">
            <span>儿童比较结论</span>
            <strong>
              {displayAnswer(state, "comparison", state.responses.comparison)}
            </strong>
          </div>
          <div className="response-row">
            <span>比较对象</span>
            <strong>
              {scenario.compare.background.label} ×{" "}
              {scenario.compare.target.label}
            </strong>
          </div>
          <div className="response-row">
            <span>比较前提</span>
            <strong>同一纵轴尺度 · {scenario.observe.sourceLabel}</strong>
          </div>
          <div className="response-row">
            <span>保留证据</span>
            <strong>{state.responses.markers.length} 个标记</strong>
          </div>
        </div>
        <V3GameGuide state={state} gameBrief={gameBrief} />
      </>
    );
  }

  if (state.phase === "conclude") {
    return (
      <>
        <div className="response-panel">
          <div className="response-row">
            <span>最初预测</span>
            <strong>
              {displayAnswer(state, "prediction", state.responses.prediction)}
            </strong>
          </div>
          <div className="response-row">
            <span>数据比较</span>
            <strong>
              {displayAnswer(state, "comparison", state.responses.comparison)}
            </strong>
          </div>
          <div className="response-row">
            <span>儿童证据结论</span>
            <strong>
              {displayAnswer(state, "conclusion", state.responses.conclusion)}
            </strong>
          </div>
        </div>
        <V3GameGuide state={state} gameBrief={gameBrief} />
      </>
    );
  }

  return null;
}

function ResultWorkspace({ state }) {
  const scenario = getActiveScenario(state);
  const rows = [
    ["观测任务", scenario.label],
    ["数据来源", scenario.result.sourceLabel],
    [
      "团队预测",
      displayAnswer(state, "prediction", state.responses.prediction),
    ],
    [scenario.result.evidenceLabel, `${state.responses.markers.length} 个`],
    [
      "曲线比较",
      displayAnswer(state, "comparison", state.responses.comparison),
    ],
    [
      "证据结论",
      displayAnswer(state, "conclusion", state.responses.conclusion),
    ],
    ["本轮发现", scenario.result.finding],
  ];
  return (
    <>
      <div className="response-panel">
        {rows.map(([label, value]) => (
          <div className="response-row" key={label}>
            <span>{label}</span>
            <strong>{value}</strong>
          </div>
        ))}
      </div>
      <div className="phase-brief">
        <span>教师审核出口</span>
        <strong>先核对来源标签，再向全组展示成果</strong>
        <p>
          成果页可以打印一张不含儿童个人信息的 A4 今日发现卡；请先等待三张资料卡完成加载，再由老师操作打印。
        </p>
      </div>
    </>
  );
}

function CompleteWorkspace({ state }) {
  const scenario = getActiveScenario(state);
  const motion = getMotionCopy(scenario);
  return (
    <>
      <div className="phase-brief">
        <span>本轮已结束</span>
        <strong>{scenario.label}已完成，儿童屏进入感谢与待命画面</strong>
        <p>
          本轮保存了 {state.responses.markers.length} 个「{scenario.observe
            .markerLabel}」。重置只会清空这个前端原型会话，不会向任何设备发送命令。
        </p>
      </div>
      <div className="response-panel">
        <div className="response-row">
          <span>运行状态</span>
          <strong>本轮完成</strong>
        </div>
        <div className="response-row">
          <span>{motion.progressLabel}</span>
          <strong>已结束</strong>
        </div>
        <div className="response-row">
          <span>下一步</span>
          <strong>确认现场后，由老师重置</strong>
        </div>
      </div>
    </>
  );
}

function Workspace({ state, now, hold, gameBrief }) {
  const meta = getScenarioPhaseCopy(state);
  const baseMeta = CONTENT.phases[state.phase];
  let content = null;
  if (state.phase === "prep") {
    content = <SetupWorkspace state={state} gameBrief={gameBrief} />;
  }
  if (["welcome", "predict"].includes(state.phase)) {
    content = <ResponseWorkspace state={state} gameBrief={gameBrief} />;
  }
  if (state.phase === "preflight") {
    content = <PreflightWorkspace state={state} now={now} />;
  }
  if (state.phase === "slew") {
    content = <SlewWorkspace state={state} now={now} hold={hold} />;
  }
  if (["observe", "compare", "conclude"].includes(state.phase)) {
    content = <EvidenceWorkspace state={state} gameBrief={gameBrief} />;
  }
  if (state.phase === "result") content = <ResultWorkspace state={state} />;
  if (state.phase === "complete") content = <CompleteWorkspace state={state} />;

  return (
    <main className="teacher-panel teacher-workspace" aria-live="polite">
      <div className="workspace-kicker">
        步骤 {String(phaseIndex(state.phase) + 1).padStart(2, "0")} ·{" "}
        {baseMeta.short}
      </div>
      <h1 className="workspace-title">{meta.teacherTitle}</h1>
      <p className="workspace-subtitle">{meta.teacherPrompt}</p>
      {content}
      {state.lastError
        ? (
          <div className="status-note" role="alert">
            状态机提示：{state.lastError}
          </div>
        )
        : null}
    </main>
  );
}

function TeacherSide({ state, gameBrief }) {
  const scenario = getActiveScenario(state);
  const meta = getScenarioPhaseCopy(state);
  const baseMeta = CONTENT.phases[state.phase];
  const cue = scenario.teacherCues[state.phase];
  const claimedRoles =
    state.responses && Array.isArray(state.responses.roleClaims)
      ? state.responses.roleClaims.length
      : 0;
  return (
    <aside className="teacher-panel teacher-side v3-teacher-side">
      <section className="child-preview" aria-label="儿童共享屏只读预览">
        <div className="preview-inner">
          <div>
            <span>儿童共享屏 · {baseMeta.short} · {scenario.shortLabel}</span>
            <strong>{meta.childTitle}</strong>
            <small>{meta.childBody}</small>
          </div>
          <img
            src={scenario.targetAsset}
            alt={`${scenario.targetName}卡通任务图标`}
          />
        </div>
      </section>
      <section className="teacher-cue-card">
        <span>TEACHER CUE · 老师怎么说</span>
        <blockquote>“{cue.say}”</blockquote>
        <hr />
        <strong>现在观察</strong>
        <p>{cue.watch}</p>
        <hr />
        <strong>儿童屏信号</strong>
        <p>{currentChildSignal(state)}</p>
      </section>
      <section className="v3-side-card" aria-label="V3.3 合作式数据游戏摘要">
        <span>V3.3 ALPHA · 合作证据捕捉 + 今日科学档案</span>
        <strong>{gameBrief.title} · {gameBrief.modeLabel}</strong>
        <small>
          {claimedRoles} / {gameBrief.roles.length}{" "}
          个角色已认领。{gameBrief.evidence}
        </small>
        <small>科学边界：{gameBrief.boundary}</small>
      </section>
    </aside>
  );
}

function getDockPlan(state, now) {
  const scenario = getActiveScenario(state);
  const motion = getMotionCopy(scenario);
  const authorizationValid = isAuthorizationValid(state, now);
  const allChecked = preflightComplete(state);
  const mockOnly = state.config.dataMode === "fixed_mock";
  const plans = {
    prep: {
      label: `开始${scenario.shortLabel} · 邀请孩子集合`,
      disabled: !mockOnly,
      gate: mockOnly
        ? "开始后，儿童屏进入团队集合页"
        : "请选择唯一可用的“教学模拟”数据来源",
      action: () => STORE.dispatch({ type: "START_SESSION", actor: "teacher" }),
    },
    welcome: {
      label: "听到团队回应 · 进入预测",
      disabled: !state.responses.teamReady,
      gate: state.responses.teamReady
        ? "儿童屏已确认团队就绪"
        : "等待儿童屏提交“我们准备好了”",
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    predict: {
      label: "已追问理由 · 进入安全预检",
      disabled: !state.responses.prediction,
      gate: state.responses.prediction
        ? `已记录：${
          displayAnswer(state, "prediction", state.responses.prediction)
        }`
        : "等待儿童屏提交预测",
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    preflight: authorizationValid
      ? {
        label: `授权有效 · 进入${motion.modeLabel}`,
        disabled: false,
        gate: `授权还剩 ${
          Math.max(
            0,
            Math.ceil((state.safety.preflight.validUntil - now) / 1000),
          )
        } 秒`,
        action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
      }
      : {
        label: state.safety.preflight.confirmedAt
          ? "重新确认 30 秒授权"
          : "确认预检并生成 30 秒授权",
        disabled: !allChecked,
        gate: allChecked
          ? "确认后仍需由老师明确进入下一阶段"
          : "请逐项完成四项现场预检",
        action: () =>
          STORE.dispatch({ type: "CONFIRM_PREFLIGHT", actor: "teacher" }),
      },
    slew: !authorizationValid && !state.movement.arrived
      ? {
        label: "返回并重新完成预检",
        disabled: false,
        gate: "模拟运动授权已失效",
        action: () =>
          STORE.dispatch({ type: "RESUME_TO_PREFLIGHT", actor: "teacher" }),
      }
      : {
        label: state.movement.arrived ? motion.dockArrived : motion.dockWaiting,
        disabled: !state.movement.arrived,
        gate: state.movement.arrived
          ? motion.gateArrived
          : `${motion.progressLabel} ${Math.round(state.movement.progress)}%`,
        action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
      },
    observe: {
      label: "已捕获证据 · 进入比较",
      disabled: !observeEvidenceReady(scenario, state.responses.markers),
      gate: observeEvidenceReady(scenario, state.responses.markers)
        ? `已保存 ${state.responses.markers.length} 个儿童标记`
        : `等待儿童屏在不同位置保存 ${
          Math.max(1, Number(scenario.observe.minMarkers) || 1)
        } 个证据标记`,
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    compare: {
      label: "已指出曲线差异 · 形成结论",
      disabled: !state.responses.comparison,
      gate: state.responses.comparison
        ? `已记录：${
          displayAnswer(state, "comparison", state.responses.comparison)
        }`
        : "等待儿童屏提交比较",
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    conclude: {
      label: "已听到证据句 · 查看成果",
      disabled: !state.responses.conclusion,
      gate: state.responses.conclusion
        ? `已记录：${
          displayAnswer(state, "conclusion", state.responses.conclusion)
        }`
        : "等待儿童屏完成证据结论",
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    result: {
      label: "审核无误 · 完成本轮",
      disabled: false,
      gate: "成果出口仅由老师处理",
      action: () => STORE.dispatch({ type: "ADVANCE", actor: "teacher" }),
    },
    complete: {
      label: "确认现场后 · 重置下一轮",
      disabled: false,
      gate: "重置只清除本地前端原型状态",
      action: () => STORE.dispatch({ type: "RESET_SESSION", actor: "teacher" }),
    },
  };
  return plans[state.phase];
}

function Dock({ state, now }) {
  const plan = getDockPlan(state, now);
  const error = state.lastError;
  return (
    <footer className="teacher-dock">
      <div className="dock-secondary">
        <button
          type="button"
          className="teacher-button"
          onClick={() => window.open("child-stage.html", "_blank", "noopener")}
        >
          打开儿童屏
        </button>
        {state.phase === "predict"
          ? (
            <button
              type="button"
              className="teacher-button"
              onClick={() => STORE.dispatch({ type: "BACK", actor: "teacher" })}
            >
              返回集合
            </button>
          )
          : null}
      </div>
      <div
        className={`dock-gate ${error ? "error" : ""}`}
        role={error ? "alert" : undefined}
      >
        {error ? error : plan.gate}
      </div>
      <button
        type="button"
        className="teacher-button primary"
        disabled={plan.disabled}
        onClick={plan.action}
      >
        {plan.label}
      </button>
    </footer>
  );
}

function SafetyOverlay({ state }) {
  const scenario = getActiveScenario(state);
  const motion = getMotionCopy(scenario);
  if (state.safety.estop.latched) {
    const returnsToEarlyStep = ["welcome", "predict"].includes(
      state.safety.estop.fromPhase,
    );
    return (
      <div
        className="teacher-overlay"
        role="dialog"
        aria-modal="true"
        aria-labelledby="estopTitle"
      >
        <div className="teacher-modal danger">
          <h2 id="estopTitle">急停已锁定</h2>
          <p>
            {state.safety.estop
              .reason}。所有模拟运动已停止，运动授权已清除。不能通过重置绕过此状态。
          </p>
          <div className="status-note">
            {state.safety.estop.acknowledged
              ? returnsToEarlyStep
                ? "已确认现场检查。清除急停后会回到刚才的教学步骤，不会跳过团队就绪或预测。"
                : "已确认现场检查。下一步清除急停后，系统会统一回到四项安全预检。"
              : "请老师离开屏幕检查现场、儿童站位与设备周围，再进行确认。"}
          </div>
          <div className="modal-actions">
            {!state.safety.estop.acknowledged
              ? (
                <button
                  type="button"
                  className="teacher-button sun"
                  autoFocus
                  onClick={() =>
                    STORE.dispatch({ type: "ACK_ESTOP", actor: "teacher" })}
                >
                  我已检查现场
                </button>
              )
              : (
                <button
                  type="button"
                  className="teacher-button primary"
                  autoFocus
                  onClick={() =>
                    STORE.dispatch({ type: "CLEAR_ESTOP", actor: "teacher" })}
                >
                  {returnsToEarlyStep
                    ? "清除急停并回到本步"
                    : "清除急停并返回预检"}
                </button>
              )}
          </div>
        </div>
      </div>
    );
  }

  if (state.safety.pause.latched || state.runState === "paused") {
    const returnsToEarlyStep = ["welcome", "predict"].includes(
      state.safety.pause.fromPhase,
    );
    return (
      <div
        className="teacher-overlay"
        role="dialog"
        aria-modal="true"
        aria-labelledby="pauseTitle"
      >
        <div className="teacher-modal">
          <h2 id="pauseTitle">教学流程已安全暂停</h2>
          <p>
            {state.safety.pause.reason ||
              `${motion.progressLabel}和课堂流程已经停止。`}
          </p>
          <div className="status-note">
            {returnsToEarlyStep
              ? "恢复后会回到刚才的教学步骤，不会跳过团队就绪或预测。"
              : `恢复不会继续刚才的执行进度。系统将清除授权与${motion.progressLabel}进度，统一回到四项安全预检。`}
          </div>
          <div className="modal-actions">
            <button
              type="button"
              className="teacher-button primary"
              autoFocus
              onClick={() =>
                STORE.dispatch({
                  type: "RESUME_TO_PREFLIGHT",
                  actor: "teacher",
                })}
            >
              {returnsToEarlyStep ? "回到本步继续" : "返回预检后恢复"}
            </button>
          </div>
        </div>
      </div>
    );
  }

  return null;
}

function TeacherApp() {
  const state = useSessionState();
  const [now, setNow] = useState(Date.now());

  useEffect(() => {
    const timer = window.setInterval(() => setNow(Date.now()), 250);
    return () => window.clearInterval(timer);
  }, []);

  const hold = useStrictHold(state, now);
  const scenario = getActiveScenario(state);
  const gameBrief = useMemo(
    () => getV3GameBrief(state, scenario),
    [
      scenario.id,
      state.config.ageBand,
      state.visualization && state.visualization.mode,
    ],
  );
  const meta = useMemo(
    () => getScenarioPhaseCopy(state),
    [state.phase, state.runScenarioId, state.config.scenarioId],
  );

  useEffect(() => {
    document.title = `宇宙来电 V3.3 · ${scenario.shortLabel} · ${
      CONTENT.phases[state.phase].short
    } · 老师导演台`;
  }, [scenario.shortLabel, state.phase]);

  return (
    <div
      className="teacher-shell"
      data-screen-label="teacher-director-console"
      data-phase={state.phase}
      data-run-state={state.runState}
    >
      <SafetyBar state={state} />
      <div className="teacher-main">
        <Timeline state={state} />
        <Workspace state={state} now={now} hold={hold} gameBrief={gameBrief} />
        <TeacherSide state={state} gameBrief={gameBrief} />
      </div>
      <Dock state={state} now={now} />
      <SafetyOverlay state={state} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("teacherRoot")).render(
  <TeacherApp />,
);
