const THREE = window.THREE;
const O3DV = window.OV;
const GLTFLoader = THREE?.GLTFLoader;
const MTLLoader = THREE?.MTLLoader;
const OBJLoader = THREE?.OBJLoader;
const OrbitControls = THREE?.OrbitControls;
const RGBELoader = THREE?.RGBELoader;
const usingO3DVBackend = window.__LESSONS3D_VIEWER_BACKEND__ === "o3dv" && Boolean(O3DV?.EmbeddedViewer);
const TARGET_QUIZ_COUNT = 10;
const VIEWER_BALANCED_PIXEL_RATIO = 2;
const VIEWER_HQ_PIXEL_RATIO = 2.5;
const VIEWER_MAX_FPS = 30;
const VIEWER_SCROLL_IDLE_MS = 140;
const DRAWER_TRANSITION_MS = 260;
const LEGACY_THREE_UNSUPPORTED_GLTF_EXTENSIONS = new Set([
  "KHR_materials_pbrSpecularGlossiness"
]);

if (!usingO3DVBackend && (!THREE || !GLTFLoader || !MTLLoader || !OBJLoader || !OrbitControls)) {
  throw new Error("Lessons3D viewer runtime is incomplete. Check vendor script loading.");
}

const rawLesson = applyRuntimeLessonLanguage(JSON.parse(document.getElementById("lesson-data").textContent));
const theme = JSON.parse(document.getElementById("theme-data")?.textContent || "{}");
const lesson = ensureChoiceQuiz(resolveLocalizedLesson(normalizeLesson(rawLesson)));
const embeddedModel = window.__LESSONS3D_EMBEDDED_MODEL__ || null;
const storageKey = `lessons3d.quizState:${lesson.tracking?.lesson_id || lesson.title}`;

function detectViewerQualityProfile() {
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
  const hardwareConcurrency = Number(window.navigator?.hardwareConcurrency || 0);
  const deviceMemory = Number(window.navigator?.deviceMemory || 0);
  const devicePixelRatio = Number(window.devicePixelRatio || 1);
  const compactViewport = Math.min(viewportWidth, viewportHeight) > 0 && Math.min(viewportWidth, viewportHeight) <= 900;
  const strongCpu = hardwareConcurrency >= 8;
  const strongMemory = deviceMemory >= 8 || deviceMemory === 0;
  const hqPreferred = !compactViewport && strongCpu && strongMemory && devicePixelRatio >= 1.25;

  return {
    mode: hqPreferred ? "high" : "balanced",
    maxPixelRatio: hqPreferred ? VIEWER_HQ_PIXEL_RATIO : VIEWER_BALANCED_PIXEL_RATIO,
    envSigma: hqPreferred ? 0.018 : 0.028,
    envMapIntensity: hqPreferred ? 1.18 : 1.05,
    roughnessFloor: hqPreferred ? 0.14 : 0.2,
    lightMultiplier: hqPreferred ? 1 : 0.9
  };
}

const state = {
  activeSectionIndex: 0,
  activeStructureIndex: 0,
  quizStates: hydrateQuizStates(),
  activeQuizIndex: 0,
  activeQuestionIndex: 0,
  quizAdvanceTimer: null,
  camera: null,
  controls: null,
  focus: null,
  object: null,
  scene: null,
  renderer: null,
  container: null,
  viewerVisible: true,
  viewerNeedsRender: true,
  viewerRaf: null,
  viewerLastFrameAt: 0,
  viewerScrollTimer: null,
  viewerPageScrolling: false,
  hotspotsVisible: false,
  modelBrightness: 0.88,
  viewerExposureOffset: 0.04,
  viewerUsesHdri: false,
  viewerQuality: detectViewerQualityProfile(),
  viewerBackend: usingO3DVBackend ? "o3dv" : "three",
  viewerToolsBound: false,
  drawerUiBound: false,
  o3dvEmbeddedViewer: null,
  o3dvViewer: null,
  o3dvAutoRotate: true,
  o3dvRotateRaf: null,
  o3dvPendingLoad: null,
  localModelDownloadUrl: null,
  zoomMode: "in",
  requestViewerRender: null,
  animationMixer: null,
  animationActions: [],
  animationClipCount: 0,
  animationPlaying: true,
  animationLastTickAt: 0,
  animationAnnotationHint: "",
  activeDrawerId: "",
  activeScriptIndex: 0,
  activeStudentTaskIndex: 0,
  studentTaskStatus: {},
  studentTaskAdvanceTimer: null,
  pendingHotspotTargetId: "",
  lastSelectedHotspotId: "",
  narration: {
    supported: Boolean(window.speechSynthesis && window.SpeechSynthesisUtterance),
    playing: false,
    paused: false,
    queue: [],
    index: 0,
    currentUtterance: null
  }
};

const ui = {
  openImportPanelButton: document.getElementById("openImportPanelButton"),
  importButton: document.getElementById("importModelButton"),
  closeImportPanelButton: document.getElementById("closeImportPanelButton"),
  importPanel: document.getElementById("modelImportPanel"),
  importInput: document.getElementById("modelImportInput"),
  dropzone: document.getElementById("modelDropzone"),
  importStatus: document.getElementById("modelImportStatus"),
  modelSearchLink: document.getElementById("modelSearchLink"),
  downloadModelLink: document.getElementById("downloadModelLink"),
  toolButtons: [...document.querySelectorAll("[data-viewer-tool]")],
  animationButton: document.querySelector('[data-viewer-tool="animation"]'),
  narrationButton: document.querySelector('[data-viewer-tool="narration"]'),
  brightnessInput: document.getElementById("modelBrightness"),
  hotspotLayer: document.getElementById("hotspotLayer"),
  keyStructuresDrawerButton: document.getElementById("keyStructuresDrawerButton"),
  teachingScriptDrawerButton: document.getElementById("teachingScriptDrawerButton"),
  studentTaskDrawerButton: document.getElementById("studentTaskDrawerButton"),
  lessonQuestionsDrawerButton: document.getElementById("lessonQuestionsDrawerButton"),
  teacherModeButton: document.getElementById("teacherModeButton"),
  studentModeButton: document.getElementById("studentModeButton"),
  teachingScriptPanel: document.getElementById("teachingScriptPanel"),
  studentTaskPanel: document.getElementById("studentTaskPanel"),
  drawers: [...document.querySelectorAll("[data-drawer-target]")],
  drawerPanels: [...document.querySelectorAll(".lesson-drawer")],
  drawerCloseButtons: [...document.querySelectorAll("[data-close-drawer]")]
};

if (ui.downloadModelLink && !ui.downloadModelLink.dataset.defaultHref) {
  ui.downloadModelLink.dataset.defaultHref = ui.downloadModelLink.getAttribute("href") || "";
}

function resolveViewerAssetPath(fileName) {
  const runtimeBase = window.__LESSONS3D_RUNTIME_BASE__ || "./vendor/";
  return new URL(fileName, new URL(runtimeBase, window.location.href)).toString();
}

function resolveViewerHdriSource() {
  return window.__LESSONS3D_HDRI_DATA_URI__ || resolveViewerAssetPath("venice_sunset_1k.hdr");
}

function normalizeRuntimeLanguage(value) {
  if (!value) return "";
  const normalized = String(value).trim().toLowerCase();
  if (normalized.startsWith("zh")) return "zh-CN";
  if (normalized.startsWith("en")) return "en";
  return "";
}

function getRequestedLessonLanguage() {
  try {
    const params = new URLSearchParams(window.location.search || "");
    const queryLanguage = normalizeRuntimeLanguage(params.get("lang") || params.get("locale") || params.get("language"));
    if (queryLanguage) return queryLanguage;
  } catch {
    // Ignore malformed URLs and keep the packaged lesson language.
  }

  try {
    const storedLanguage = normalizeRuntimeLanguage(window.localStorage?.getItem("lessons3d.uiLocale"));
    if (storedLanguage) return storedLanguage;
  } catch {
    // Local file previews can block storage; the lesson JSON remains the source of truth.
  }

  return "";
}

function applyRuntimeLessonLanguage(input) {
  const requestedLanguage = getRequestedLessonLanguage();
  if (!requestedLanguage || input.language === "bilingual") return input;
  if (requestedLanguage === "en" && input.i18n?.en) {
    return { ...input, language: "en" };
  }
  if (requestedLanguage === "zh-CN") {
    return { ...input, language: "zh-CN" };
  }
  return input;
}

function lessonUiCopy(language) {
  if (language === "en") {
    return {
      topbarLabel: "Lessons3D interactive lesson",
      overviewPanelLabel: "Course overview",
      structuresPanelLabel: "Key structures",
      questionsPanelLabel: "10 lesson questions",
      objectivesPanelLabel: "What you will learn",
      sectionsPanelLabel: "Lesson path",
      quizPanelLabel: "Check your understanding",
      reviewBadgeDraft: "AI draft / Needs teacher review",
      reviewBadgeReviewed: "Reviewed lesson",
      overviewTitle: "Start with the big picture",
      overviewSummary: "Lesson summary",
      overviewBigIdea: "Big idea",
      overviewObservation: "What to notice first",
      overviewPrompt: "Think first",
      structureRole: "Why it matters",
      structureLookFor: "What to look for",
      sectionPrefix: "Step",
      noQuestions: "Questions will appear here after lesson generation.",
      embeddedModelActive: "The embedded lesson model is currently active.",
      packagedModelActive: "The packaged lesson model is currently active. You can replace it with your own local file.",
      manualImportNeeded: "No model is embedded in this lesson yet. Import your own file to start the 3D viewer.",
      defaultLoadFailed: "The default model could not be loaded. Please import a local file instead.",
      importSuccessPrefix: "Loaded",
      noModelLabel: "Import required",
      heroKeyStructures: "Key structures",
      heroLessonQuestions: "Lesson questions",
      heroTeachingMode: "Enter teaching mode",
      flowPanelLabel: "Teaching flow",
      flowPanelTitle: "Lesson flow",
      modelPanelLabel: "3D model",
      modelPanelTitle: "Model study area",
      interactiveModelChip: "Interactive",
      toolRotate: "Rotate",
      toolZoom: "Zoom in",
      toolZoomOut: "Zoom out",
      toolReset: "Reset",
      toolNarration: "Play narration",
      toolNarrationPause: "Pause narration",
      toolNarrationResume: "Resume narration",
      toolNarrationUnavailable: "Narration unavailable",
      brightnessLabel: "Brightness",
      teacherMode: "Teacher mode",
      studentMode: "Student mode",
      replaceModel: "Replace model",
      downloadModel: "Download model",
      keyStructuresDrawer: "Key structures",
      teachingScriptDrawer: "Teaching script",
      studentTaskDrawer: "Student task",
      lessonQuestionsDrawer: "Lesson questions",
      scriptStep: "Teaching step",
      scriptObserve: "Observe",
      scriptQuestion: "Ask",
      scriptPrevious: "Previous step",
      scriptNext: "Next step",
      taskPrompt: "Task prompt",
      taskTarget: "Target hotspot",
      taskInstruction: "Click the correct hotspot in the 3D model.",
      taskCorrect: "Correct. Nice work.",
      taskIncorrect: "Not quite yet. Try another hotspot.",
      taskCompleted: "All hotspot tasks are complete.",
      taskContinue: "Next task",
      importPanelTitle: "Import or replace the local model",
      importPanelCopy: "Import a single .glb, a multi-file .gltf package, or an .obj bundle with textures.",
      close: "Close",
      dropzoneTitle: "Drop a model here",
      dropzoneHint: "or click to choose local files",
      chooseModelFiles: "Choose model files",
      openImportGuide: "Open import guide",
      overviewPanelTitle: "Course overview",
      objectivesPanelTitle: "Learning objectives",
      sectionsPanelTitle: "Teaching focus",
      teachingNoteTitle: "Teaching note",
      footerTools: ["Classroom tools", "Pen annotation", "Text annotation", "Lesson Q&A", "Knowledge map"]
    };
  }
  if (language === "bilingual") {
    return {
      topbarLabel: "Lessons3D interactive lesson / Lessons3D 学生互动课件",
      overviewPanelLabel: "Course overview / 课程概述",
      structuresPanelLabel: "Key structures / 重点结构",
      questionsPanelLabel: "Lesson questions / 课程问题",
      objectivesPanelLabel: "What you will learn / 本节课你将学会",
      sectionsPanelLabel: "Lesson path / 学习路径",
      quizPanelLabel: "Check your understanding / 理解检查",
      reviewBadgeDraft: "AI draft / Needs teacher review / AI 草稿 / 需教师复核",
      reviewBadgeReviewed: "Reviewed lesson / 教师已复核",
      overviewTitle: "Start with the big picture / 先看整体，再看细节",
      overviewSummary: "Lesson summary / 课程概述",
      overviewBigIdea: "Big idea / 核心理解",
      overviewObservation: "What to notice first / 先观察什么",
      overviewPrompt: "Think first / 先想一想",
      structureRole: "Why it matters / 为什么重要",
      structureLookFor: "What to look for / 观察重点",
      sectionPrefix: "Step / 步骤",
      noQuestions: "Questions will appear here after lesson generation. / 课件生成后，这里会显示课程相关问题。",
      embeddedModelActive: "The embedded lesson model is currently active. / 当前正在使用课件内置模型。",
      packagedModelActive: "The packaged lesson model is currently active. You can replace it with your own local file. / 当前正在使用课件包内模型，你可以随时替换为自己的本地文件。",
      manualImportNeeded: "No model is embedded in this lesson yet. Import your own file to start the 3D viewer. / 这个课件还没有内置模型，请先导入自己的文件。",
      defaultLoadFailed: "The default model could not be loaded. Please import a local file instead. / 默认模型加载失败，请改用本地导入。",
      importSuccessPrefix: "Loaded / 已加载",
      noModelLabel: "Import required / 需要导入模型",
      heroKeyStructures: "Key structures / 重点结构",
      heroLessonQuestions: "Lesson questions / 课程问题",
      heroTeachingMode: "Enter teaching mode / 进入授课模式",
      flowPanelLabel: "Teaching flow / 教学流程",
      flowPanelTitle: "Lesson flow / 课程流程",
      modelPanelLabel: "3D model / 3D 模型",
      modelPanelTitle: "Model study area / 模型学习区",
      interactiveModelChip: "Interactive / 可交互",
      toolRotate: "Rotate / 旋转",
      toolZoom: "Zoom in / 放大",
      toolZoomOut: "Zoom out / 缩小",
      toolReset: "Reset / 重置",
      toolNarration: "Play narration / 播放讲解",
      toolNarrationPause: "Pause narration / 暂停讲解",
      toolNarrationResume: "Resume narration / 继续讲解",
      toolNarrationUnavailable: "Narration unavailable / 朗读不可用",
      brightnessLabel: "Brightness / 亮度",
      teacherMode: "Teacher mode / 教师模式",
      studentMode: "Student mode / 学生模式",
      replaceModel: "Replace model / 替换模型",
      downloadModel: "Download model / 下载模型",
      keyStructuresDrawer: "Key structures / 重点结构",
      teachingScriptDrawer: "Teaching script / 讲解脚本",
      studentTaskDrawer: "Student task / 学生任务",
      lessonQuestionsDrawer: "Lesson questions / 课程问题",
      scriptStep: "Teaching step / 教学步骤",
      scriptObserve: "Observe / 观察",
      scriptQuestion: "Ask / 提问",
      scriptPrevious: "Previous step / 上一步",
      scriptNext: "Next step / 下一步",
      taskPrompt: "Task prompt / 任务说明",
      taskTarget: "Target hotspot / 目标热点",
      taskInstruction: "Click the correct hotspot in the 3D model. / 请在 3D 模型中点击正确热点。",
      taskCorrect: "Correct. Nice work. / 回答正确。",
      taskIncorrect: "Not quite yet. Try another hotspot. / 还不对，再试一次。",
      taskCompleted: "All hotspot tasks are complete. / 热点任务已全部完成。",
      taskContinue: "Next task / 下一题",
      importPanelTitle: "Import or replace the local model / 导入或替换本地模型",
      importPanelCopy: "Import a single .glb, a multi-file .gltf package, or an .obj bundle with textures. / 可导入单个 .glb、多文件 .gltf 包，或带贴图的 .obj 文件组。",
      close: "Close / 关闭",
      dropzoneTitle: "Drop a model here / 将模型拖到这里",
      dropzoneHint: "or click to choose local files / 或点击选择本地文件",
      chooseModelFiles: "Choose model files / 选择模型文件",
      openImportGuide: "Open import guide / 打开导入指南",
      overviewPanelTitle: "Course overview / 课程概述",
      objectivesPanelTitle: "Learning objectives / 学习目标",
      sectionsPanelTitle: "Teaching focus / 教学重点",
      teachingNoteTitle: "Teaching note / 教学提示",
      footerTools: ["Classroom tools / 课堂工具", "Pen annotation / 画笔标注", "Text annotation / 文字标注", "Lesson Q&A / 课程问答", "Knowledge map / 知识图谱"]
    };
  }
  return {
    topbarLabel: "Lessons3D 学生互动课件",
    overviewPanelLabel: "课程概述",
    structuresPanelLabel: "重点结构",
    questionsPanelLabel: "10 个课程问题",
    objectivesPanelLabel: "本节课你将学会",
    sectionsPanelLabel: "学习路径",
    quizPanelLabel: "理解检查",
    reviewBadgeDraft: "AI 草稿 / 需教师复核",
    reviewBadgeReviewed: "教师已复核",
    overviewTitle: "先看整体，再看细节",
    overviewSummary: "课程概述",
    overviewBigIdea: "核心理解",
    overviewObservation: "先观察什么",
    overviewPrompt: "先想一想",
    structureRole: "为什么重要",
    structureLookFor: "观察重点",
    sectionPrefix: "步骤",
    noQuestions: "课件生成后，这里会显示 10 个课程相关问题。",
    quizProgressPrefix: "第",
    quizProgressSuffix: "题",
    quizSingleHint: "单题作答，选择后自动进入下一题。",
    quizCompleteTitle: "本轮问答已完成",
    quizCompleteBody: "所有知识点问答都已经完成，可以重新开始本轮练习。",
    quizRestart: "重新开始",
    embeddedModelActive: "当前已加载默认模型。你可以随时替换为本地模型。",
    packagedModelActive: "当前模型来自课件包内置资源。你可以随时替换为本地模型。",
    manualImportNeeded: "当前课件没有内置模型，请先导入你自己的模型文件。",
    defaultLoadFailed: "默认模型加载失败，请改用本地导入。",
    importSuccessPrefix: "已加载",
    noModelLabel: "待导入模型",
    heroKeyStructures: "重点结构",
    heroLessonQuestions: "课程问题",
    heroTeachingMode: "进入授课模式",
    flowPanelLabel: "教学流程",
    flowPanelTitle: "课程流程",
    modelPanelLabel: "3D 模型",
    modelPanelTitle: "模型学习区",
    interactiveModelChip: "可交互",
    toolRotate: "旋转",
    toolZoom: "放大",
    toolZoomOut: "缩小",
    toolReset: "重置",
    toolNarration: "播放讲解",
    toolNarrationPause: "暂停讲解",
    toolNarrationResume: "继续讲解",
    toolNarrationUnavailable: "朗读不可用",
    brightnessLabel: "亮度",
    teacherMode: "教师模式",
    studentMode: "学生模式",
    replaceModel: "替换模型",
    downloadModel: "下载模型",
    keyStructuresDrawer: "重点结构",
    teachingScriptDrawer: "讲解脚本",
    studentTaskDrawer: "学生任务",
    lessonQuestionsDrawer: "课程问题",
    scriptStep: "教学步骤",
    scriptObserve: "观察",
    scriptQuestion: "提问",
    scriptPrevious: "上一步",
    scriptNext: "下一步",
    taskPrompt: "任务说明",
    taskTarget: "目标热点",
    taskInstruction: "请在 3D 模型中点击正确热点。",
    taskCorrect: "回答正确。",
    taskIncorrect: "还不对，再试一次。",
    taskCompleted: "热点任务已全部完成。",
    taskContinue: "下一题",
    importPanelTitle: "导入或替换本地模型",
    importPanelCopy: "可导入单个 .glb、多文件 .gltf 包，或带贴图的 .obj 文件组。",
    close: "关闭",
    dropzoneTitle: "将模型拖到这里",
    dropzoneHint: "或点击选择本地文件",
    chooseModelFiles: "选择模型文件",
    openImportGuide: "打开导入指南",
    overviewPanelTitle: "课程概述",
    objectivesPanelTitle: "学习目标",
    sectionsPanelTitle: "教学重点",
    teachingNoteTitle: "教学提示",
    footerTools: ["课堂工具", "画笔标注", "文字标注", "课程问答", "知识图谱"]
  };
}

const uiCopy = lessonUiCopy(lesson.language);

function normalizeLesson(input) {
  const normalizedOverview = input.course_overview && typeof input.course_overview === "object"
    ? {
        summary: input.course_overview.summary || "",
        big_idea: input.course_overview.big_idea || "",
        observation_focus: input.course_overview.observation_focus || "",
        teacher_prompt: input.course_overview.teacher_prompt || ""
      }
    : {
        summary: input.subtitle || input.title || "",
        big_idea: "",
        observation_focus: "",
        teacher_prompt: ""
      };

  const normalizedStructures = (input.key_structures || []).map((item, index) => ({
    id: item.id || `structure-${index + 1}`,
    name: item.name || `Structure ${index + 1}`,
    role: item.role || "",
    look_for: item.look_for || ""
  }));

  const normalizedSections = (input.sections || []).map((section, index) => ({
    id: section.id || `section-${index + 1}`,
    label: section.label || section.title || `Section ${index + 1}`,
    title: section.title || section.label || `Section ${index + 1}`,
    description: section.description || section.content || "",
    fun_fact: section.fun_fact || null,
    hotspot: normalizeHotspot(section.hotspot),
    camera_view: normalizeCameraView(section.camera_view),
    key_points: Array.isArray(section.key_points)
      ? section.key_points.map((point, pointIndex) => ({
          id: point.id || `${section.id || `section-${index + 1}`}-point-${pointIndex + 1}`,
          text: point.text || point.description || point.name || "",
          fun_fact: point.fun_fact || null
        }))
      : []
  }));

  if (!normalizedSections.length && Array.isArray(input.key_points) && input.key_points.length) {
    normalizedSections.push({
      id: "overview",
      label: "Overview",
      title: "Overview",
      description: input.subtitle || input.title || "",
      fun_fact: null,
      hotspot: null,
      camera_view: null,
      key_points: input.key_points.map((point, index) => ({
        id: point.id || `legacy-point-${index + 1}`,
        text: point.description || point.text || point.name || "",
        fun_fact: point.fun_fact || null
      }))
    });
  }

  const normalizedQuiz = (input.quiz || input.questions || []).map((question, index) => ({
    id: question.id || `quiz-${index + 1}`,
    type: question.type || "short_answer",
    question: question.question || `Question ${index + 1}`,
    options: question.options || question.choices || [],
    answer: question.answer,
    explanation: question.explanation || ""
  }));

  const normalizeScriptItem = (item, index) => {
    if (typeof item === "string") {
      return {
        section_id: `script-${index + 1}`,
        title: "",
        narration: item.trim(),
        model_action: "",
        question: ""
      };
    }
    if (!item || typeof item !== "object") return null;
    return {
      section_id: item.section_id || item.id || `script-${index + 1}`,
      title: displayQuestionText(item.title),
      narration: displayQuestionText(item.narration || item.text || item.body),
      model_action: displayQuestionText(item.model_action || item.action || item.view),
      question: displayQuestionText(item.question || item.prompt),
      camera_view: normalizeCameraView(item.camera_view),
      target_hotspot_id: displayQuestionText(item.target_hotspot_id || item.hotspot_id)
    };
  };

  const teacherScript = Array.isArray(input.teacher_script)
    ? input.teacher_script.map(normalizeScriptItem).filter((item) => item?.narration)
    : [];
  const narrationScript = Array.isArray(input.narration_script)
    ? input.narration_script.map(normalizeScriptItem).filter((item) => item?.narration)
    : [];
  const normalizedStudentTasks = Array.isArray(input.student_tasks)
    ? input.student_tasks.map((task, index) => ({
        id: task.id || `student-task-${index + 1}`,
        type: displayQuestionText(task.type) || "hotspot_identification",
        title: displayQuestionText(task.title) || `Task ${index + 1}`,
        prompt: displayQuestionText(task.prompt || task.question || task.body),
        target_hotspot_id: displayQuestionText(task.target_hotspot_id || task.section_id || task.hotspot_id),
        expected_answer: displayQuestionText(task.expected_answer || task.answer),
        feedback_correct: displayQuestionText(task.feedback_correct),
        feedback_incorrect: displayQuestionText(task.feedback_incorrect)
      })).filter((task) => task.prompt)
    : [];

  return {
    ...input,
    tracking_enabled: input.tracking_enabled ?? input.tracking?.enabled ?? true,
    course_overview: normalizedOverview,
    key_structures: normalizedStructures,
    sections: normalizedSections,
    quiz: normalizedQuiz,
    teacher_script: teacherScript,
    narration_script: narrationScript,
    student_tasks: normalizedStudentTasks,
    lesson_questions: Array.isArray(input.lesson_questions)
      ? input.lesson_questions.map(displayQuestionText).filter(Boolean)
      : []
  };
}

function ensureChoiceQuiz(input) {
  const completedQuiz = supplementQuizItems(input, TARGET_QUIZ_COUNT);
  const answerPool = buildQuizAnswerPool({ ...input, quiz: completedQuiz });
  return {
    ...input,
    quiz: completedQuiz.map((item, index) => normalizeChoiceQuizItem(item, index, input.language, answerPool))
  };
}

function supplementQuizItems(input, targetCount) {
  const baseQuiz = Array.isArray(input.quiz) ? input.quiz.filter((item) => String(item?.question || "").trim()) : [];
  if (baseQuiz.length >= targetCount) {
    return baseQuiz.slice(0, targetCount);
  }

  const generated = buildSupplementalQuizCandidates(input);
  const seenQuestions = new Set(baseQuiz.map((item) => String(item.question || "").trim()));
  const result = [...baseQuiz];

  for (const candidate of generated) {
    const question = String(candidate?.question || "").trim();
    if (!question || seenQuestions.has(question)) continue;
    result.push(candidate);
    seenQuestions.add(question);
    if (result.length >= targetCount) break;
  }

  return result.slice(0, targetCount);
}

function buildSupplementalQuizCandidates(input) {
  const language = input.language;
  const structures = Array.isArray(input.key_structures) ? input.key_structures : [];
  const sections = Array.isArray(input.sections) ? input.sections : [];
  const overview = input.course_overview || {};
  const structureNames = structures.map((item) => normalizeChoiceText(item.name)).filter(Boolean);
  const lookForPool = structures.map((item) => normalizeChoiceText(item.look_for)).filter(Boolean);
  const pointPool = sections.flatMap((section) => (section.key_points || []).map((point) => normalizeChoiceText(point.text))).filter(Boolean);
  const descriptionPool = sections.map((section) => normalizeChoiceText(section.description)).filter(Boolean);
  const candidates = [];

  structures.forEach((structure, index) => {
    const structureName = normalizeChoiceText(structure.name);
    const role = normalizeChoiceText(structure.role);
    const lookFor = normalizeChoiceText(structure.look_for);
    if (structureName && role) {
      candidates.push(buildChoiceQuestion(
        language === "en"
          ? `Which structure is best matched with this role: ${role}?`
          : `下面哪一个结构最适合对应这项作用：${role}？`,
        structureName,
        structureNames,
        language,
        index
      ));
    }
    if (structureName && lookFor) {
      candidates.push(buildChoiceQuestion(
        language === "en"
          ? `If you want to identify ${structureName} in the model, what should you look for first?`
          : `如果要在模型中识别“${structureName}”，最值得先观察哪一项？`,
        lookFor,
        lookForPool,
        language,
        index + 1
      ));
    }
  });

  sections.forEach((section, index) => {
    const title = normalizeChoiceText(section.title || section.label);
    const mainPoint = normalizeChoiceText(section.key_points?.[0]?.text || section.description);
    if (title && mainPoint) {
      candidates.push(buildChoiceQuestion(
        language === "en"
          ? `In the lesson step "${title}", which point should students grasp most clearly?`
          : `在“${title}”这一学习步骤中，学生最应该抓住哪一个要点？`,
        mainPoint,
        pointPool.length ? pointPool : descriptionPool,
        language,
        index + structures.length
      ));
    }
  });

  const overviewBigIdea = normalizeChoiceText(overview.big_idea);
  if (overviewBigIdea) {
    candidates.push(buildChoiceQuestion(
      language === "en"
        ? "Which statement is closest to the big idea of this lesson?"
        : "这节课的核心理解最接近下面哪一项？",
      overviewBigIdea,
      [overviewBigIdea, normalizeChoiceText(overview.observation_focus), normalizeChoiceText(overview.teacher_prompt), ...pointPool],
      language,
      candidates.length
    ));
  }

  const overviewObservation = normalizeChoiceText(overview.observation_focus);
  if (overviewObservation) {
    candidates.push(buildChoiceQuestion(
      language === "en"
        ? "When students first inspect the model, what should they notice first?"
        : "学生刚开始观察模型时，最应该先注意什么？",
      overviewObservation,
      [overviewObservation, overviewBigIdea, normalizeChoiceText(overview.teacher_prompt), ...lookForPool, ...pointPool],
      language,
      candidates.length + 1
    ));
  }

  const overviewPrompt = normalizeChoiceText(overview.teacher_prompt);
  if (overviewPrompt) {
    candidates.push(buildChoiceQuestion(
      language === "en"
        ? "Which teacher prompt best fits the opening move of this lesson?"
        : "下面哪一句最适合作为本课开场追问？",
      overviewPrompt,
      [overviewPrompt, overviewBigIdea, overviewObservation, ...descriptionPool],
      language,
      candidates.length + 2
    ));
  }

  return candidates
    .filter((item) => item && item.options?.length >= 4)
    .map((item, index) => ({
      id: item.id || `quiz-generated-${index + 1}`,
      type: "multiple_choice",
      question: item.question,
      options: item.options,
      answer: item.answer,
      explanation: item.explanation || ""
    }));
}

function buildChoiceQuestion(question, correctAnswer, pool, language, seed) {
  const normalizedCorrect = normalizeChoiceText(correctAnswer);
  if (!normalizedCorrect) return null;

  const options = buildChoiceOptions(normalizedCorrect, pool, language, seed);
  if (options.length < 4) return null;

  return {
    question,
    options,
    answer: options.indexOf(normalizedCorrect),
    explanation: language === "en"
      ? "This option matches the lesson content most directly."
      : "这个选项与课件内容的对应关系最直接。"
  };
}

function buildChoiceOptions(correctAnswer, pool, language, seed) {
  const normalizedPool = [];
  const append = (value) => {
    const text = normalizeChoiceText(value);
    if (!text || text === correctAnswer || normalizedPool.includes(text)) return;
    normalizedPool.push(text);
  };

  (pool || []).forEach(append);

  const distractors = normalizedPool.slice(0, 3);
  while (distractors.length < 3) {
    const fallback = defaultDistractor(language, distractors.length);
    if (!distractors.includes(fallback) && fallback !== correctAnswer) {
      distractors.push(fallback);
    }
  }

  const insertionIndex = seed % 4;
  const options = [...distractors];
  options.splice(insertionIndex, 0, correctAnswer);
  return options;
}

function normalizeChoiceText(value) {
  const text = String(value || "").trim();
  if (!text) return "";
  if (/^\d+([.)、-]\s*)?$/.test(text)) return "";
  return text;
}

function normalizeChoiceQuizItem(item, index, language, answerPool) {
  if (Array.isArray(item.options) && item.options.length >= 2) {
    return {
      ...item,
      type: "multiple_choice",
      answer: normalizeCorrectOptionIndex(item)
    };
  }

  const correctAnswer = String(item.answer || "").trim();
  const distractors = answerPool
    .filter((candidate) => candidate !== correctAnswer)
    .slice(0, 3);

  while (distractors.length < 3) {
    distractors.push(defaultDistractor(language, distractors.length));
  }

  const insertionIndex = index % 4;
  const options = [...distractors];
  options.splice(insertionIndex, 0, correctAnswer);

  return {
    ...item,
    type: "multiple_choice",
    options,
    answer: insertionIndex
  };
}

function normalizeCorrectOptionIndex(item) {
  if (Number.isInteger(item.answer)) {
    return Math.max(0, Math.min(item.answer, Math.max((item.options?.length || 1) - 1, 0)));
  }
  const answerText = String(item.answer || "").trim();
  if (!answerText) return null;
  const optionIndex = (item.options || []).findIndex((option) => String(option || "").trim() === answerText);
  return optionIndex >= 0 ? optionIndex : null;
}

function buildQuizAnswerPool(input) {
  const pool = [];
  const append = (value) => {
    const text = String(value || "").trim();
    if (!isValidQuizOptionText(text) || pool.includes(text)) return;
    pool.push(text);
  };

  (input.quiz || []).forEach((item) => {
    append(item.answer);
  });
  (input.key_structures || []).forEach((item) => {
    append(item.role);
    append(item.look_for);
  });
  (input.teaching_objectives || []).forEach(append);
  return pool;
}

function isValidQuizOptionText(text) {
  if (!text) return false;
  if (/^\d+([.)、-]\s*)?$/.test(text)) return false;
  if (text.length < 6) return false;
  return true;
}

function defaultDistractor(language, index) {
  const zh = [
    "它主要只用于帮助观察位置，不承担核心功能。",
    "它和主要结构关系不大，通常可以忽略。",
    "它只影响外观变化，与课堂重点没有直接关系。"
  ];
  const en = [
    "It mainly helps with visual positioning and does not carry the core function.",
    "It has little connection to the main structure and can usually be ignored.",
    "It only changes appearance and is not directly tied to the lesson focus."
  ];
  const source = language === "en" ? en : zh;
  return source[index % source.length];
}

function normalizeHotspot(hotspot) {
  if (!hotspot || typeof hotspot !== "object") return null;
  const x = Number(hotspot.x);
  const y = Number(hotspot.y);
  if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
  return { x, y };
}

function normalizeCameraVector(vector) {
  if (!Array.isArray(vector) || vector.length < 3) return null;
  const [x, y, z] = vector.map((value) => Number(value));
  if (![x, y, z].every((value) => Number.isFinite(value))) return null;
  return [x, y, z];
}

function normalizeCameraView(cameraView) {
  if (!cameraView || typeof cameraView !== "object") return null;
  const position = normalizeCameraVector(cameraView.position);
  const target = normalizeCameraVector(cameraView.target);
  const orbitDistance = Number(cameraView.orbit_distance);
  const elevation = Number(cameraView.elevation);
  const azimuth = Number(cameraView.azimuth);
  const zoom = Number(cameraView.zoom);
  return {
    position,
    target,
    orbit_distance: Number.isFinite(orbitDistance) ? orbitDistance : null,
    elevation: Number.isFinite(elevation) ? elevation : null,
    azimuth: Number.isFinite(azimuth) ? azimuth : null,
    zoom: Number.isFinite(zoom) ? zoom : null
  };
}

function displayQuestionText(value) {
  if (typeof value === "string" || typeof value === "number") {
    const text = String(value).trim();
    return text === "[object Object]" ? "" : text;
  }
  if (!value || typeof value !== "object") return "";
  for (const key of ["question", "text", "prompt", "body", "title", "label"]) {
    const text = displayQuestionText(value[key]);
    if (text) return text;
  }
  return "";
}

function localizedValue(baseValue, localizedValue, language) {
  const baseText = displayQuestionText(baseValue);
  const localizedText = displayQuestionText(localizedValue);
  if (language === "bilingual" && localizedText) return `${baseText || ""} / ${localizedText}`.trim();
  if (language === "en" && localizedText) return localizedText;
  return baseText;
}

function resolveLocalizedLesson(input) {
  const english = input.i18n?.en;
  if (!english || (input.language !== "en" && input.language !== "bilingual")) {
    return input;
  }

  return {
    ...input,
    title: localizedValue(input.title, english.title, input.language),
    subtitle: localizedValue(input.subtitle, english.subtitle, input.language),
    teaching_objectives: Array.isArray(english.teaching_objectives) && english.teaching_objectives.length
      ? input.language === "en"
        ? english.teaching_objectives
        : input.teaching_objectives.map((item, index) => localizedValue(item, english.teaching_objectives[index], input.language))
      : input.teaching_objectives,
    course_overview: {
      summary: localizedValue(input.course_overview?.summary, english.course_overview?.summary, input.language),
      big_idea: localizedValue(input.course_overview?.big_idea, english.course_overview?.big_idea, input.language),
      observation_focus: localizedValue(input.course_overview?.observation_focus, english.course_overview?.observation_focus, input.language),
      teacher_prompt: localizedValue(input.course_overview?.teacher_prompt, english.course_overview?.teacher_prompt, input.language)
    },
    key_structures: input.key_structures.map((item, index) => {
      const localizedStructure = english.key_structures?.[index] || {};
      return {
        ...item,
        name: localizedValue(item.name, localizedStructure.name, input.language),
        role: localizedValue(item.role, localizedStructure.role, input.language),
        look_for: localizedValue(item.look_for, localizedStructure.look_for, input.language)
      };
    }),
    sections: input.sections.map((section, index) => {
      const localizedSection = english.sections?.[index] || {};
      return {
        ...section,
        label: localizedValue(section.label, localizedSection.label, input.language),
        title: localizedValue(section.title, localizedSection.title, input.language),
        description: localizedValue(section.description, localizedSection.description, input.language),
        fun_fact: localizedValue(section.fun_fact, localizedSection.fun_fact, input.language),
        key_points: section.key_points.map((point, pointIndex) => {
          const localizedPoint = localizedSection.key_points?.[pointIndex] || {};
          return {
            ...point,
            text: localizedValue(point.text, localizedPoint.text, input.language),
            fun_fact: localizedValue(point.fun_fact, localizedPoint.fun_fact, input.language)
          };
        })
      };
    }),
    teacher_script: Array.isArray(input.teacher_script) && Array.isArray(english.teacher_script) && english.teacher_script.length
      ? input.teacher_script.map((item, index) => {
          const localizedScript = english.teacher_script?.[index] || {};
          return {
            ...item,
            title: localizedValue(item.title, localizedScript.title, input.language),
            narration: localizedValue(item.narration, localizedScript.narration, input.language),
            model_action: localizedValue(item.model_action, localizedScript.model_action, input.language),
            question: localizedValue(item.question, localizedScript.question, input.language)
          };
        })
      : input.teacher_script,
    quiz: input.quiz.map((item, index) => {
      const localizedQuiz = english.quiz?.[index] || {};
      return {
        ...item,
        question: localizedValue(item.question, localizedQuiz.question, input.language),
        explanation: localizedValue(item.explanation, localizedQuiz.explanation, input.language),
        answer: input.language === "en" && localizedQuiz.answer !== undefined ? localizedQuiz.answer : item.answer,
        options: Array.isArray(localizedQuiz.options) && localizedQuiz.options.length
          ? input.language === "en"
            ? localizedQuiz.options
            : item.options.map((option, optionIndex) => localizedValue(option, localizedQuiz.options[optionIndex], input.language))
          : item.options
      };
    }),
    lesson_questions: input.lesson_questions.map((item, index) =>
      localizedValue(item, english.lesson_questions?.[index], input.language)
    )
  };
}

function applyTheme(themeConfig) {
  const tokens = themeConfig?.tokens || {};
  const root = document.documentElement.style;
  if (tokens.background) root.setProperty("--ls-bg", tokens.background);
  if (tokens.paper) root.setProperty("--ls-paper", tokens.paper);
  if (tokens.ink) root.setProperty("--ls-ink", tokens.ink);
  if (tokens.stage) root.setProperty("--ls-stage", tokens.stage);
  if (tokens.primary) root.setProperty("--ls-primary", tokens.primary);
  if (tokens.accent) root.setProperty("--ls-accent", tokens.accent);
  if (tokens.technical) root.setProperty("--ls-technical", tokens.technical);
  if (tokens.review) root.setProperty("--ls-review", tokens.review);
}

function hydrateQuizStates() {
  try {
    const defaults = lesson.quiz.map((item) => createDefaultQuizState(item.type));
    if (!window.sessionStorage) return defaults;
    const stored = JSON.parse(window.sessionStorage.getItem(storageKey) || "[]");
    return defaults.map((fallback, index) => normalizeQuizState(stored[index], fallback));
  } catch {
    return lesson.quiz.map((item) => createDefaultQuizState(item.type));
  }
}

function createDefaultQuizState(type) {
  return {
    status: "not_started",
    selectedIndex: null,
    type
  };
}

function normalizeQuizState(value, fallback) {
  const allowed = new Set(["not_started", "in_progress", "submitted", "reviewed"]);
  if (!value || typeof value !== "object") return fallback;
  return {
    status: allowed.has(value.status) ? value.status : fallback.status,
    selectedIndex: Number.isInteger(value.selectedIndex) ? value.selectedIndex : null,
    type: fallback.type
  };
}

function persistQuizStates() {
  try {
    if (!window.sessionStorage) return;
    window.sessionStorage.setItem(storageKey, JSON.stringify(state.quizStates));
  } catch {
    // Ignore storage failures and keep the session in-memory only.
  }
}

function setText(id, value) {
  const element = document.getElementById(id);
  if (element) element.textContent = value ?? "";
}

function setToolText(tool, value) {
  const element = document.querySelector(`[data-viewer-tool="${tool}"]`);
  if (element) element.textContent = value ?? "";
}

function escapeHtml(value) {
  return String(value ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

function findSectionIndexById(sectionId) {
  if (!sectionId) return -1;
  return lesson.sections.findIndex((section) => section.id === sectionId);
}

function currentStudentTask() {
  return lesson.student_tasks?.[state.activeStudentTaskIndex] || null;
}

function currentTeacherScriptStep() {
  return lesson.teacher_script?.[state.activeScriptIndex] || null;
}

function isStudentTaskCompleted(task) {
  if (!task) return false;
  return state.studentTaskStatus[task.id]?.status === "correct";
}

function allStudentTasksCompleted() {
  const tasks = Array.isArray(lesson.student_tasks) ? lesson.student_tasks : [];
  return tasks.length > 0 && tasks.every((task) => isStudentTaskCompleted(task));
}

function firstPendingStudentTaskIndex() {
  const tasks = Array.isArray(lesson.student_tasks) ? lesson.student_tasks : [];
  const index = tasks.findIndex((task) => !isStudentTaskCompleted(task));
  return index >= 0 ? index : Math.max(tasks.length - 1, 0);
}

function hasPendingStudentTasksAfter(index) {
  const tasks = Array.isArray(lesson.student_tasks) ? lesson.student_tasks : [];
  return tasks.some((task, taskIndex) => taskIndex > index && !isStudentTaskCompleted(task));
}

function hotspotIdForSection(section, index) {
  return section?.id || `hotspot-${index + 1}`;
}

function sectionIndexForHotspotId(hotspotId) {
  if (!hotspotId) return -1;
  return lesson.sections.findIndex((section, index) => hotspotIdForSection(section, index) === hotspotId);
}

function teacherScriptIndexForHotspotId(hotspotId) {
  if (!hotspotId) return -1;
  return Array.isArray(lesson.teacher_script)
    ? lesson.teacher_script.findIndex((item) => item.target_hotspot_id === hotspotId || item.section_id === hotspotId)
    : -1;
}

function clearStudentTaskAdvanceTimer() {
  if (!state.studentTaskAdvanceTimer) return;
  window.clearTimeout(state.studentTaskAdvanceTimer);
  state.studentTaskAdvanceTimer = null;
}

function advanceToNextStudentTask() {
  clearStudentTaskAdvanceTimer();
  const tasks = Array.isArray(lesson.student_tasks) ? lesson.student_tasks : [];
  if (!tasks.length) return;
  const nextIndex = tasks.findIndex((task, index) => index > state.activeStudentTaskIndex && !isStudentTaskCompleted(task));
  if (nextIndex >= 0) {
    state.activeStudentTaskIndex = nextIndex;
    state.lastSelectedHotspotId = "";
    const nextTask = tasks[nextIndex];
    const sectionIndex = sectionIndexForHotspotId(nextTask.target_hotspot_id);
    if (sectionIndex >= 0) {
      focusSection(sectionIndex);
      renderStudentTaskPanel();
      renderHotspots();
    } else {
      renderStudentTaskPanel();
      renderHotspots();
    }
    return;
  }
  state.pendingHotspotTargetId = "";
  state.lastSelectedHotspotId = "";
  renderStudentTaskPanel();
  renderHotspots();
}

function scheduleNextStudentTask() {
  clearStudentTaskAdvanceTimer();
  state.studentTaskAdvanceTimer = window.setTimeout(() => {
    advanceToNextStudentTask();
  }, 900);
}

function applyCameraView(cameraView, fallbackIndex = state.activeSectionIndex) {
  if (!state.camera || !state.controls || !state.focus) return;
  const view = cameraView && typeof cameraView === "object" ? cameraView : null;
  const center = view?.target
    ? new THREE.Vector3(...view.target)
    : state.focus.center.clone();

  if (view?.position) {
    state.camera.position.set(...view.position);
    state.controls.target.copy(center);
  } else if (Number.isFinite(view?.orbit_distance) || Number.isFinite(view?.azimuth) || Number.isFinite(view?.elevation)) {
    const distance = view?.orbit_distance || state.focus.distance;
    const azimuth = Number.isFinite(view?.azimuth)
      ? THREE.MathUtils.degToRad(view.azimuth)
      : (fallbackIndex / Math.max(lesson.sections.length, 1)) * Math.PI * 2;
    const elevationFactor = Number.isFinite(view?.elevation) ? view.elevation / 100 : 0.12;
    state.camera.position.set(
      center.x + Math.cos(azimuth) * distance,
      center.y + distance * elevationFactor,
      center.z + Math.sin(azimuth) * distance
    );
    state.controls.target.copy(center);
  } else {
    const angle = (fallbackIndex / Math.max(lesson.sections.length, 1)) * Math.PI * 2;
    const { distance } = state.focus;
    state.camera.position.set(
      center.x + Math.cos(angle) * distance,
      center.y + distance * 0.12,
      center.z + Math.sin(angle) * distance
    );
    state.controls.target.copy(center);
  }

  if (Number.isFinite(view?.zoom) && view.zoom > 0) {
    state.camera.zoom = view.zoom;
  } else {
    state.camera.zoom = 1;
  }
  state.camera.updateProjectionMatrix();
  state.controls.update();
  requestViewerRender();
}

function renderPlayerMode() {
  if (ui.teachingScriptDrawerButton) {
    ui.teachingScriptDrawerButton.hidden = false;
  }
}

function focusScriptStep(index) {
  const scripts = Array.isArray(lesson.teacher_script) ? lesson.teacher_script : [];
  if (!scripts.length) return;
  state.activeScriptIndex = Math.min(Math.max(index, 0), scripts.length - 1);
  state.activeStructureIndex = Math.min(state.activeScriptIndex, Math.max((lesson.key_structures?.length || 1) - 1, 0));
  state.activeQuestionIndex = Math.min(
    state.activeScriptIndex,
    Math.max(Math.max(lesson.teacher_script?.length || 0, lesson.key_structures?.length || 0, 1) - 1, 0)
  );
  const step = scripts[state.activeScriptIndex];
  const sectionIndex = findSectionIndexById(step.section_id);
  if (sectionIndex >= 0) {
    state.activeSectionIndex = sectionIndex;
    renderStructuredActiveSection();
  }
  applyCameraView(step.camera_view || lesson.sections[state.activeSectionIndex]?.camera_view, sectionIndex >= 0 ? sectionIndex : state.activeSectionIndex);
  renderStructuredKeyStructures();
  renderStructuredLessonQuestions();
  renderTeachingScriptPanel();
}

function renderTeachingScriptPanel() {
  if (!ui.teachingScriptPanel) return;
  const scripts = Array.isArray(lesson.teacher_script) ? lesson.teacher_script : [];
  if (!scripts.length) {
    ui.teachingScriptPanel.innerHTML = "";
    return;
  }
  const copy = lessonUiCopy(lesson.language);
  const step = scripts[Math.min(state.activeScriptIndex, scripts.length - 1)];
  const relatedStructure = lesson.key_structures?.[Math.min(state.activeScriptIndex, Math.max((lesson.key_structures?.length || 1) - 1, 0))] || null;
  const relatedSection = lesson.sections?.[state.activeSectionIndex] || null;
  const relatedPoints = Array.isArray(relatedSection?.key_points) ? relatedSection.key_points.filter((point) => point?.text) : [];
  ui.teachingScriptPanel.innerHTML = `
    <article class="script-card">
      <div class="script-step-meta">${escapeHtml(copy.scriptStep)} ${state.activeScriptIndex + 1}/${scripts.length}</div>
      <h3>${escapeHtml(step.title || `${copy.scriptStep} ${state.activeScriptIndex + 1}`)}</h3>
      ${step.narration ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Knowledge explanation" : "知识讲解")}</span>
          <p>${escapeHtml(step.narration)}</p>
        </div>
      ` : ""}
      ${relatedStructure ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Related structure" : "关联结构")}</span>
          <strong>${escapeHtml(relatedStructure.name)}</strong>
          ${relatedStructure.role ? `<p>${escapeHtml(relatedStructure.role)}</p>` : ""}
        </div>
      ` : ""}
      ${step.model_action ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(copy.scriptObserve)}</span>
          <p>${escapeHtml(step.model_action)}</p>
        </div>
      ` : ""}
      ${relatedPoints.length ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Extended points" : "展开要点")}</span>
          <ul class="card-bullets">
            ${relatedPoints.map((point) => `<li>${escapeHtml(point.text)}</li>`).join("")}
          </ul>
        </div>
      ` : ""}
      <div class="script-actions">
        <button class="script-action" type="button" data-script-nav="prev" ${state.activeScriptIndex === 0 ? "disabled" : ""}>${escapeHtml(copy.scriptPrevious)}</button>
        <button class="script-action" type="button" data-script-nav="next" ${state.activeScriptIndex >= scripts.length - 1 ? "disabled" : ""}>${escapeHtml(copy.scriptNext)}</button>
      </div>
    </article>
  `;
  ui.teachingScriptPanel.querySelectorAll("[data-script-nav]").forEach((button) => {
    button.addEventListener("click", () => {
      const delta = button.dataset.scriptNav === "next" ? 1 : -1;
      focusScriptStep(state.activeScriptIndex + delta);
    });
  });
}

function themeIndexForSequence(index, total, themeCount) {
  if (!themeCount) return 0;
  if (total <= 1) return 0;
  return Math.max(0, Math.min(themeCount - 1, Math.floor((index / total) * themeCount)));
}

function structureThemeBundle(index = state.activeStructureIndex) {
  const structures = Array.isArray(lesson.key_structures) ? lesson.key_structures : [];
  if (!structures.length) return null;
  const normalizedIndex = Math.min(Math.max(index, 0), structures.length - 1);
  const structure = structures[normalizedIndex];
  const scripts = Array.isArray(lesson.teacher_script) ? lesson.teacher_script : [];
  const script = scripts[normalizedIndex] || scripts[Math.min(normalizedIndex, Math.max(scripts.length - 1, 0))] || null;
  const section = script
    ? lesson.sections?.[findSectionIndexById(script.section_id)]
    : lesson.sections?.[normalizedIndex] || null;
  return { index: normalizedIndex, structure, script, section };
}

function groupItemsByTheme(items, themeCount) {
  if (!themeCount) return [];
  return Array.from({ length: themeCount }, (_, themeIndex) => ({
    themeIndex,
    items: items.filter((_, index) => themeIndexForSequence(index, items.length, themeCount) === themeIndex)
  }));
}

function questionThemeBundle(index = state.activeQuestionIndex) {
  const questions = Array.isArray(lesson.lesson_questions) ? lesson.lesson_questions.map(displayQuestionText).filter(Boolean) : [];
  if (!questions.length) return null;
  const scripts = Array.isArray(lesson.teacher_script) ? lesson.teacher_script : [];
  const themeCount = Math.max(scripts.length || 0, lesson.key_structures?.length || 0, 1);
  const groupedQuestions = groupItemsByTheme(questions, themeCount);
  const groupedQuizItems = groupItemsByTheme(lesson.quiz || [], themeCount);
  const normalizedIndex = Math.min(Math.max(index, 0), themeCount - 1);
  const script = scripts[normalizedIndex] || null;
  const structure = lesson.key_structures?.[normalizedIndex] || null;
  const questionItems = groupedQuestions[normalizedIndex]?.items || [];
  const quizItems = (groupedQuizItems[normalizedIndex]?.items || []).map((item) => ({
    item,
    quizIndex: (lesson.quiz || []).findIndex((candidate) => candidate === item),
    quizState: state.quizStates[(lesson.quiz || []).findIndex((candidate) => candidate === item)] || createDefaultQuizState(item?.type)
  })).filter((entry) => entry.quizIndex >= 0);
  return {
    index: normalizedIndex,
    script,
    structure,
    questionItems,
    quizItems,
    total: themeCount
  };
}

function renderIntegratedQuestionChoice(item, quizState, index) {
  if (!item?.options?.length) return "";
  const isLocked = quizState.status === "reviewed" && Number.isInteger(item.answer);
  return `
    <div class="quiz-options">
      ${(item.options || []).map((option, optionIndex) => `
        <button
          class="quiz-option ${buildOptionClass(item, quizState, optionIndex)}"
          type="button"
          data-quiz-option="true"
          data-quiz-index="${index}"
          data-option-index="${optionIndex}"
          ${isLocked ? "disabled" : ""}
        >
          ${escapeHtml(option)}
        </button>
      `).join("")}
    </div>
  `;
}

function focusStructureTheme(index) {
  const structures = Array.isArray(lesson.key_structures) ? lesson.key_structures : [];
  if (!structures.length) return;
  state.activeStructureIndex = Math.min(Math.max(index, 0), structures.length - 1);
  if (Array.isArray(lesson.teacher_script) && lesson.teacher_script[state.activeStructureIndex]) {
    state.activeScriptIndex = state.activeStructureIndex;
  }
  state.activeQuestionIndex = Math.min(
    state.activeStructureIndex,
    Math.max(Math.max(lesson.teacher_script?.length || 0, lesson.key_structures?.length || 0, 1) - 1, 0)
  );
  renderStructuredKeyStructures();
  renderTeachingScriptPanel();
  renderStructuredLessonQuestions();
}

function focusQuestionTheme(index) {
  const themeCount = Math.max(lesson.teacher_script?.length || 0, lesson.key_structures?.length || 0, 1);
  if (!(Array.isArray(lesson.lesson_questions) && lesson.lesson_questions.length)) return;
  state.activeQuestionIndex = Math.min(Math.max(index, 0), themeCount - 1);
  state.activeScriptIndex = Math.min(state.activeQuestionIndex, Math.max((lesson.teacher_script?.length || 1) - 1, 0));
  state.activeStructureIndex = Math.min(state.activeQuestionIndex, Math.max((lesson.key_structures?.length || 1) - 1, 0));
  renderTeachingScriptPanel();
  renderStructuredKeyStructures();
  renderStructuredLessonQuestions();
  renderQuizPanel();
}

function syncStudentTaskTarget() {
  const task = currentStudentTask();
  state.pendingHotspotTargetId = task?.type === "hotspot_identification" ? task.target_hotspot_id : "";
}

function renderStudentTaskPanel() {
  if (!ui.studentTaskPanel) return;
  const copy = lessonUiCopy(lesson.language);
  const tasks = Array.isArray(lesson.student_tasks) ? lesson.student_tasks : [];
  if (!tasks.length || allStudentTasksCompleted()) {
    state.pendingHotspotTargetId = "";
    ui.studentTaskPanel.innerHTML = `
      <article class="task-card">
        <p class="task-feedback success">${escapeHtml(copy.taskCompleted)}</p>
        <div class="task-actions">
          <button class="task-action" type="button" data-task-restart="true">${escapeHtml(copy.quizRestart || copy.taskContinue)}</button>
        </div>
      </article>
    `;
    ui.studentTaskPanel.querySelectorAll("[data-task-restart]").forEach((button) => {
      button.addEventListener("click", () => {
        state.studentTaskStatus = {};
        state.activeStudentTaskIndex = 0;
        state.lastSelectedHotspotId = "";
        syncStudentTaskTarget();
        renderStudentTaskPanel();
        renderHotspots();
      });
    });
    return;
  }

  const task = currentStudentTask();
  if (!task) return;
  const status = state.studentTaskStatus[task.id] || { status: "idle" };
  const isDone = isStudentTaskCompleted(task);
  const hasNextTask = state.activeStudentTaskIndex < tasks.length - 1;
  syncStudentTaskTarget();
  ui.studentTaskPanel.innerHTML = `
    <article class="task-card">
      <div class="task-meta">${state.activeStudentTaskIndex + 1}/${tasks.length}</div>
      <h3>${escapeHtml(task.title || copy.studentTaskDrawer)}</h3>
      <p><strong>${escapeHtml(copy.taskPrompt)}:</strong> ${escapeHtml(task.prompt)}</p>
      ${task.target_hotspot_id ? `<p><strong>${escapeHtml(copy.taskTarget)}:</strong> ${escapeHtml(task.target_hotspot_id)}</p>` : ""}
      <p>${escapeHtml(copy.taskInstruction)}</p>
      <p class="task-feedback ${status.status === "correct" ? "success" : status.status === "incorrect" ? "warning" : ""}">
        ${escapeHtml(
          status.status === "correct"
            ? (task.feedback_correct || copy.taskCorrect)
            : status.status === "incorrect"
              ? (task.feedback_incorrect || copy.taskIncorrect)
              : ""
        )}
      </p>
      <div class="task-actions">
        <button class="task-action" type="button" data-task-next="true" ${!hasNextTask ? "disabled" : ""}>${escapeHtml(copy.taskContinue)}</button>
      </div>
    </article>
  `;
  ui.studentTaskPanel.querySelectorAll("[data-task-next]").forEach((button) => {
    button.addEventListener("click", () => {
      advanceToNextStudentTask();
    });
  });
}

function setPlayerMode(mode) {
  if (mode) {
    renderPlayerMode();
    renderHotspots();
  }
}

function renderLessonContent() {
  document.documentElement.lang = lesson.language === "zh-CN" ? "zh-CN" : "en";
  document.title = lesson.title || document.title;
  const copy = lessonUiCopy(lesson.language);
  state.activeQuizIndex = findNextPendingQuizIndex();
  setText("modelType", lesson.model?.path?.split("/").pop() || copy.noModelLabel);
  setToolText("rotate", copy.toolRotate);
  setToolText("zoom", copy.toolZoom);
  setToolText("reset", copy.toolReset);
  setToolText("zoom", getNextZoomAction() === "in" ? copy.toolZoom : copy.toolZoomOut);
  setText("brightnessLabel", copy.brightnessLabel);
  setText("openImportPanelButton", copy.replaceModel);
  setText("downloadModelLink", copy.downloadModel);
  setText("teachingScriptDrawerButton", copy.teachingScriptDrawer);
  setText("keyStructuresDrawerButton", copy.keyStructuresDrawer);
  setText("lessonQuestionsDrawerButton", copy.lessonQuestionsDrawer);
  setText("keyStructuresDrawerTitle", copy.keyStructuresDrawer);
  setText("teachingScriptDrawerTitle", copy.teachingScriptDrawer);
  setText("lessonQuestionsDrawerTitle", copy.lessonQuestionsDrawer);
  setText("importPanelLabel", copy.replaceModel);
  setText("importPanelTitle", copy.importPanelTitle);
  setText("importPanelCopy", copy.importPanelCopy);
  setText("closeImportPanelButton", copy.close);
  setText("dropzoneTitle", copy.dropzoneTitle);
  setText("dropzoneHint", copy.dropzoneHint);
  setText("importModelButton", copy.chooseModelFiles);
  setText("modelSearchLink", copy.openImportGuide);
  document.querySelectorAll("[data-close-drawer]").forEach((button) => {
    button.textContent = copy.close;
  });
  restoreDefaultModelDownloadLink();
  renderStructuredKeyStructures();
  renderStructuredLessonQuestions();
  renderStructuredActiveSection();
  renderQuizPanel();
  renderPlayerMode();
  renderTeachingScriptPanel();
  renderAnimationButtonState();
  renderNarrationButtonState();

  if (ui.modelSearchLink) {
    ui.modelSearchLink.href = "./README.md";
  }
}

function renderSectionNav() {
  return;
}

function renderQuizPanel() {
  const container = document.getElementById("quizPanel");
  if (!container) return;
  container.hidden = true;
  container.innerHTML = "";
}

function renderStructuredCourseOverview() {
  return;
}

function renderStructuredKeyStructures() {
  const container = document.getElementById("keyStructures");
  if (!container) return;
  const bundle = structureThemeBundle();
  if (!bundle) {
    container.innerHTML = "";
    return;
  }
  const copy = lessonUiCopy(lesson.language);
  const relatedPoints = Array.isArray(bundle.section?.key_points) ? bundle.section.key_points.filter((point) => point?.text) : [];
  container.innerHTML = `
    <article class="structure-card structure-card--themed">
      <div class="structure-copy">
        <div class="structure-topline">
          <div class="structure-index">${String(bundle.index + 1).padStart(2, "0")}</div>
          <div>
            <div class="drawer-theme-kicker">${escapeHtml(bundle.script?.title || (lesson.language === "en" ? "Theme" : "主题"))}</div>
            <h3>${escapeHtml(bundle.structure.name)}</h3>
          </div>
        </div>
        ${bundle.structure.role ? `
          <div class="structure-role card-knowledge-block">
            <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Knowledge explanation" : "知识讲解")}</span>
            <p>${escapeHtml(bundle.structure.role)}</p>
          </div>
        ` : ""}
        ${bundle.structure.look_for ? `
          <div class="structure-look-for card-knowledge-block">
            <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Observation evidence" : "观察证据")}</span>
            <p>${escapeHtml(bundle.structure.look_for)}</p>
          </div>
        ` : ""}
        ${relatedPoints.length ? `
          <div class="card-knowledge-block">
            <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Expanded content" : "展开内容")}</span>
            <ul class="card-bullets">
              ${relatedPoints.map((point) => `<li>${escapeHtml(point.text)}</li>`).join("")}
            </ul>
          </div>
        ` : ""}
        <div class="script-actions">
          <button class="script-action" type="button" data-structure-nav="prev" ${bundle.index === 0 ? "disabled" : ""}>${escapeHtml(copy.scriptPrevious)}</button>
          <button class="script-action" type="button" data-structure-nav="next" ${bundle.index >= (lesson.key_structures.length - 1) ? "disabled" : ""}>${escapeHtml(copy.scriptNext)}</button>
        </div>
      </div>
    </article>
  `;
  container.querySelectorAll("[data-structure-nav]").forEach((button) => {
    button.addEventListener("click", () => {
      const delta = button.dataset.structureNav === "next" ? 1 : -1;
      focusStructureTheme(state.activeStructureIndex + delta);
    });
  });
}

function renderStructuredLessonQuestions() {
  const container = document.getElementById("lessonQuestions");
  if (!container) return;
  clearQuizAdvanceTimer();
  const bundle = questionThemeBundle();
  const copy = lessonUiCopy(lesson.language);
  if (!bundle) {
    container.innerHTML = `<p>${escapeHtml(copy.noQuestions)}</p>`;
    return;
  }
  const { index, script, structure, questionItems, quizItems, total } = bundle;
  const progressWidth = `${((index + 1) / total) * 100}%`;
  container.innerHTML = `
    <article class="script-card quiz-card quiz-card--single quiz-card--integrated">
      <div class="script-step-meta">${escapeHtml(copy.scriptStep)} ${index + 1}/${total}</div>
      <h3>${escapeHtml(script?.title || structure?.name || copy.lessonQuestionsDrawer)}</h3>
      <div class="quiz-progress">
        <span class="quiz-progress-label">${escapeHtml(copy.quizProgressPrefix)} ${index + 1}/${total} ${escapeHtml(copy.quizProgressSuffix)}</span>
        <div class="quiz-progress-bar"><span style="width:${progressWidth}"></span></div>
      </div>
      ${structure ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Related structure" : "关联结构")}</span>
          <strong>${escapeHtml(structure.name)}</strong>
          ${structure.role ? `<p>${escapeHtml(structure.role)}</p>` : ""}
        </div>
      ` : ""}
      ${questionItems.length ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Lesson questions" : "课程问题")}</span>
          <ol class="card-bullets card-bullets--numbered">
            ${questionItems.map((question) => `<li>${escapeHtml(question)}</li>`).join("")}
          </ol>
        </div>
      ` : ""}
      ${quizItems.length ? `
        <div class="card-knowledge-block">
          <span class="overview-label">${escapeHtml(lesson.language === "en" ? "Single-choice checks" : "单选检测")}</span>
          <div class="integrated-quiz-stack">
            ${quizItems.map(({ item, quizIndex, quizState }) => `
              <section class="integrated-quiz-item">
                <p class="integrated-quiz-question">${escapeHtml(item.question)}</p>
                <div class="quiz-note">${escapeHtml(copy.quizSingleHint)}</div>
                ${renderIntegratedQuestionChoice(item, quizState, quizIndex)}
                ${buildQuizFeedback(item, quizState)}
              </section>
            `).join("")}
          </div>
        </div>
      ` : ""}
      <div class="script-actions">
        <button class="script-action" type="button" data-question-nav="prev" ${index === 0 ? "disabled" : ""}>${escapeHtml(copy.scriptPrevious)}</button>
        <button class="script-action" type="button" data-question-nav="next" ${index >= total - 1 ? "disabled" : ""}>${escapeHtml(copy.scriptNext)}</button>
      </div>
    </article>
  `;
  container.querySelectorAll("[data-question-nav]").forEach((button) => {
    button.addEventListener("click", () => {
      const delta = button.dataset.questionNav === "next" ? 1 : -1;
      focusQuestionTheme(state.activeQuestionIndex + delta);
    });
  });
  container.querySelectorAll("[data-quiz-option]").forEach((button) => {
    button.addEventListener("click", () => selectMultipleChoice(
      Number(button.dataset.quizIndex),
      Number(button.dataset.optionIndex)
    ));
  });
  container.querySelectorAll("[data-quiz-reset]").forEach((button) => {
    button.addEventListener("click", resetQuizFlow);
  });
}

function renderStructuredActiveSection() {
  const section = lesson.sections[state.activeSectionIndex];
  if (!section) return;
  const scriptIndex = Array.isArray(lesson.teacher_script)
    ? lesson.teacher_script.findIndex((item) => item.section_id === section.id)
    : -1;
  if (scriptIndex >= 0) {
    state.activeScriptIndex = scriptIndex;
  }
  renderHotspots();
  renderTeachingScriptPanel();
}

function formatQuestionCountLabel(language, count) {
  if (language === "en") return `${count} lesson questions`;
  if (language === "bilingual") return `${count} lesson questions / ${count} 个课程问题`;
  return `${count} 个课程问题`;
}

function renderMultipleChoice(item, quizState, index) {
  const isLocked = quizState.status === "reviewed";

  return `
    <div class="quiz-options">
      ${(item.options || []).map((option, optionIndex) => `
        <button
          class="quiz-option ${buildOptionClass(item, quizState, optionIndex)}"
          type="button"
          data-quiz-option="true"
          data-quiz-index="${index}"
          data-option-index="${optionIndex}"
          ${isLocked ? "disabled" : ""}
        >
          ${escapeHtml(option)}
        </button>
      `).join("")}
    </div>
  `;
}

function buildOptionClass(item, quizState, optionIndex) {
  const classes = [];
  const hasCorrectAnswer = Number.isInteger(item.answer);
  if (quizState.selectedIndex === optionIndex) classes.push("selected");
  if (hasCorrectAnswer && (quizState.status === "submitted" || quizState.status === "reviewed")) {
    if (optionIndex === item.answer) classes.push("correct");
    if (quizState.selectedIndex === optionIndex && optionIndex !== item.answer) classes.push("incorrect");
  }
  return classes.join(" ");
}

function buildQuizFeedback(item, quizState) {
  const isEnglish = lesson.language === "en";
  if (quizState.status === "not_started") {
    return "";
  }
  const hasCorrectAnswer = Number.isInteger(item.answer);
  if (!hasCorrectAnswer) {
    return `
      <div class="quiz-feedback muted">
        <strong>${isEnglish ? "Answer recorded" : "已记录答案"}</strong>
        <p>${isEnglish ? "This question is currently being used as a discussion prompt, so your choice is kept without auto-grading." : "这道题当前按讨论题处理，因此会保留你的选择，但不会自动判定对错。"}</p>
        ${item.explanation ? `<p>${escapeHtml(item.explanation)}</p>` : ""}
      </div>
    `;
  }
  const isCorrect = quizState.selectedIndex === item.answer;
  const correctOption = item.options?.[item.answer] ?? "";
  return `
    <div class="quiz-feedback ${isCorrect ? "success" : "warning"}">
      <strong>${isCorrect ? (isEnglish ? "Correct" : "回答正确") : (isEnglish ? "Not quite yet" : "还需要再想一想")}</strong>
      <p>${isEnglish ? "Correct answer" : "正确答案"}: ${escapeHtml(correctOption)}</p>
      ${item.explanation ? `<p>${escapeHtml(item.explanation)}</p>` : ""}
    </div>
  `;
}

function formatQuizState(status) {
  if (lesson.language !== "en") {
    return {
      not_started: "未开始",
      in_progress: "作答中",
      submitted: "已提交",
      reviewed: "已讲解"
    }[status] || status.replaceAll("_", " ");
  }
  return status.replaceAll("_", " ");
}

function selectMultipleChoice(quizIndex, optionIndex) {
  if (state.quizStates[quizIndex]?.status === "reviewed") return;
  state.quizStates[quizIndex] = {
    ...state.quizStates[quizIndex],
    status: "reviewed",
    selectedIndex: optionIndex
  };
  state.activeQuestionIndex = themeIndexForSequence(
    quizIndex,
    lesson.quiz.length,
    Math.max(lesson.teacher_script?.length || 0, lesson.key_structures?.length || 0, 1)
  );
  state.activeQuizIndex = quizIndex;
  persistQuizStates();
  renderStructuredLessonQuestions();
  renderQuizPanel();
}

function scheduleNextQuiz(currentIndex) {
  clearQuizAdvanceTimer();
  state.quizAdvanceTimer = window.setTimeout(() => {
    const nextIndex = findNextPendingQuizIndex(currentIndex + 1);
    state.activeQuizIndex = nextIndex;
    state.activeQuestionIndex = nextIndex >= lesson.lesson_questions.length
      ? Math.max(lesson.lesson_questions.length - 1, 0)
      : nextIndex;
    renderStructuredLessonQuestions();
    renderQuizPanel();
  }, 900);
}

function clearQuizAdvanceTimer() {
  if (!state.quizAdvanceTimer) return;
  window.clearTimeout(state.quizAdvanceTimer);
  state.quizAdvanceTimer = null;
}

function findNextPendingQuizIndex(startIndex = 0) {
  for (let index = startIndex; index < lesson.quiz.length; index += 1) {
    if ((state.quizStates[index]?.status || "not_started") !== "reviewed") {
      return index;
    }
  }
  for (let index = 0; index < startIndex; index += 1) {
    if ((state.quizStates[index]?.status || "not_started") !== "reviewed") {
      return index;
    }
  }
  return lesson.quiz.length;
}

function resetQuizFlow() {
  clearQuizAdvanceTimer();
  state.quizStates = hydrateQuizStates().map((item) => ({
    ...item,
    status: "not_started",
    selectedIndex: null
  }));
  state.activeQuizIndex = 0;
  state.activeQuestionIndex = 0;
  persistQuizStates();
  renderStructuredLessonQuestions();
  renderQuizPanel();
}

function hotspotPosition(section, index, total) {
  if (section.hotspot) {
    return { left: section.hotspot.x, top: section.hotspot.y };
  }
  const positions = [
    { left: 50, top: 16 },
    { left: 82, top: 30 },
    { left: 82, top: 68 },
    { left: 50, top: 84 },
    { left: 18, top: 68 },
    { left: 18, top: 30 }
  ];
  return positions[index % Math.max(total, 1)] || positions[0];
}

function renderHotspots() {
  const layer = ui.hotspotLayer || document.getElementById("hotspotLayer");
  if (!layer) return;
  layer.hidden = true;
  layer.innerHTML = "";
}

function closeDrawers() {
  state.activeDrawerId = "";
  ui.drawerPanels.forEach((panel) => {
    hideDrawerPanel(panel);
  });
  ui.keyStructuresDrawerButton?.setAttribute("aria-expanded", "false");
  ui.teachingScriptDrawerButton?.setAttribute("aria-expanded", "false");
  ui.lessonQuestionsDrawerButton?.setAttribute("aria-expanded", "false");
}

function showDrawerPanel(panel) {
  if (!panel) return;
  if (panel.__drawerHideTimer) {
    window.clearTimeout(panel.__drawerHideTimer);
    panel.__drawerHideTimer = null;
  }
  panel.hidden = false;
  window.requestAnimationFrame(() => {
    panel.classList.add("is-open");
  });
}

function hideDrawerPanel(panel, immediate = false) {
  if (!panel) return;
  if (panel.__drawerHideTimer) {
    window.clearTimeout(panel.__drawerHideTimer);
    panel.__drawerHideTimer = null;
  }
  panel.classList.remove("is-open");
  if (immediate) {
    panel.hidden = true;
    return;
  }
  panel.__drawerHideTimer = window.setTimeout(() => {
    if (!panel.classList.contains("is-open")) {
      panel.hidden = true;
    }
    panel.__drawerHideTimer = null;
  }, DRAWER_TRANSITION_MS);
}

function openDrawer(drawerId) {
  state.activeDrawerId = drawerId;
  toggleImportPanel(false);
  ui.drawerPanels.forEach((panel) => {
    if (panel.id === drawerId) {
      showDrawerPanel(panel);
      return;
    }
    hideDrawerPanel(panel);
  });
  ui.keyStructuresDrawerButton?.setAttribute("aria-expanded", String(drawerId === "keyStructuresDrawer"));
  ui.teachingScriptDrawerButton?.setAttribute("aria-expanded", String(drawerId === "teachingScriptDrawer"));
  ui.lessonQuestionsDrawerButton?.setAttribute("aria-expanded", String(drawerId === "lessonQuestionsDrawer"));
}

function toggleDrawer(drawerId) {
  if (state.activeDrawerId === drawerId) {
    closeDrawers();
    return;
  }
  openDrawer(drawerId);
}

function narrationScriptItems(source) {
  return Array.isArray(source) ? source.filter((item) => item && typeof item === "object") : [];
}

function lessonVoiceForText(text) {
  const content = String(text || "").trim();
  if (!content) return lesson.language === "zh-CN" ? "zh-CN" : "en-US";
  if (/[\u4e00-\u9fff]/.test(content) && /[A-Za-z]/.test(content) && lesson.language === "bilingual") {
    return /[\u4e00-\u9fff]/.test(content[0]) ? "zh-CN" : "en-US";
  }
  if (/[\u4e00-\u9fff]/.test(content)) return "zh-CN";
  return "en-US";
}

function compactNarrationText(text, limit) {
  const normalized = String(text || "").replace(/\s+/g, " ").trim();
  if (!normalized) return "";
  return normalized.length > limit ? `${normalized.slice(0, limit - 1).trim()}…` : normalized;
}

function buildNarrationQueue() {
  const preferred = narrationScriptItems(lesson.teacher_script);
  const fallbackNarration = narrationScriptItems(lesson.narration_script);
  const sectionScripts = narrationScriptItems(
    lesson.sections.flatMap((section) => Array.isArray(section.teacher_script) ? section.teacher_script : section.teacher_script ? [section.teacher_script] : [])
  );
  const scriptSource = preferred.length ? preferred : fallbackNarration.length ? fallbackNarration : sectionScripts;

  if (scriptSource.length) {
    return scriptSource.map((item, index) => {
      const lines = [
        item.title,
        item.narration,
        item.model_action,
        item.question
      ].filter(Boolean);
      const text = compactNarrationText(lines.join(" "), lesson.language === "zh-CN" ? 500 : 900);
      return text ? { id: item.section_id || `script-${index + 1}`, text, lang: lessonVoiceForText(text) } : null;
    }).filter(Boolean);
  }

  const sectionQueue = lesson.sections.map((section, index) => {
    const pointText = (section.key_points || []).map((point) => point.text).filter(Boolean).slice(0, 2).join(" ");
    const text = compactNarrationText(
      [section.title, section.description, pointText].filter(Boolean).join(" "),
      lesson.language === "zh-CN" ? 500 : 900
    );
    return text ? { id: section.id || `section-${index + 1}`, text, lang: lessonVoiceForText(text) } : null;
  }).filter(Boolean);

  if (sectionQueue.length) return sectionQueue;

  const objectiveText = compactNarrationText(
    [lesson.title, ...(lesson.teaching_objectives || []).slice(0, 3)].filter(Boolean).join(" "),
    lesson.language === "zh-CN" ? 500 : 900
  );
  return objectiveText ? [{ id: "lesson-summary", text: objectiveText, lang: lessonVoiceForText(objectiveText) }] : [];
}

function stopNarration(resetIndex = true) {
  if (window.speechSynthesis?.cancel) {
    window.speechSynthesis.cancel();
  }
  state.narration.playing = false;
  state.narration.paused = false;
  state.narration.currentUtterance = null;
  if (resetIndex) state.narration.index = 0;
  renderNarrationButtonState();
}

function speakNextNarrationItem() {
  if (!state.narration.supported) return;
  if (state.narration.index >= state.narration.queue.length) {
    stopNarration(true);
    return;
  }

  const item = state.narration.queue[state.narration.index];
  window.__LESSONS3D_LAST_NARRATION_TEXT__ = item.text;
  const utterance = new window.SpeechSynthesisUtterance(item.text);
  utterance.lang = item.lang || (lesson.language === "zh-CN" ? "zh-CN" : "en-US");
  utterance.onend = () => {
    state.narration.currentUtterance = null;
    state.narration.index += 1;
    if (state.narration.paused) return;
    speakNextNarrationItem();
  };
  utterance.onerror = () => {
    stopNarration(true);
  };

  state.narration.currentUtterance = utterance;
  state.narration.playing = true;
  state.narration.paused = false;
  renderNarrationButtonState();
  window.speechSynthesis.speak(utterance);
}

function startNarration() {
  if (!state.narration.supported) {
    renderNarrationButtonState();
    return;
  }
  stopNarration(true);
  state.narration.queue = buildNarrationQueue();
  state.narration.index = 0;
  if (!state.narration.queue.length) {
    renderNarrationButtonState();
    return;
  }
  speakNextNarrationItem();
}

function pauseNarration() {
  if (!state.narration.supported || !window.speechSynthesis?.pause) return;
  window.speechSynthesis.pause();
  state.narration.paused = true;
  state.narration.playing = true;
  renderNarrationButtonState();
}

function resumeNarration() {
  if (!state.narration.supported || !window.speechSynthesis?.resume) return;
  window.speechSynthesis.resume();
  state.narration.paused = false;
  state.narration.playing = true;
  renderNarrationButtonState();
}

function renderNarrationButtonState() {
  const button = ui.narrationButton;
  if (!button) return;
  const copy = lessonUiCopy(lesson.language);
  if (!state.narration.supported) {
    button.textContent = copy.toolNarrationUnavailable;
    button.disabled = true;
    button.removeAttribute("aria-pressed");
    return;
  }
  button.disabled = false;
  if (state.narration.playing && !state.narration.paused) {
    button.textContent = copy.toolNarrationPause;
    button.setAttribute("aria-pressed", "true");
    return;
  }
  if (state.narration.paused) {
    button.textContent = copy.toolNarrationResume;
    button.setAttribute("aria-pressed", "true");
    return;
  }
  button.textContent = copy.toolNarration;
  button.setAttribute("aria-pressed", "false");
}

function getAnimationButtonCopy() {
  const copy = lessonUiCopy(lesson.language);
  return {
    play: copy.toolAnimationPlay || (lesson.language === "zh-CN" ? "播放动画" : "Play animation"),
    pause: copy.toolAnimationPause || (lesson.language === "zh-CN" ? "暂停动画" : "Pause animation")
  };
}

function renderAnimationButtonState() {
  const button = ui.animationButton;
  if (!button) return;
  const hasAnimations = state.animationActions.length > 0;
  button.hidden = !hasAnimations;
  button.disabled = !hasAnimations;
  button.textContent = state.animationPlaying
    ? getAnimationButtonCopy().pause
    : getAnimationButtonCopy().play;
  button.setAttribute("aria-pressed", String(hasAnimations && state.animationPlaying));
  button.classList.toggle("is-active", hasAnimations && state.animationPlaying);
}

function requestViewerRender() {
  state.viewerNeedsRender = true;
  if (typeof state.requestViewerRender === "function") {
    state.requestViewerRender();
  }
}

function isRotateActive() {
  if (state.viewerBackend === "o3dv") {
    return state.o3dvAutoRotate;
  }
  return !!state.controls?.autoRotate;
}

function requestO3dvRender() {
  state.o3dvViewer?.Render?.();
}

function cancelO3dvAutoRotate() {
  if (state.o3dvRotateRaf) {
    window.cancelAnimationFrame(state.o3dvRotateRaf);
    state.o3dvRotateRaf = null;
  }
}

function stepO3dvAutoRotate() {
  state.o3dvRotateRaf = null;
  if (
    state.viewerBackend !== "o3dv"
    || !state.o3dvAutoRotate
    || !state.o3dvViewer
    || document.hidden
    || !state.viewerVisible
  ) {
    return;
  }

  const camera = state.o3dvViewer.GetCamera?.();
  if (!camera?.eye || !camera?.center) return;

  const center = camera.center;
  const dx = camera.eye.x - center.x;
  const dz = camera.eye.z - center.z;
  const angle = 0.0048;
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  camera.eye.x = center.x + (dx * cos) - (dz * sin);
  camera.eye.z = center.z + (dx * sin) + (dz * cos);
  state.o3dvViewer.SetCamera?.(camera);
  requestO3dvRender();
  state.o3dvRotateRaf = window.requestAnimationFrame(stepO3dvAutoRotate);
}

function syncO3dvAutoRotate() {
  cancelO3dvAutoRotate();
  if (state.viewerBackend === "o3dv" && state.o3dvAutoRotate) {
    state.o3dvRotateRaf = window.requestAnimationFrame(stepO3dvAutoRotate);
  }
}

function getO3dvEnvironmentSettings() {
  if (!O3DV?.EnvironmentSettings) return null;
  const embeddedEnv = window.__LESSONS3D_O3DV_ENVMAP__ || null;
  const faceUrls = embeddedEnv
    ? [
        embeddedEnv.posx,
        embeddedEnv.negx,
        embeddedEnv.posy,
        embeddedEnv.negy,
        embeddedEnv.posz,
        embeddedEnv.negz
      ]
    : (() => {
        const envBase = `${resolveViewerAssetPath("o3dv/envmaps/fishermans_bastion/")}`;
        return [
          `${envBase}posx.jpg`,
          `${envBase}negx.jpg`,
          `${envBase}posy.jpg`,
          `${envBase}negy.jpg`,
          `${envBase}posz.jpg`,
          `${envBase}negz.jpg`
        ];
      })();
  return new O3DV.EnvironmentSettings([
    ...faceUrls
  ], false);
}

function createO3dvFilesFromEmbeddedDescriptor(descriptor) {
  if (!descriptor) return [];
  if (descriptor.kind === "glb") {
    return [
      new File(
        [decodeBase64(descriptor.base64)],
        descriptor.fileName || "embedded-model.glb",
        { type: descriptor.mimeType || "model/gltf-binary" }
      )
    ];
  }

  return (descriptor.files || []).map((file) => new File(
    [decodeBase64(file.base64)],
    normalizePath(file.relativePath) || file.relativePath || "model.bin",
    { type: file.mimeType || inferMimeType(file.relativePath) }
  ));
}

function getO3dvBoundingSphere() {
  return state.o3dvViewer?.GetBoundingSphere?.(() => true) || null;
}

function fitO3dvToWindow() {
  const sphere = getO3dvBoundingSphere();
  if (sphere) {
    state.o3dvViewer?.FitSphereToWindow?.(sphere, false);
  }
  requestO3dvRender();
}

function buildO3dvSuccessMessage(label, capabilities = null) {
  const prefix = uiCopy.importSuccessPrefix || (lesson.language === "zh-CN" ? "已加载" : "Loaded");
  const compatibilityNote = buildLegacyMaterialCompatibilityNote(capabilities);
  return [`${prefix}: ${label}`, compatibilityNote].filter(Boolean).join(" / ");
}

function resetLoadedAnimationState() {
  state.animationActions.forEach((action) => {
    try {
      action.stop?.();
    } catch (error) {
      console.warn("Failed to stop animation action", error);
    }
  });
  state.animationActions = [];
  state.animationMixer = null;
  state.animationClipCount = 0;
  state.animationPlaying = true;
  state.animationLastTickAt = 0;
  renderAnimationButtonState();
}

function configureLoadedAnimations(gltf, object) {
  resetLoadedAnimationState();
  state.animationAnnotationHint = "";
  const clips = gltf?.animations || [];
  if (!object || !clips.length || !THREE?.AnimationMixer) return;

  const mixer = new THREE.AnimationMixer(object);
  const actions = clips
    .map((clip) => {
      try {
        const action = mixer.clipAction(clip);
        action.reset();
        action.setLoop(THREE.LoopRepeat, Infinity);
        action.play();
        return action;
      } catch (error) {
        console.warn("Failed to prepare animation clip", clip?.name, error);
        return null;
      }
    })
    .filter(Boolean);

  if (!actions.length) return;
  state.animationMixer = mixer;
  state.animationActions = actions;
  state.animationClipCount = actions.length;
  state.animationPlaying = true;
  state.animationLastTickAt = 0;
  state.animationMixer.setTime?.(0);
  prepareLoadedObjectForDisplay(object);
  renderAnimationButtonState();
}

function toggleLoadedAnimations() {
  if (!state.animationActions.length) {
    renderAnimationButtonState();
    return;
  }
  state.animationPlaying = !state.animationPlaying;
  state.animationActions.forEach((action) => {
    action.paused = !state.animationPlaying;
    if (state.animationPlaying) {
      action.play?.();
    }
  });
  state.animationLastTickAt = 0;
  renderAnimationButtonState();
  requestViewerRender();
}

function buildCapabilityNote(capabilities = null) {
  if (!capabilities) return "";
  const notes = [];
  if (capabilities.animationCount > 0) {
    notes.push(
      lesson.language === "zh-CN"
        ? `${capabilities.animationCount} 段动画已自动播放`
        : `${capabilities.animationCount} animation${capabilities.animationCount > 1 ? "s" : ""} autoplaying`
    );
  }
  if (capabilities.annotationStatus === "missing") {
    notes.push(
      lesson.language === "zh-CN"
        ? "文件内未发现可直接展示的标注元数据"
        : "no embedded annotation metadata found"
    );
  }
  return notes.join(" / ");
}

function buildLegacyMaterialCompatibilityNote(capabilities = null) {
  return "";
}

function shouldUseThreeAnimationViewer(capabilities = null) {
  return Boolean(capabilities?.animationCount > 0);
}

function inspectGltfJson(json) {
  const annotationPattern = /(annot|annotation|hotspot|label|callout|marker)/i;
  const hasAnnotationData = (value) => {
    if (!value) return false;
    if (typeof value === "string") return annotationPattern.test(value);
    if (Array.isArray(value)) return value.some((entry) => hasAnnotationData(entry));
    if (typeof value === "object") {
      return Object.entries(value).some(([key, entry]) => annotationPattern.test(key) || hasAnnotationData(entry));
    }
    return false;
  };
  const collections = ["scenes", "nodes", "meshes", "materials", "cameras", "skins", "animations"];
  const annotationStatus = hasAnnotationData(json?.extras)
    || collections.some((key) => Array.isArray(json?.[key]) && json[key].some((entry) => (
      annotationPattern.test(String(entry?.name || ""))
      || hasAnnotationData(entry?.extras)
      || hasAnnotationData(entry?.extensions)
    )))
    ? "present"
    : "missing";
  const extensionsUsed = Array.isArray(json?.extensionsUsed) ? json.extensionsUsed : [];
  const extensionsRequired = Array.isArray(json?.extensionsRequired) ? json.extensionsRequired : [];
  const requiresLegacyThreeCompatibility = extensionsRequired.some((extension) => (
    LEGACY_THREE_UNSUPPORTED_GLTF_EXTENSIONS.has(extension)
  ));

  return {
    animationCount: Array.isArray(json?.animations) ? json.animations.length : 0,
    annotationStatus,
    extensionsUsed,
    extensionsRequired,
    requiresLegacyThreeCompatibility
  };
}

function inspectGlbBytes(bytes) {
  if (!bytes || bytes.length < 20) {
    return {
      animationCount: 0,
      annotationStatus: "unknown",
      extensionsUsed: [],
      extensionsRequired: [],
      requiresLegacyThreeCompatibility: false
    };
  }
  try {
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    if (view.getUint32(0, true) !== 0x46546c67) {
      return {
        animationCount: 0,
        annotationStatus: "unknown",
        extensionsUsed: [],
        extensionsRequired: [],
        requiresLegacyThreeCompatibility: false
      };
    }
    const jsonLength = view.getUint32(12, true);
    const jsonChunk = bytes.slice(20, 20 + jsonLength);
    const json = JSON.parse(new TextDecoder().decode(jsonChunk));
    return inspectGltfJson(json);
  } catch (error) {
    console.warn("Failed to inspect GLB bytes", error);
    return {
      animationCount: 0,
      annotationStatus: "unknown",
      extensionsUsed: [],
      extensionsRequired: [],
      requiresLegacyThreeCompatibility: false
    };
  }
}

async function inspectImportedFiles(files) {
  if (!files?.length) return null;
  if (files.length === 1 && files[0].name.toLowerCase().endsWith(".glb")) {
    const bytes = new Uint8Array(await files[0].arrayBuffer());
    return inspectGlbBytes(bytes);
  }
  const gltfFile = files.find((file) => file.name.toLowerCase().endsWith(".gltf"));
  if (!gltfFile) return null;
  try {
    const json = JSON.parse(await gltfFile.text());
    return inspectGltfJson(json);
  } catch (error) {
    console.warn("Failed to inspect glTF json", error);
    return null;
  }
}

function embeddedModelHasAnimations(descriptor) {
  return inspectEmbeddedModelCapabilities(descriptor).animationCount > 0;
}

function inspectEmbeddedModelCapabilities(descriptor) {
  const fallback = {
    animationCount: 0,
    annotationStatus: "unknown",
    extensionsUsed: [],
    extensionsRequired: [],
    requiresLegacyThreeCompatibility: false
  };
  if (!descriptor) return fallback;
  try {
    if (descriptor.kind === "glb" && descriptor.base64) {
      return inspectGlbBytes(decodeBase64(descriptor.base64));
    }
    const gltfFile = (descriptor.files || []).find((file) => String(file.relativePath || "").toLowerCase().endsWith(".gltf"));
    if (!gltfFile?.base64) return fallback;
    const json = JSON.parse(new TextDecoder().decode(decodeBase64(gltfFile.base64)));
    return inspectGltfJson(json);
  } catch (error) {
    console.warn("Failed to inspect embedded model animations", error);
    return fallback;
  }
}

async function switchViewerBackend(nextBackend, options = {}) {
  if (state.viewerBackend === nextBackend) return;

  if (state.viewerRaf) {
    window.cancelAnimationFrame(state.viewerRaf);
    state.viewerRaf = null;
  }
  cancelO3dvAutoRotate();
  resetLoadedAnimationState();

  if (state.viewerBackend === "o3dv") {
    state.o3dvEmbeddedViewer?.Destroy?.();
    state.o3dvEmbeddedViewer = null;
    state.o3dvViewer = null;
  }

  if (state.viewerBackend === "three") {
    try {
      state.controls?.dispose?.();
    } catch (error) {
      console.warn("Failed to dispose controls cleanly", error);
    }
    try {
      state.renderer?.dispose?.();
    } catch (error) {
      console.warn("Failed to dispose renderer cleanly", error);
    }
  }

  if (state.container) {
    state.container.innerHTML = "";
  }
  state.camera = null;
  state.controls = null;
  state.focus = null;
  state.object = null;
  state.scene = null;
  state.renderer = null;
  state.requestViewerRender = null;
  state.viewerBackend = nextBackend;
  state.zoomMode = "in";

  if (options.statusMessage) {
    setImportStatus(options.statusMessage);
  }

  await initViewer({ skipInitialLoad: true });
}

function loadO3dvModel(loadFn, metadata = {}) {
  return new Promise((resolve, reject) => {
    state.o3dvPendingLoad = {
      resolve,
      reject,
      label: metadata.label || "",
      successMessage: metadata.successMessage || ""
    };
    try {
      loadFn();
    } catch (error) {
      state.o3dvPendingLoad = null;
      reject(error);
    }
  });
}

function resizeRendererToDisplaySize(renderer, camera, container) {
  if (!renderer || !camera || !container) return false;
  const width = container.clientWidth;
  const height = Math.max(container.clientHeight, 1);
  const pixelRatio = Math.min(window.devicePixelRatio || 1, state.viewerQuality?.maxPixelRatio || VIEWER_BALANCED_PIXEL_RATIO);
  const canvas = renderer.domElement;
  const targetWidth = Math.floor(width * pixelRatio);
  const targetHeight = Math.floor(height * pixelRatio);
  const needsResize = canvas.width !== targetWidth || canvas.height !== targetHeight;
  if (!needsResize) return false;

  renderer.setPixelRatio(pixelRatio);
  renderer.setSize(width, height, false);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  return true;
}

function setupViewerTools() {
  if (!state.viewerToolsBound) {
    if (ui.brightnessInput) {
      ui.brightnessInput.addEventListener("input", () => {
        applyModelBrightness(ui.brightnessInput.value);
      });
    }

    ui.toolButtons.forEach((button) => {
      button.addEventListener("click", () => {
        handleViewerTool(button.dataset.viewerTool);
      });
    });
    state.viewerToolsBound = true;
  }

  if (ui.brightnessInput) {
    ui.brightnessInput.value = String(state.modelBrightness);
    ui.brightnessInput.disabled = false;
  }

  applyModelBrightness(state.modelBrightness);
  updateViewerToolButtons();
  renderNarrationButtonState();
  renderAnimationButtonState();
}

function handleViewerTool(tool) {
  if (tool === "rotate") {
    if (state.viewerBackend === "o3dv") {
      state.o3dvAutoRotate = !state.o3dvAutoRotate;
      syncO3dvAutoRotate();
      updateViewerToolButtons();
      requestViewerRender();
      return;
    }
    if (!state.controls) return;
    state.controls.autoRotate = !state.controls.autoRotate;
    updateViewerToolButtons();
    requestViewerRender();
    return;
  }

  if (tool === "zoom") {
    zoomViewer();
    return;
  }

  if (tool === "reset") {
    resetViewerView();
    return;
  }

  if (tool === "animation") {
    toggleLoadedAnimations();
    return;
  }

  if (tool === "narration") {
    if (!state.narration.supported) {
      renderNarrationButtonState();
      return;
    }
    if (state.narration.playing && !state.narration.paused) {
      pauseNarration();
      return;
    }
    if (state.narration.paused) {
      resumeNarration();
      return;
    }
    startNarration();
  }
}

function updateViewerToolButtons() {
  ui.toolButtons.forEach((button) => {
    const tool = button.dataset.viewerTool;
    const pressed = tool === "rotate"
      ? isRotateActive()
      : tool === "animation"
        ? state.animationActions.length > 0 && state.animationPlaying
      : tool === "narration"
            ? state.narration.playing
          : false;

    button.classList.toggle("is-active", pressed);
    if (tool === "rotate" || tool === "animation" || tool === "narration") {
      button.setAttribute("aria-pressed", String(pressed));
    } else {
      button.removeAttribute("aria-pressed");
    }
  });
  const copy = lessonUiCopy(lesson.language);
  setToolText("zoom", getNextZoomAction() === "in" ? copy.toolZoom : copy.toolZoomOut);
  renderAnimationButtonState();
  renderNarrationButtonState();
}

function getCurrentViewerDistance() {
  if (state.viewerBackend === "o3dv" && state.o3dvViewer?.GetCamera) {
    const camera = state.o3dvViewer.GetCamera();
    if (camera?.eye && camera?.center) {
      const dx = camera.eye.x - camera.center.x;
      const dy = camera.eye.y - camera.center.y;
      const dz = camera.eye.z - camera.center.z;
      const distance = Math.sqrt((dx * dx) + (dy * dy) + (dz * dz));
      return Number.isFinite(distance) ? distance : null;
    }
  }
  if (!state.camera || !state.controls) return null;
  const target = state.controls.target;
  const direction = new THREE.Vector3().subVectors(state.camera.position, target);
  const distance = direction.length();
  return Number.isFinite(distance) ? distance : null;
}

function getNextZoomAction() {
  if (state.zoomMode === "in" || state.zoomMode === "out") {
    return state.zoomMode;
  }
  const distance = getCurrentViewerDistance();
  if (!Number.isFinite(distance) || !state.controls) return "in";
  const minDistance = Math.max(Number(state.controls.minDistance || 0.35), 0.35);
  const defaultDistance = Math.max(Number(state.focus?.distance || minDistance * 2.6), minDistance * 1.5);
  const switchThreshold = Math.max(minDistance * 1.7, defaultDistance * 0.62);
  return distance <= switchThreshold ? "out" : "in";
}

function zoomViewer() {
  if (state.viewerBackend === "o3dv" && state.o3dvViewer?.navigation) {
    const nextAction = getNextZoomAction();
    state.o3dvViewer.navigation.Zoom(nextAction === "in" ? -0.12 : 0.18);
    state.o3dvViewer.Render?.();
    state.zoomMode = nextAction === "in" ? "out" : "in";
    updateViewerToolButtons();
    return;
  }
  if (!state.camera || !state.controls) return;
  const target = state.controls.target;
  const direction = new THREE.Vector3().subVectors(state.camera.position, target);
  const distance = direction.length();
  if (!Number.isFinite(distance) || distance <= 0.05) return;

  const nextAction = getNextZoomAction();
  const minDistance = Math.max(Number(state.controls.minDistance || 0.35), 0.35);
  const maxDistance = Math.max(Number(state.controls.maxDistance || 6), minDistance + 0.5);
  const resetDistance = Math.max(Number(state.focus?.distance || maxDistance), minDistance * 2);
  const nextDistance = nextAction === "in"
    ? Math.max(distance * 0.74, minDistance * 1.08)
    : Math.min(distance * 1.28, resetDistance, maxDistance);

  direction.setLength(nextDistance);
  state.camera.position.copy(target).add(direction);
  state.camera.updateProjectionMatrix();
  state.controls.update();
  state.zoomMode = nextAction === "in" ? "out" : "in";
  updateViewerToolButtons();
  requestViewerRender();
}

function resetViewerView() {
  if (state.viewerBackend === "o3dv") {
    fitO3dvToWindow();
    state.zoomMode = "in";
    updateViewerToolButtons();
    return;
  }
  if (!state.camera || !state.controls) return;
  if (state.object) {
    fitCameraToObject(state.camera, state.controls, state.object);
  } else {
    state.camera.position.set(0, 0.5, 6);
    state.controls.target.set(0, 0, 0);
    state.camera.updateProjectionMatrix();
    state.controls.update();
  }
  state.zoomMode = "in";
  updateViewerToolButtons();
  requestViewerRender();
}

function applyModelBrightness(value) {
  const brightness = Number(value);
  if (!Number.isFinite(brightness)) return;
  state.modelBrightness = Math.min(Math.max(brightness, 0.45), 1.25);
  if (state.container) {
    if (state.viewerBackend === "o3dv") {
      state.container.style.filter = `brightness(${state.modelBrightness})`;
      state.container.style.transition = "filter 120ms ease";
    } else {
      state.container.style.filter = "";
      state.container.style.transition = "";
    }
  }
  if (state.renderer) {
    state.renderer.toneMappingExposure = state.modelBrightness + state.viewerExposureOffset;
  }
  requestViewerRender();
}

function fitCameraToObject(camera, controls, object) {
  prepareLoadedObjectForDisplay(object);
  const box = new THREE.Box3().setFromObject(object);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y, size.z) || 1;
  const distance = maxDim * 1.82;
  camera.position.set(center.x + distance * 0.22, center.y + distance * 0.1, center.z + distance * 0.94);
  camera.near = Math.max(0.01, distance / 100);
  camera.far = distance * 20;
  camera.updateProjectionMatrix();
  controls.minDistance = Math.max(maxDim * 0.42, 0.28);
  controls.maxDistance = Math.max(maxDim * 4.4, 6);
  controls.target.copy(center);
  controls.update();
  state.focus = { center, distance };
  state.zoomMode = "in";
  updateViewerToolButtons();
}

function focusSection(index) {
  state.activeSectionIndex = index;
  renderStructuredActiveSection();
  if (!state.controls) return;
  state.controls.autoRotate = false;
  updateViewerToolButtons();
  applyCameraView(lesson.sections[index]?.camera_view, index);
}

function setupDrawerUi() {
  if (state.drawerUiBound) return;
  ui.drawers.forEach((button) => {
    button.addEventListener("click", () => toggleDrawer(button.dataset.drawerTarget));
  });
  ui.drawerCloseButtons.forEach((button) => {
    button.addEventListener("click", () => closeDrawers());
  });
  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape") {
      closeDrawers();
      toggleImportPanel(false);
      clearStudentTaskAdvanceTimer();
    }
  });
  state.drawerUiBound = true;
}

function setupImportUi() {
  ui.openImportPanelButton?.addEventListener("click", () => toggleImportPanel(true));
  ui.importButton?.addEventListener("click", () => ui.importInput?.click());
  ui.closeImportPanelButton?.addEventListener("click", () => toggleImportPanel(false));
  ui.importInput?.addEventListener("change", async (event) => {
    const files = [...(event.target.files || [])];
    if (!files.length) return;
    await handleImportedFiles(files);
    event.target.value = "";
  });

  for (const eventName of ["dragenter", "dragover"]) {
    ui.dropzone?.addEventListener(eventName, (event) => {
      event.preventDefault();
      ui.dropzone.classList.add("dragging");
    });
  }
  for (const eventName of ["dragleave", "dragend", "drop"]) {
    ui.dropzone?.addEventListener(eventName, (event) => {
      event.preventDefault();
      ui.dropzone.classList.remove("dragging");
    });
  }
  ui.dropzone?.addEventListener("drop", async (event) => {
    const files = [...(event.dataTransfer?.files || [])];
    if (!files.length) return;
    await handleImportedFiles(files);
  });
}

function toggleImportPanel(forceVisible) {
  if (!ui.importPanel) return;
  const visible = typeof forceVisible === "boolean" ? forceVisible : ui.importPanel.hidden;
  if (visible) {
    closeDrawers();
  }
  ui.importPanel.hidden = !visible;
  if (visible) {
    ui.openImportPanelButton?.setAttribute("aria-expanded", "true");
  } else {
    ui.openImportPanelButton?.setAttribute("aria-expanded", "false");
  }
}

function setImportStatus(message, tone = "") {
  if (!ui.importStatus) return;
  ui.importStatus.textContent = message;
  ui.importStatus.className = `import-status${tone ? ` ${tone}` : ""}`;
}

function setModelLabel(label) {
  setText("modelType", label || "Imported model");
}

function revokeLocalModelDownloadUrl() {
  if (!state.localModelDownloadUrl) return;
  URL.revokeObjectURL(state.localModelDownloadUrl);
  state.localModelDownloadUrl = null;
}

function setModelDownloadLink({ href = "", download = "", hidden = false } = {}) {
  if (!ui.downloadModelLink) return;
  ui.downloadModelLink.hidden = hidden;
  if (hidden || !href) {
    ui.downloadModelLink.removeAttribute("href");
    ui.downloadModelLink.removeAttribute("download");
    return;
  }
  ui.downloadModelLink.href = href;
  if (download) {
    ui.downloadModelLink.setAttribute("download", download);
  } else {
    ui.downloadModelLink.removeAttribute("download");
  }
}

function restoreDefaultModelDownloadLink() {
  revokeLocalModelDownloadUrl();
  const defaultHref = embeddedModel ? "" : (lesson.model?.path || ui.downloadModelLink?.dataset.defaultHref || "");
  setModelDownloadLink({
    href: defaultHref,
    download: defaultHref ? baseName(defaultHref) : "",
    hidden: !defaultHref
  });
}

function setLocalModelDownloadLink(file) {
  revokeLocalModelDownloadUrl();
  if (!file) {
    setModelDownloadLink({ hidden: true });
    return;
  }
  state.localModelDownloadUrl = URL.createObjectURL(file);
  setModelDownloadLink({
    href: state.localModelDownloadUrl,
    download: file.name || "imported-model.glb",
    hidden: false
  });
}

function createStudioEnvironment(renderer, quality = state.viewerQuality) {
  const pmrem = new THREE.PMREMGenerator(renderer);
  const environmentScene = new THREE.Scene();
  environmentScene.background = new THREE.Color(0x1f2226);

  const sky = new THREE.Mesh(
    new THREE.SphereGeometry(12, 32, 32),
    new THREE.MeshBasicMaterial({
      color: new THREE.Color(0.12, 0.13, 0.15),
      side: THREE.BackSide
    })
  );
  const floor = new THREE.Mesh(
    new THREE.CircleGeometry(8, 48),
    new THREE.MeshStandardMaterial({ color: 0x23272d, roughness: 0.96, metalness: 0.02 })
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.y = -2.4;

  const keyPanel = new THREE.Mesh(
    new THREE.PlaneGeometry(2.4, 6.4),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(7.8, 6.8, 4.6) })
  );
  keyPanel.position.set(4.8, 3.4, 3.4);
  keyPanel.lookAt(0, 0, 0);

  const fillPanel = new THREE.Mesh(
    new THREE.PlaneGeometry(1.8, 5.8),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(2.8, 3.2, 3.8) })
  );
  fillPanel.position.set(-4.8, 2.7, 2.8);
  fillPanel.lookAt(0, 0, 0);

  const rimPanel = new THREE.Mesh(
    new THREE.PlaneGeometry(1.7, 6),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(4.8, 4.5, 4.2) })
  );
  rimPanel.position.set(-2.6, 3.2, -5.3);
  rimPanel.lookAt(0, 0.8, 0);

  const topPanel = new THREE.Mesh(
    new THREE.PlaneGeometry(5.6, 1.4),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(4.6, 4.4, 4.1) })
  );
  topPanel.position.set(0, 5.2, 1.2);
  topPanel.lookAt(0, 0.6, 0);

  const bouncePanel = new THREE.Mesh(
    new THREE.PlaneGeometry(5.2, 2.1),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(1.2, 1.1, 1.05) })
  );
  bouncePanel.position.set(0, -0.4, 5.9);
  bouncePanel.lookAt(0, 0.2, 0);

  environmentScene.add(sky, floor, keyPanel, fillPanel, rimPanel, topPanel, bouncePanel);
  environmentScene.add(new THREE.HemisphereLight(0x66737d, 0x101214, 0.45));

  const envMap = pmrem.fromScene(environmentScene, quality?.envSigma || 0.03).texture;
  pmrem.dispose();
  return envMap;
}

async function loadHdriEnvironment(renderer) {
  if (!RGBELoader) return null;

  try {
    const texture = await new Promise((resolve, reject) => {
      const loader = new RGBELoader();
      loader.setDataType(THREE.HalfFloatType);
      loader.load(resolveViewerHdriSource(), resolve, undefined, reject);
    });

    const pmrem = new THREE.PMREMGenerator(renderer);
    pmrem.compileEquirectangularShader?.();
    const envMap = pmrem.fromEquirectangular(texture).texture;
    texture.dispose?.();
    pmrem.dispose();
    return envMap;
  } catch (error) {
    console.warn("Lessons3D HDR environment unavailable, falling back to studio scene.", error);
    return null;
  }
}

async function configureViewerLighting(scene, renderer, quality = state.viewerQuality) {
  const hdriEnvironment = await loadHdriEnvironment(renderer);
  state.viewerUsesHdri = Boolean(hdriEnvironment);
  scene.environment = hdriEnvironment || createStudioEnvironment(renderer, quality);
  scene.background = new THREE.Color(0x1f2226);
  state.viewerExposureOffset = state.viewerUsesHdri ? 0.08 : 0.04;
  renderer.toneMappingExposure = state.modelBrightness + state.viewerExposureOffset;

  const lightMultiplier = quality?.lightMultiplier || 1;
  const ambientStrength = state.viewerUsesHdri ? 0.16 : 0.28;
  const hemisphereStrength = state.viewerUsesHdri ? 0.28 : 0.72;
  const keyStrength = state.viewerUsesHdri ? 0.72 : 1.85;
  const fillStrength = state.viewerUsesHdri ? 0.22 : 0.58;
  const frontStrength = state.viewerUsesHdri ? 0.12 : 0.3;
  const rimStrength = state.viewerUsesHdri ? 0.42 : 1.16;
  const topStrength = state.viewerUsesHdri ? 0.18 : 0.36;

  const ambient = new THREE.AmbientLight(0xffffff, ambientStrength * lightMultiplier);
  const hemisphere = new THREE.HemisphereLight(0xf0f4f6, 0x4d5961, hemisphereStrength * lightMultiplier);
  const key = new THREE.DirectionalLight(0xfff7e7, keyStrength * lightMultiplier);
  key.position.set(5.5, 7.5, 6.5);
  const fill = new THREE.DirectionalLight(0xdbe6f2, fillStrength * lightMultiplier);
  fill.position.set(-6.5, 4.5, 5.5);
  const front = new THREE.DirectionalLight(0xffffff, frontStrength * lightMultiplier);
  front.position.set(0, 2.8, 7.5);
  const rim = new THREE.DirectionalLight(0xfff0d8, rimStrength * lightMultiplier);
  rim.position.set(-4.5, 5.2, -5.8);
  const top = new THREE.DirectionalLight(0xffffff, topStrength * lightMultiplier);
  top.position.set(0.4, 8.4, 0.6);

  scene.add(ambient, hemisphere, key, fill, front, rim, top);
}

function tuneTexture(texture, { isColor = false, anisotropy = 1 } = {}) {
  if (!texture) return;
  if (isColor && "colorSpace" in texture) {
    texture.colorSpace = THREE.SRGBColorSpace;
  }
  if ("anisotropy" in texture) {
    texture.anisotropy = anisotropy;
  }
  texture.needsUpdate = true;
}

function isPredominantlyMetallicMaterial(material) {
  if (!material) return false;
  const metalness = Number(material.metalness);
  return Boolean(
    material.metalnessMap ||
    (Number.isFinite(metalness) && metalness >= 0.6)
  );
}

function tuneLoadedMaterial(material, renderer) {
  const anisotropy = Math.max(renderer?.capabilities?.getMaxAnisotropy?.() || 1, 1);
  tuneTexture(material.map, { isColor: true, anisotropy });
  tuneTexture(material.emissiveMap, { isColor: true, anisotropy });
  tuneTexture(material.normalMap, { anisotropy });
  tuneTexture(material.roughnessMap, { anisotropy });
  tuneTexture(material.metalnessMap, { anisotropy });
  tuneTexture(material.aoMap, { anisotropy });
  tuneTexture(material.specularColorMap, { isColor: true, anisotropy });
  tuneTexture(material.clearcoatNormalMap, { anisotropy });
  tuneTexture(material.clearcoatRoughnessMap, { anisotropy });

  const metallicSurface = isPredominantlyMetallicMaterial(material);
  const envMapFloor = metallicSurface
    ? Math.max((state.viewerQuality?.envMapIntensity || 1.05) + 0.18, 1.22)
    : (state.viewerQuality?.envMapIntensity || 1.05);
  const roughnessFloor = metallicSurface
    ? 0.06
    : (state.viewerQuality?.roughnessFloor || 0.2);
  const roughnessCeiling = metallicSurface ? 0.58 : 0.82;

  if ("envMapIntensity" in material) {
    material.envMapIntensity = Math.max(
      Number(material.envMapIntensity) || 0.9,
      envMapFloor
    );
  }
  if ("roughness" in material && Number.isFinite(material.roughness)) {
    material.roughness = Math.min(
      Math.max(material.roughness, roughnessFloor),
      roughnessCeiling
    );
  }
  if (metallicSurface && "metalness" in material && Number.isFinite(material.metalness)) {
    material.metalness = Math.max(material.metalness, 0.88);
  }
  material.needsUpdate = true;
}

function tuneLoadedObjectMaterials(object, renderer) {
  object.traverse((child) => {
    if (!child.isMesh || !child.material) return;
    const materials = Array.isArray(child.material) ? child.material : [child.material];
    materials.forEach((material) => tuneLoadedMaterial(material, renderer));
  });
}

function prepareLoadedObjectForDisplay(object) {
  if (!object) return;
  object.updateMatrixWorld?.(true);
  object.traverse((child) => {
    if (child.isSkinnedMesh) {
      child.frustumCulled = false;
      child.skeleton?.update?.();
    }
    if (child.isMesh) {
      child.frustumCulled = false;
      child.geometry?.computeBoundingBox?.();
      child.geometry?.computeBoundingSphere?.();
    }
  });
  object.updateMatrixWorld?.(true);
}

async function initViewer(options = {}) {
  const container = document.getElementById("viewerCanvas");
  if (!container) return;

  if (state.viewerBackend === "o3dv") {
    container.innerHTML = "";
    const viewer = new O3DV.EmbeddedViewer(container, {
      backgroundColor: new O3DV.RGBAColor(31, 34, 38, 255),
      edgeSettings: new O3DV.EdgeSettings(false, new O3DV.RGBColor(0, 0, 0), 1),
      environmentSettings: getO3dvEnvironmentSettings(),
      onModelLoaded: () => {
        const pending = state.o3dvPendingLoad;
        state.o3dvPendingLoad = null;
        fitO3dvToWindow();
        syncO3dvAutoRotate();
        if (pending?.label) {
          setModelLabel(pending.label);
        }
        if (pending?.successMessage) {
          setImportStatus(pending.successMessage, "success");
        }
        pending?.resolve?.();
      },
      onModelLoadFailed: () => {
        const pending = state.o3dvPendingLoad;
        state.o3dvPendingLoad = null;
        const message = lesson.language === "zh-CN"
          ? "模型加载失败。该文件可能包含当前浏览器不支持的旧材质或引用。"
          : "Model load failed. The file may use unsupported legacy materials or references.";
        setImportStatus(message, "error");
        pending?.reject?.(new Error(message));
      }
    });

    state.container = container;
    state.o3dvEmbeddedViewer = viewer;
    state.o3dvViewer = viewer.GetViewer?.() || null;
    state.requestViewerRender = requestO3dvRender;

    setupViewerTools();
    setupDrawerUi();

    window.addEventListener("resize", () => {
      state.o3dvEmbeddedViewer?.Resize?.();
      requestViewerRender();
    });
    document.addEventListener("visibilitychange", () => {
      if (document.hidden) {
        stopNarration(true);
        closeDrawers();
        clearStudentTaskAdvanceTimer();
        cancelO3dvAutoRotate();
      } else {
        syncO3dvAutoRotate();
      }
      requestViewerRender();
    });
    window.addEventListener("beforeunload", () => {
      stopNarration(true);
      clearStudentTaskAdvanceTimer();
      cancelO3dvAutoRotate();
      revokeLocalModelDownloadUrl();
    });

    if ("IntersectionObserver" in window) {
      const observer = new IntersectionObserver((entries) => {
        state.viewerVisible = entries.some((entry) => entry.isIntersecting && entry.intersectionRatio > 0.1);
        if (state.viewerVisible) {
          syncO3dvAutoRotate();
        } else {
          cancelO3dvAutoRotate();
        }
      }, { threshold: [0, 0.1, 0.35] });
      observer.observe(container);
    }

    if (!options.skipInitialLoad) {
      await loadInitialModel();
    }
    requestViewerRender();
    return;
  }

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(38, container.clientWidth / Math.max(container.clientHeight, 1), 0.1, 1000);
  camera.position.set(0, 0.5, 6);

  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, state.viewerQuality.maxPixelRatio));
  renderer.setSize(container.clientWidth, container.clientHeight, false);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.AgXToneMapping;
  renderer.toneMappingExposure = state.modelBrightness + state.viewerExposureOffset;
  container.appendChild(renderer.domElement);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.autoRotate = true;
  controls.autoRotateSpeed = 0.55;

  await configureViewerLighting(scene, renderer, state.viewerQuality);

  state.camera = camera;
  state.controls = controls;
  state.scene = scene;
  state.renderer = renderer;
  state.container = container;

  const requestViewerRenderFrame = () => {
    state.viewerNeedsRender = true;
    if (state.viewerRaf) return;
    state.viewerRaf = window.requestAnimationFrame(renderViewerFrame);
  };

  const shouldAnimateViewer = () =>
    !document.hidden
    && (
      (!!state.animationMixer && state.animationPlaying)
      || (state.viewerVisible && !state.viewerPageScrolling && !!state.controls?.autoRotate)
    );

  const renderViewerFrame = (timestamp = 0) => {
    state.viewerRaf = null;
    const minFrameInterval = 1000 / VIEWER_MAX_FPS;

    if (shouldAnimateViewer()) {
      resizeRendererToDisplaySize(renderer, camera, container);
      if (timestamp - state.viewerLastFrameAt < minFrameInterval) {
        state.viewerRaf = window.requestAnimationFrame(renderViewerFrame);
        return;
      }
      if (state.animationMixer && state.animationPlaying) {
        const deltaSeconds = state.animationLastTickAt > 0
          ? Math.min((timestamp - state.animationLastTickAt) / 1000, 0.05)
          : 0;
        state.animationLastTickAt = timestamp;
        if (deltaSeconds > 0) {
          state.animationMixer.update(deltaSeconds);
        }
      } else {
        state.animationLastTickAt = 0;
      }
      state.controls?.update();
      renderer.render(scene, camera);
      state.viewerNeedsRender = false;
      state.viewerLastFrameAt = timestamp;
      state.viewerRaf = window.requestAnimationFrame(renderViewerFrame);
      return;
    }

    if (!state.viewerNeedsRender) return;
    resizeRendererToDisplaySize(renderer, camera, container);
    state.animationLastTickAt = 0;
    state.controls?.update();
    renderer.render(scene, camera);
    state.viewerNeedsRender = false;
    state.viewerLastFrameAt = timestamp;
  };

  const markPageScrolling = () => {
    state.viewerPageScrolling = true;
    if (state.viewerScrollTimer) {
      window.clearTimeout(state.viewerScrollTimer);
    }
    state.viewerScrollTimer = window.setTimeout(() => {
      state.viewerPageScrolling = false;
      requestViewerRenderFrame();
    }, VIEWER_SCROLL_IDLE_MS);
  };

  state.requestViewerRender = requestViewerRenderFrame;
  setupViewerTools();
  setupDrawerUi();

  controls.addEventListener("change", requestViewerRenderFrame);
  window.addEventListener("resize", () => {
    resizeRendererToDisplaySize(renderer, camera, container);
    requestViewerRenderFrame();
  });
  document.addEventListener("visibilitychange", () => {
    if (document.hidden) {
      stopNarration(true);
      closeDrawers();
      clearStudentTaskAdvanceTimer();
    }
    requestViewerRenderFrame();
  });
  window.addEventListener("scroll", markPageScrolling, { passive: true });
  window.addEventListener("beforeunload", () => {
    stopNarration(true);
    clearStudentTaskAdvanceTimer();
    revokeLocalModelDownloadUrl();
  });

  if ("IntersectionObserver" in window) {
    const observer = new IntersectionObserver((entries) => {
      state.viewerVisible = entries.some((entry) => entry.isIntersecting && entry.intersectionRatio > 0.1);
      requestViewerRenderFrame();
    }, { threshold: [0, 0.1, 0.35] });
    observer.observe(container);
  }

  if (!options.skipInitialLoad) {
    await loadInitialModel();
  }
  requestViewerRenderFrame();
}

async function loadInitialModel() {
  try {
    if (embeddedModel) {
      const embeddedCapabilities = inspectEmbeddedModelCapabilities(embeddedModel);
      if (state.viewerBackend === "o3dv" && shouldUseThreeAnimationViewer(embeddedCapabilities)) {
        await switchViewerBackend("three", {
          statusMessage: lesson.language === "zh-CN"
            ? "检测到模型动画，正在切换到动画播放器..."
            : "Animated model detected. Switching to the animation player..."
        });
        await loadEmbeddedModel(embeddedModel);
        setImportStatus(uiCopy.embeddedModelActive);
        return;
      }
      if (state.viewerBackend === "o3dv") {
        await loadEmbeddedModelWithO3DV(embeddedModel, embeddedCapabilities);
      } else {
        await loadEmbeddedModel(embeddedModel);
      }
      setImportStatus(uiCopy.embeddedModelActive);
      return;
    }
    if (lesson.model?.path) {
      if (state.viewerBackend === "o3dv") {
        await loadModelForLessonWithO3DV(lesson.model);
      } else {
        await loadModelForLesson(lesson.model);
      }
      setImportStatus(uiCopy.packagedModelActive);
      return;
    }
  } catch (error) {
    console.error("Initial model load failed", error);
    setImportStatus(`${uiCopy.defaultLoadFailed}: ${error.message}`, "error");
  }

  setModelLabel(uiCopy.noModelLabel);
  setImportStatus(uiCopy.manualImportNeeded);
  toggleImportPanel(true);
}

function finalizeLoadedObject(object, label, detailText = "") {
  if (!object || !state.scene || !state.camera || !state.controls) return;
  if (state.object) {
    state.scene.remove(state.object);
  }
  prepareLoadedObjectForDisplay(object);
  tuneLoadedObjectMaterials(object, state.renderer);
  state.object = object;
  state.scene.add(object);
  fitCameraToObject(state.camera, state.controls, object);
  renderHotspots();
  setModelLabel(label);
  requestViewerRender();
  if (detailText) {
    setImportStatus(detailText, "success");
  }
}

function finalizeLoadedGltf(gltf, label, detailText = "", capabilities = null) {
  const object = gltf?.scene || gltf?.scenes?.[0];
  if (!object) return;
  const note = buildCapabilityNote(capabilities);
  const message = [detailText, note].filter(Boolean).join(" / ");
  configureLoadedAnimations(gltf, object);
  finalizeLoadedObject(object, label, message);
  requestViewerRender();
}

async function loadModelForLesson(model) {
  if (model?.type === "obj") {
    const objLoader = new OBJLoader();
    if (model.material_path) {
      const materials = await new MTLLoader().loadAsync(model.material_path);
      materials.preload();
      objLoader.setMaterials(materials);
    }
    const object = await objLoader.loadAsync(model.path);
    resetLoadedAnimationState();
    finalizeLoadedObject(object, baseName(model.path));
    return;
  }

  const gltf = await new GLTFLoader().loadAsync(model.path);
  finalizeLoadedGltf(gltf, baseName(model.path));
}

async function loadModelForLessonWithO3DV(model) {
  restoreDefaultModelDownloadLink();
  await loadO3dvModel(
    () => state.o3dvEmbeddedViewer?.LoadModelFromUrlList?.([model.path]),
    {
      label: baseName(model.path),
      successMessage: uiCopy.packagedModelActive
    }
  );
}

async function loadEmbeddedModel(descriptor) {
  if (!descriptor) throw new Error("No embedded model payload available");
  if (descriptor.kind === "glb") {
    const bytes = decodeBase64(descriptor.base64);
    const gltf = await loadGltfFromSingleFile(bytes, descriptor.mimeType || "model/gltf-binary");
    const capabilities = inspectGlbBytes(bytes);
    finalizeLoadedGltf(
      gltf,
      descriptor.fileName || "embedded-model.glb",
      buildSuccessMessage(descriptor.fileName || "embedded-model.glb", bytes.length, gltf.scene || gltf.scenes?.[0]),
      capabilities
    );
    return;
  }

  const entries = descriptor.files.map((file) => ({
    relativePath: file.relativePath,
    mimeType: file.mimeType,
    bytes: decodeBase64(file.base64)
  }));

  if (descriptor.kind === "obj_bundle") {
    const object = await loadObjFromEntries(entries, descriptor.entryPath, descriptor.materialPath || "");
    resetLoadedAnimationState();
    finalizeLoadedObject(
      object,
      descriptor.fileName || baseName(descriptor.entryPath),
      buildSuccessMessage(descriptor.fileName || baseName(descriptor.entryPath), totalEntryBytes(entries), object)
    );
    return;
  }

  const gltf = await loadGltfFromEntries(entries, descriptor.entryPath);
  finalizeLoadedGltf(
    gltf,
    descriptor.fileName || baseName(descriptor.entryPath),
    buildSuccessMessage(descriptor.fileName || baseName(descriptor.entryPath), totalEntryBytes(entries), gltf.scene || gltf.scenes?.[0])
  );
}

async function loadEmbeddedModelWithO3DV(descriptor, capabilities = null) {
  const files = createO3dvFilesFromEmbeddedDescriptor(descriptor);
  if (!files.length) {
    throw new Error("No embedded model payload available");
  }
  setLocalModelDownloadLink(files.length === 1 ? files[0] : null);
  const primaryLabel = descriptor.fileName || baseName(descriptor.entryPath || files[0].name);
  await loadO3dvModel(
    () => state.o3dvEmbeddedViewer?.LoadModelFromFileList?.(files),
    {
      label: primaryLabel,
      successMessage: buildO3dvSuccessMessage(primaryLabel, capabilities)
    }
  );
}

async function handleImportedFiles(files) {
  try {
    setImportStatus(lesson.language === "zh-CN" ? "正在解析模型文件..." : "Reading model files...");
    const capabilities = await inspectImportedFiles(files);
    if (state.viewerBackend === "o3dv" && shouldUseThreeAnimationViewer(capabilities)) {
      await switchViewerBackend("three", {
        statusMessage: lesson.language === "zh-CN"
          ? "检测到带骨骼动画的模型，正在切换到动画兼容查看器..."
          : "Animated model detected. Switching to the animation-compatible viewer..."
      });
    }
    if (state.viewerBackend === "o3dv") {
      await loadImportedFilesWithO3DV(files, capabilities);
      toggleImportPanel(false);
      return;
    }
    const result = await resolveImportedModel(files, capabilities);
    if (result.gltf) {
      finalizeLoadedGltf(result.gltf, result.label, result.message, result.capabilities);
    } else {
      resetLoadedAnimationState();
      finalizeLoadedObject(result.object, result.label, result.message);
    }
    toggleImportPanel(false);
  } catch (error) {
    console.error("Import failed", error);
    setImportStatus(error.message || (lesson.language === "zh-CN"
      ? "无法识别此文件。请选择 .glb、.gltf 多文件，或 ZIP 模型包。"
      : "Could not read that file. Choose a .glb, a multi-file .gltf package, or a ZIP model bundle."), "error");
    toggleImportPanel(true);
  }
}

async function loadImportedFilesWithO3DV(files, capabilities = null) {
  const label = files.length === 1 ? files[0].name : `${files.length} files`;
  setLocalModelDownloadLink(files.length === 1 ? files[0] : null);
  if (files.length === 1 && files[0].name.toLowerCase().endsWith(".zip")) {
    throw new Error(lesson.language === "zh-CN"
      ? "Online3DViewer 试验接入暂不支持 ZIP 模型包，请直接选择 .glb 或 .gltf 关联文件。"
      : "The Online3DViewer PoC does not support ZIP model bundles yet. Choose a .glb or the related .gltf files directly.");
  }
  await loadO3dvModel(
    () => state.o3dvEmbeddedViewer?.LoadModelFromFileList?.(files),
    {
      label,
      successMessage: buildO3dvSuccessMessage(label, capabilities)
    }
  );
}

async function resolveImportedModel(files, inspectedCapabilities = null) {
  if (files.length === 1 && files[0].name.toLowerCase().endsWith(".zip")) {
    return loadFromZipFile(files[0]);
  }

  if (files.length === 1 && files[0].name.toLowerCase().endsWith(".glb")) {
    const bytes = new Uint8Array(await files[0].arrayBuffer());
    const gltf = await loadGltfFromSingleFile(bytes, "model/gltf-binary");
    const capabilities = inspectedCapabilities || inspectGlbBytes(bytes);
    return {
      gltf,
      object: gltf.scene || gltf.scenes?.[0],
      capabilities,
      label: files[0].name,
      message: buildSuccessMessage(files[0].name, files[0].size, gltf.scene || gltf.scenes?.[0])
    };
  }

  if (files.some((file) => file.name.toLowerCase().endsWith(".gltf"))) {
    const entries = await Promise.all(files.map(async (file) => ({
      relativePath: file.webkitRelativePath || file.name,
      mimeType: inferMimeType(file.name),
      bytes: new Uint8Array(await file.arrayBuffer())
    })));
    const entryPath = chooseEntry(entries.map((entry) => entry.relativePath), ".gltf");
    const gltf = await loadGltfFromEntries(entries, entryPath);
    return {
      gltf,
      object: gltf.scene || gltf.scenes?.[0],
      capabilities: inspectedCapabilities || null,
      label: baseName(entryPath),
      message: buildSuccessMessage(baseName(entryPath), totalEntryBytes(entries), gltf.scene || gltf.scenes?.[0])
    };
  }

    throw new Error(lesson.language === "zh-CN"
      ? "请选择 .glb、.gltf 多文件，或 ZIP 模型包。"
      : "Choose a .glb, a multi-file .gltf package, or a ZIP model bundle.");
}

async function loadFromZipFile(file) {
  const entries = await extractZipEntriesBrowser(new Uint8Array(await file.arrayBuffer()));
  const normalizedEntries = entries.map((entry) => ({
    relativePath: entry.name,
    mimeType: inferMimeType(entry.name),
    bytes: entry.data
  }));

  const glbEntry = chooseEntry(normalizedEntries.map((entry) => entry.relativePath), ".glb", true);
  if (glbEntry) {
    const selected = normalizedEntries.find((entry) => entry.relativePath === glbEntry);
    const gltf = await loadGltfFromSingleFile(selected.bytes, selected.mimeType);
    const capabilities = inspectGlbBytes(selected.bytes);
    return {
      gltf,
      object: gltf.scene || gltf.scenes?.[0],
      capabilities,
      label: `${file.name} / ${baseName(glbEntry)}`,
      message: buildSuccessMessage(baseName(glbEntry), file.size, gltf.scene || gltf.scenes?.[0])
    };
  }

  const gltfEntry = chooseEntry(normalizedEntries.map((entry) => entry.relativePath), ".gltf");
  if (!gltfEntry) {
    throw new Error(lesson.language === "zh-CN"
      ? "ZIP 内未找到 .glb 或 .gltf 文件。"
      : "No .glb or .gltf file was found inside the ZIP.");
  }

  const gltf = await loadGltfFromEntries(normalizedEntries, gltfEntry);
  return {
    gltf,
    object: gltf.scene || gltf.scenes?.[0],
    label: `${file.name} / ${baseName(gltfEntry)}`,
    message: buildSuccessMessage(baseName(gltfEntry), file.size, gltf.scene || gltf.scenes?.[0])
  };
}

async function loadGltfFromSingleFile(bytes, mimeType) {
  const url = URL.createObjectURL(new Blob([bytes], { type: mimeType || "model/gltf-binary" }));
  try {
    return await new GLTFLoader().loadAsync(url);
  } finally {
    URL.revokeObjectURL(url);
  }
}

async function loadGltfFromEntries(entries, entryPath) {
  const bundle = createVirtualBundle(entries);
  try {
    const loader = new GLTFLoader(createBundleLoadingManager(bundle));
    return await loader.loadAsync(resolveBundleUrl(entryPath, bundle.map));
  } finally {
    bundle.revoke();
  }
}

async function loadObjFromEntries(entries, entryPath, materialPath) {
  const bundle = createVirtualBundle(entries);
  try {
    const manager = createBundleLoadingManager(bundle);
    const objLoader = new OBJLoader(manager);
    if (materialPath) {
      const materials = await new MTLLoader(manager).loadAsync(resolveBundleUrl(materialPath, bundle.map));
      materials.preload();
      objLoader.setMaterials(materials);
    }
    return await objLoader.loadAsync(resolveBundleUrl(entryPath, bundle.map));
  } finally {
    bundle.revoke();
  }
}

function createBundleLoadingManager(bundle) {
  const manager = new THREE.LoadingManager();
  manager.setURLModifier((url) => resolveBundleUrl(url, bundle.map));
  return manager;
}

function createVirtualBundle(entries) {
  const map = new Map();
  const urls = [];
  const basenameCounts = new Map();

  for (const entry of entries) {
    const normalized = normalizePath(entry.relativePath);
    if (!normalized) continue;
    const basename = baseName(normalized).toLowerCase();
    basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
  }

  for (const entry of entries) {
    const normalized = normalizePath(entry.relativePath);
    if (!normalized) continue;
    const url = URL.createObjectURL(new Blob([entry.bytes], { type: entry.mimeType || inferMimeType(normalized) }));
    urls.push(url);
    map.set(normalized.toLowerCase(), url);
    const basename = baseName(normalized).toLowerCase();
    if (basenameCounts.get(basename) === 1) {
      map.set(basename, url);
    }
  }

  return {
    map,
    revoke() {
      urls.forEach((url) => URL.revokeObjectURL(url));
    }
  };
}

function resolveBundleUrl(rawUrl, map) {
  const normalized = normalizePath(rawUrl);
  if (!normalized) return rawUrl;
  return map.get(normalized.toLowerCase()) || map.get(baseName(normalized).toLowerCase()) || rawUrl;
}

async function extractZipEntriesBrowser(bytes) {
  const eocdOffset = findEndOfCentralDirectory(bytes);
  const totalEntries = readUint16(bytes, eocdOffset + 10);
  const centralDirectoryOffset = readUint32(bytes, eocdOffset + 16);
  const entries = [];

  let offset = centralDirectoryOffset;
  for (let index = 0; index < totalEntries; index += 1) {
    if (readUint32(bytes, offset) !== 0x02014b50) {
      throw new Error("ZIP central directory is invalid.");
    }

    const compressionMethod = readUint16(bytes, offset + 10);
    const compressedSize = readUint32(bytes, offset + 20);
    const uncompressedSize = readUint32(bytes, offset + 24);
    const fileNameLength = readUint16(bytes, offset + 28);
    const extraLength = readUint16(bytes, offset + 30);
    const commentLength = readUint16(bytes, offset + 32);
    const localHeaderOffset = readUint32(bytes, offset + 42);
    const fileName = decodeText(bytes.slice(offset + 46, offset + 46 + fileNameLength));

    const localNameLength = readUint16(bytes, localHeaderOffset + 26);
    const localExtraLength = readUint16(bytes, localHeaderOffset + 28);
    const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
    const compressedData = bytes.slice(dataOffset, dataOffset + compressedSize);

    let data;
    if (compressionMethod === 0) {
      data = compressedData;
    } else if (compressionMethod === 8) {
      data = await inflateRaw(compressedData);
    } else {
      throw new Error(`ZIP 压缩方式暂不支持：${compressionMethod}`);
    }

    if (!fileName.endsWith("/")) {
      if (data.length !== uncompressedSize) {
        throw new Error(`ZIP entry size mismatch for ${fileName}`);
      }
      entries.push({
        name: normalizePath(fileName),
        data
      });
    }

    offset += 46 + fileNameLength + extraLength + commentLength;
  }

  return entries.filter((entry) => !entry.name.startsWith("__MACOSX/"));
}

function findEndOfCentralDirectory(bytes) {
  for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset -= 1) {
    if (readUint32(bytes, offset) === 0x06054b50) return offset;
  }
  throw new Error("ZIP end of central directory not found.");
}

function readUint16(bytes, offset) {
  return bytes[offset] | (bytes[offset + 1] << 8);
}

function readUint32(bytes, offset) {
  return (
    bytes[offset] |
    (bytes[offset + 1] << 8) |
    (bytes[offset + 2] << 16) |
    (bytes[offset + 3] << 24)
  ) >>> 0;
}

async function inflateRaw(bytes) {
  if (typeof DecompressionStream !== "function") {
    throw new Error("当前浏览器不支持 ZIP 本地解压。请改用 Chromium、Edge 或更新版 Chrome。");
  }
  const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

function decodeText(bytes) {
  return new TextDecoder().decode(bytes);
}

function decodeBase64(base64) {
  const binary = window.atob(base64 || "");
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index += 1) {
    bytes[index] = binary.charCodeAt(index);
  }
  return bytes;
}

function normalizePath(value) {
  return String(value || "")
    .replace(/^blob:/i, "")
    .replace(/^file:\/+/i, "")
    .replaceAll("\\", "/")
    .replace(/^[./]+/, "")
    .trim();
}

function chooseEntry(paths, extension, optional = false) {
  const candidates = paths
    .map((path) => normalizePath(path))
    .filter((path) => path.toLowerCase().endsWith(extension));
  if (!candidates.length) {
    if (optional) return "";
    throw new Error(`未找到 ${extension} 入口文件。`);
  }
  return candidates.sort((left, right) => scoreEntry(left) - scoreEntry(right) || left.length - right.length)[0];
}

function scoreEntry(path) {
  const lower = path.toLowerCase();
  if (lower === "scene.gltf" || lower.endsWith("/scene.gltf")) return 0;
  if (lower === "scene.glb" || lower.endsWith("/scene.glb")) return 0;
  if (lower.startsWith("source/")) return 1;
  return 2;
}

function totalEntryBytes(entries) {
  return entries.reduce((sum, entry) => sum + (entry.bytes?.length || 0), 0);
}

function buildSuccessMessage(fileName, sizeBytes, object) {
  const sizeMb = `${(sizeBytes / 1024 / 1024).toFixed(1)}MB`;
  const triangleCount = countTriangles(object);
  const triangleLabel = triangleCount
    ? (lesson.language === "zh-CN" ? `${triangleCount.toLocaleString()} 个三角形` : `${triangleCount.toLocaleString()} triangles`)
    : (lesson.language === "zh-CN" ? "三角形数量不可用" : "triangle count unavailable");
  const prefix = uiCopy.importSuccessPrefix || (lesson.language === "zh-CN" ? "已加载" : "Loaded");
  return `${prefix}: ${fileName} / ${sizeMb} / ${triangleLabel}`;
}

function countTriangles(object) {
  let triangles = 0;
  object?.traverse((child) => {
    if (!child.isMesh || !child.geometry) return;
    const geometry = child.geometry;
    if (geometry.index) {
      triangles += Math.floor(geometry.index.count / 3);
      return;
    }
    const position = geometry.getAttribute?.("position");
    if (position?.count) triangles += Math.floor(position.count / 3);
  });
  return triangles;
}

function inferMimeType(fileName) {
  const lower = String(fileName || "").toLowerCase();
  if (lower.endsWith(".glb")) return "model/gltf-binary";
  if (lower.endsWith(".gltf")) return "model/gltf+json";
  if (lower.endsWith(".bin")) return "application/octet-stream";
  if (lower.endsWith(".png")) return "image/png";
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
  if (lower.endsWith(".webp")) return "image/webp";
  if (lower.endsWith(".ktx2")) return "image/ktx2";
  return "application/octet-stream";
}

function baseName(value) {
  return String(value || "").replaceAll("\\", "/").split("/").pop() || "";
}

function sendOpenPing() {
  if (!lesson.tracking?.enabled || !lesson.tracking?.open_ping_url || location.protocol === "file:") return;
  const url = `${lesson.tracking.open_ping_url}?lesson_id=${encodeURIComponent(lesson.tracking.lesson_id || "unknown")}`;
  if (navigator.sendBeacon) {
    navigator.sendBeacon(url);
  } else {
    const image = new Image();
    image.src = url;
  }
}

renderLessonContent();
applyTheme(theme);
setupImportUi();
initViewer().catch((error) => {
  console.error("Viewer initialization failed", error);
  setImportStatus(`3D viewer failed to initialize: ${error.message}`, "error");
  toggleImportPanel(true);
});
sendOpenPing();

