import { w2popup } from "w2ui";
import type { Object3D, PerspectiveCamera, Scene, Texture, WebGLRenderer } from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { ModelItem } from "../../../types";
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
import { escapeHtml, formatBytes } from "../../../utils/format";
import { notify, notifyError } from "../../../ui/dialogs";
import { api } from "../../../services/api";

type PreviewRuntime = {
  renderer: WebGLRenderer;
  scene: Scene;
  camera: PerspectiveCamera;
  context: PreviewLoadContext;
};

type PreviewLoadContext = {
  controller: AbortController;
  disposed: boolean;
  renderer?: WebGLRenderer;
  scene?: Scene;
  camera?: PerspectiveCamera;
  controls?: OrbitControls;
  environmentMap?: Texture | null;
  modelObject?: Object3D;
  animationId?: number;
  resizeObserver?: ResizeObserver;
};

type ThreeModule = typeof import("three");
type LoadedGltf = Awaited<ReturnType<GLTFLoader["loadAsync"]>>;

type ParseableGltfLoader = GLTFLoader & {
  parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
};

export type PreviewBasePoint = {
  x?: number;
  y?: number;
  z?: number;
  rx?: number;
  ry?: number;
  rz?: number;
};

export type PreviewResolvedBasePoint = Required<PreviewBasePoint>;

export type PreviewOperation = OperationTree & {
  parsedCraftPlayData: P_OPERATION | null;
  frameCount: number;
  parseError: string | null;
};

export type PreviewImportScenePayload = {
  model: ModelItem;
  modelUrl: string;
  basePointEnabled: boolean;
  basePoint: PreviewResolvedBasePoint;
  rawOperationTree: string;
  operations: PreviewOperation[];
  selectedOperation: PreviewOperation | null;
  selectedCraftPlayData: P_OPERATION | null;
};

export type ModelPreviewOptions = {
  onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void;
};

const zeroBasePoint: PreviewResolvedBasePoint = {
  x: 0,
  y: 0,
  z: 0,
  rx: 0,
  ry: 0,
  rz: 0
};

const previewEnvironmentUrl = "/DayCityOutdoor.exr";

let runtime: PreviewRuntime | null = null;
let activePreviewContext: PreviewLoadContext | null = null;
let previewEnvironmentTexturePromise: Promise<Texture> | null = null;

export function openModelPreview(
  model: ModelItem,
  onThumbnailSaved?: () => Promise<void> | void,
  options: ModelPreviewOptions = {}
) {
  const context = beginPreviewContext();
  const canWrite = model.permissions.write;
  const url = model.file_url;
  const operations = parseModelOperations(model.operation_tree);
  const popup = w2popup.open({
    title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
    width: 860,
    height: 620,
    modal: true,
    body: `
      <div class="preview-shell">
        <aside class="preview-process-panel">
          <div class="preview-process-section">
            <div class="preview-process-header">
              <strong>工艺播放</strong>
              <span>${operations.length} 项</span>
            </div>
            <ul class="preview-process-tree">
              ${operationListHtml(operations)}
            </ul>
            <div class="preview-process-actions">
              <button id="previewPlayBtn" type="button">播放</button>
              <button id="previewPauseBtn" type="button">暂停</button>
            </div>
            <div id="previewProcessStatus" class="preview-process-status">
              ${operationStatusHtml(operations[0] ?? null)}
            </div>
          </div>
          <div class="preview-scene-section">
            <div class="preview-scene-body">
              ${canWrite ? `
                <button id="previewCaptureThumbBtn" type="button">截缩略图</button>
                <label class="preview-switch">
                  <input id="previewTransparentThumb" type="checkbox" />
                  <span>透明背景截图</span>
                </label>
              ` : ""}
              <button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
              <label class="preview-switch">
                <input id="previewUseBasePoint" type="checkbox" />
                <span>启用基点导入</span>
              </label>
              <div class="preview-basepoint-grid">
                <label><span>X</span><input name="baseX" type="number" value="0" step="0.001" /></label>
                <label><span>Y</span><input name="baseY" type="number" value="0" step="0.001" /></label>
                <label><span>Z</span><input name="baseZ" type="number" value="0" step="0.001" /></label>
                <label><span>RX</span><input name="baseRx" type="number" value="0" step="0.001" /></label>
                <label><span>RY</span><input name="baseRy" type="number" value="0" step="0.001" /></label>
                <label><span>RZ</span><input name="baseRz" type="number" value="0" step="0.001" /></label>
              </div>
            </div>
          </div>
        </aside>
        <div class="preview-canvas-panel">
          <div id="modelPreviewViewport" class="preview-viewport">
            ${previewLoadingHtml("准备加载模型...")}
          </div>
        </div>
      </div>
    `,
    actions: {
      关闭() {
        disposePreview();
        w2popup.close();
      }
    }
  });

  popup.self
    .on("open:after", () => {
      bindProcessPlaceholder(operations);
      bindImportScene(model, operations, options.onImportScene);
      if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
      requestAnimationFrame(() => {
        initPreview(url, context).catch((error) => {
          if (!isAbortError(error)) notifyError(error);
        });
      });
    })
    .on("close:after", () => disposePreview());
}

export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) {
  const fieldMap: Array<[keyof PreviewResolvedBasePoint, string]> = [
    ["x", "baseX"],
    ["y", "baseY"],
    ["z", "baseZ"],
    ["rx", "baseRx"],
    ["ry", "baseRy"],
    ["rz", "baseRz"]
  ];
  for (const [key, name] of fieldMap) {
    const input = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
    if (input && basePoint[key] !== undefined) {
      input.value = String(basePoint[key]);
    }
  }

  const checkbox = document.querySelector<HTMLInputElement>("#previewUseBasePoint");
  if (checkbox) {
    checkbox.checked = enabled;
  }
}

export function createImportScenePayload(options: {
  model: ModelItem;
  basePointEnabled?: boolean;
  basePoint?: PreviewBasePoint;
  selectedOperationIndex?: number | null;
}): PreviewImportScenePayload {
  const operations = parseModelOperations(options.model.operation_tree);
  const selectedOperation = options.selectedOperationIndex === null
    ? null
    : operations[options.selectedOperationIndex ?? 0] ?? null;

  return buildImportScenePayload({
    model: options.model,
    operations,
    selectedOperation,
    basePointEnabled: options.basePointEnabled ?? false,
    basePoint: resolveBasePoint(options.basePoint)
  });
}

export function emitImportScenePayload(payload: PreviewImportScenePayload) {
  window.dispatchEvent(new CustomEvent<PreviewImportScenePayload>("dmt:import-scene", { detail: payload }));
}

function parseModelOperations(value: string): PreviewOperation[] {
  try {
    const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
    const list = JSON.parse(dbValue.OperationTree || "[]") as unknown;
    if (!Array.isArray(list)) return [];
    return list
      .filter((item): item is OperationTree => Boolean(item && typeof item === "object"))
      .map((operation) => {
        const parsed = parseCraftPlayData(operation.CraftPlayData);
        return {
          ...operation,
          parsedCraftPlayData: parsed.data,
          frameCount: parsed.data?.OPERATION.frames.length ?? 0,
          parseError: parsed.error
        };
      });
  } catch {
    return [];
  }
}

function parseCraftPlayData(value?: string | null): { data: P_OPERATION | null; error: string | null } {
  const text = value?.trim();
  if (!text) return { data: null, error: null };
  try {
    const jsonData = parseJsonText(text);
    return { data: P_OPERATION.fromjson(jsonData), error: null };
  } catch (error) {
    return {
      data: null,
      error: error instanceof Error ? error.message : "工艺播放数据解析失败"
    };
  }
}

function parseJsonText(text: string): unknown {
  let value: unknown = text;
  for (let index = 0; index < 2 && typeof value === "string"; index += 1) {
    value = JSON.parse(value);
  }
  return value;
}

function operationListHtml(operations: PreviewOperation[]) {
  if (operations.length === 0) {
    return `<li class="preview-process-empty">暂无工艺数据</li>`;
  }
  return operations.map((operation, index) => {
    const name = operation.CraftName?.trim() || `工艺 ${index + 1}`;
    const id = operation.ID || operation.CraftCode || String(index + 1);
    const title = operation.parseError
      ? `${name} / 播放数据解析失败`
      : `${name} / ${operation.frameCount} 帧`;
    return `
      <li class="${index === 0 ? "is-active" : ""}">
        <button type="button" data-process-id="${escapeHtml(String(id))}" data-process-index="${index}" title="${escapeHtml(title)}">${escapeHtml(name)}</button>
      </li>
    `;
  }).join("");
}

function operationStatusHtml(operation: PreviewOperation | null) {
  if (!operation) return "未选择工艺";
  if (operation.parseError) return `播放数据解析失败：${escapeHtml(operation.parseError)}`;
  if (!operation.parsedCraftPlayData) return "当前工艺暂无播放数据";
  return `已读取 ${operation.frameCount} 帧播放数据`;
}

function previewLoadingHtml(text: string) {
  return `
    <div id="previewLoading" class="preview-loading">
      <div class="preview-loading-box">
        <strong>模型加载中</strong>
        <span id="previewLoadingText">${escapeHtml(text)}</span>
        <div class="preview-loading-track">
          <i id="previewLoadingBar" style="width: 0%"></i>
        </div>
      </div>
    </div>
  `;
}

function updatePreviewLoading(event: ProgressEvent) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar) return;

  if (event.lengthComputable && event.total > 0) {
    const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
    text.textContent = `${percent}%`;
    bar.style.width = `${percent}%`;
    return;
  }

  text.textContent = `已加载 ${formatBytes(event.loaded)}`;
  bar.style.width = "28%";
}

function updatePreviewLoadingByCount(loaded: number, total: number) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar || total <= 0) return;

  const percent = Math.min(100, Math.round((loaded / total) * 100));
  text.textContent = `资源加载 ${loaded}/${total}`;
  bar.style.width = `${percent}%`;
}

function showPreviewLoadingError(message: string) {
  const loading = document.querySelector<HTMLElement>("#previewLoading");
  if (loading) {
    loading.innerHTML = `<div class="preview-loading-box is-error"><strong>加载失败</strong><span>${escapeHtml(message)}</span></div>`;
  }
}

function hidePreviewLoading() {
  document.querySelector<HTMLElement>("#previewLoading")?.remove();
}

function beginPreviewContext() {
  disposePreview();
  const context: PreviewLoadContext = {
    controller: new AbortController(),
    disposed: false
  };
  activePreviewContext = context;
  return context;
}

function ensurePreviewContext(context: PreviewLoadContext) {
  if (!isPreviewContextActive(context)) {
    throw new DOMException("预览已关闭", "AbortError");
  }
}

function isPreviewContextActive(context: PreviewLoadContext) {
  return !context.disposed && activePreviewContext === context && !context.controller.signal.aborted;
}

function isAbortError(error: unknown) {
  return error instanceof DOMException && error.name === "AbortError";
}

function cleanupPreviewContextResources(context: PreviewLoadContext) {
  if (context.animationId !== undefined) {
    cancelAnimationFrame(context.animationId);
    context.animationId = undefined;
  }
  context.resizeObserver?.disconnect();
  context.resizeObserver = undefined;
  context.controls?.dispose();
  context.controls = undefined;
  disposeObject3D(context.modelObject);
  context.modelObject = undefined;
  context.environmentMap?.dispose();
  context.environmentMap = null;
  context.renderer?.dispose();
  context.renderer?.domElement.remove();
  context.renderer = undefined;
  if (runtime?.context === context) {
    runtime = null;
  }
}

function disposeObject3D(object?: Object3D) {
  if (!object) return;
  object.traverse((child) => {
    const mesh = child as Object3D & {
      geometry?: { dispose?: () => void };
      material?: unknown;
    };
    mesh.geometry?.dispose?.();
    disposeMaterial(mesh.material);
  });
}

function disposeMaterial(material: unknown) {
  if (Array.isArray(material)) {
    material.forEach(disposeMaterial);
    return;
  }
  if (!material || typeof material !== "object") return;
  const record = material as Record<string, unknown> & { dispose?: () => void };
  for (const value of Object.values(record)) {
    if (value && typeof value === "object" && "isTexture" in value && "dispose" in value) {
      (value as Texture).dispose();
    }
  }
  record.dispose?.();
}

function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
  document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
    try {
      if (!runtime) {
        throw new Error("模型还未加载完成");
      }
      const { renderer, scene, camera, controls } = runtime.context;
      if (!renderer || !scene || !camera || !controls) {
        throw new Error("模型还未加载完成");
      }
      const transparent = document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
      controls.update();
      const oldBackground = scene.background;
      const oldClearAlpha = renderer.getClearAlpha();
      if (transparent) {
        scene.background = null;
        renderer.setClearColor(0x000000, 0);
      }
      renderer.render(scene, camera);
      const thumbnail = renderer.domElement.toDataURL("image/png");
      if (transparent) {
        scene.background = oldBackground;
        renderer.setClearAlpha(oldClearAlpha);
        renderer.render(scene, camera);
      }
      await api(`/api/models/${modelId}/thumbnail`, {
        method: "PUT",
        body: JSON.stringify({ thumbnail })
      });
      await onThumbnailSaved?.();
      notify("缩略图已保存");
    } catch (error) {
      notifyError(error);
    }
  });
}

function getSelectedOperation(operations: PreviewOperation[]) {
  const selectedButton = document.querySelector<HTMLButtonElement>(".preview-process-tree li.is-active button");
  const index = Number(selectedButton?.dataset.processIndex ?? 0);
  return operations[index] ?? null;
}

function readPreviewBasePoint(): PreviewResolvedBasePoint {
  return resolveBasePoint({
    x: readNumberInput("baseX"),
    y: readNumberInput("baseY"),
    z: readNumberInput("baseZ"),
    rx: readNumberInput("baseRx"),
    ry: readNumberInput("baseRy"),
    rz: readNumberInput("baseRz")
  });
}

function readNumberInput(name: string) {
  const value = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`)?.value ?? "0";
  const numberValue = Number(value);
  return Number.isFinite(numberValue) ? numberValue : 0;
}

function bindImportScene(
  model: ModelItem,
  operations: PreviewOperation[],
  onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void
) {
  document.querySelector<HTMLButtonElement>("#previewImportSceneBtn")?.addEventListener("click", async () => {
    try {
      const selectedOperation = getSelectedOperation(operations);
      const payload = buildImportScenePayload({
        model,
        operations,
        selectedOperation,
        basePointEnabled: document.querySelector<HTMLInputElement>("#previewUseBasePoint")?.checked ?? false,
        basePoint: readPreviewBasePoint()
      });

      emitImportScenePayload(payload);
      await onImportScene?.(payload);
      notify("导入场景数据已输出");
    } catch (error) {
      notifyError(error);
    }
  });
}

function resolveBasePoint(basePoint?: PreviewBasePoint): PreviewResolvedBasePoint {
  return {
    x: basePoint?.x ?? zeroBasePoint.x,
    y: basePoint?.y ?? zeroBasePoint.y,
    z: basePoint?.z ?? zeroBasePoint.z,
    rx: basePoint?.rx ?? zeroBasePoint.rx,
    ry: basePoint?.ry ?? zeroBasePoint.ry,
    rz: basePoint?.rz ?? zeroBasePoint.rz
  };
}

function buildImportScenePayload(options: {
  model: ModelItem;
  operations: PreviewOperation[];
  selectedOperation: PreviewOperation | null;
  basePointEnabled: boolean;
  basePoint: PreviewResolvedBasePoint;
}): PreviewImportScenePayload {
  return {
    model: options.model,
    modelUrl: options.model.file_url,
    basePointEnabled: options.basePointEnabled,
    basePoint: options.basePoint,
    rawOperationTree: options.model.operation_tree,
    operations: options.operations,
    selectedOperation: options.selectedOperation,
    selectedCraftPlayData: options.selectedOperation?.parsedCraftPlayData ?? null
  };
}

function bindProcessPlaceholder(operations: PreviewOperation[]) {
  const updateStatus = (operation: PreviewOperation | null) => {
    const status = document.querySelector<HTMLDivElement>("#previewProcessStatus");
    if (status) {
      status.innerHTML = operationStatusHtml(operation);
    }
  };

  document.querySelectorAll<HTMLButtonElement>(".preview-process-tree button").forEach((button) => {
    button.addEventListener("click", () => {
      document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
      button.closest("li")?.classList.add("is-active");
      const index = Number(button.dataset.processIndex ?? 0);
      updateStatus(operations[index] ?? null);
    });
  });

  document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
    const operation = getSelectedOperation(operations);
    updateStatus(operation);
    if (!operation) {
      notify("请先选择工艺");
      return;
    }
    if (operation.parseError) {
      notifyError(`工艺播放数据解析失败：${operation.parseError}`);
      return;
    }
    if (!operation.parsedCraftPlayData) {
      notify("当前工艺暂无播放数据");
      return;
    }
    notify(`${operation.CraftName || "当前工艺"} 已读取 ${operation.frameCount} 帧，播放逻辑待接入`);
  });

  document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
    updateStatus(getSelectedOperation(operations));
    notify("暂停逻辑待接入");
  });
}

async function initPreview(url: string, context: PreviewLoadContext) {
  ensurePreviewContext(context);
  const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
  if (!viewport) return;
  viewport.innerHTML = previewLoadingHtml("正在加载 3D 预览引擎...");

  const [THREE, { OrbitControls: OrbitControlsClass }, { GLTFLoader: GLTFLoaderClass }, { EXRLoader: EXRLoaderClass }] = await Promise.all([
    import("three"),
    import("three/examples/jsm/controls/OrbitControls.js"),
    import("three/examples/jsm/loaders/GLTFLoader.js"),
    import("three/examples/jsm/loaders/EXRLoader.js")
  ]);
  ensurePreviewContext(context);

  THREE.Object3D.DEFAULT_UP.set(0, 0, 1);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf4f7f9);

  const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
  camera.up.set(0, 0, 1);
  camera.position.set(3, -4, 2.5);

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    preserveDrawingBuffer: true
  });
  ensurePreviewContext(context);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  viewport.appendChild(renderer.domElement);
  context.renderer = renderer;

  ensurePreviewContext(context);
  const environmentMap = await loadPreviewEnvironment(THREE, EXRLoaderClass, renderer, scene, context);
  context.environmentMap = environmentMap;
  ensurePreviewContext(context);

  const controls = new OrbitControlsClass(camera, renderer.domElement);
  controls.enableDamping = true;
  context.controls = controls;

  updatePreviewLoadingByText("正在请求模型文件...", 4);
  const loader = new GLTFLoaderClass();
  const gltf = await loadGltfWithProgress(loader, url, context);
  const object = gltf.scene;
  if (!isPreviewContextActive(context)) {
    disposeObject3D(object);
    throw new DOMException("预览已关闭", "AbortError");
  }
  context.modelObject = object;
  scene.add(object);
  fitCameraToObject(THREE, camera, controls, object);
  hidePreviewLoading();
  context.camera = camera;
  context.scene = scene;

  const resize = () => {
    const width = Math.max(viewport.clientWidth, 1);
    const height = Math.max(viewport.clientHeight, 1);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height, false);
  };

  const resizeObserver = new ResizeObserver(resize);
  resizeObserver.observe(viewport);
  context.resizeObserver = resizeObserver;
  resize();

  const animate = () => {
    controls.update();
    renderer.render(scene, camera);
    if (runtime?.context === context && !context.disposed) {
      context.animationId = requestAnimationFrame(animate);
    }
  };

  runtime = {
    renderer,
    scene,
    camera,
    context
  };
  context.animationId = requestAnimationFrame(animate);
}

async function loadPreviewEnvironment(
  THREE: ThreeModule,
  EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader,
  renderer: WebGLRenderer,
  scene: Scene,
  context: PreviewLoadContext
) {
  try {
    updatePreviewLoadingByText("正在准备环境光...", 2);
    const exrTexture = await getPreviewEnvironmentTexture(EXRLoaderClass);
    ensurePreviewContext(context);
    exrTexture.mapping = THREE.EquirectangularReflectionMapping;
    const pmremGenerator = new THREE.PMREMGenerator(renderer);
    const environmentMap = pmremGenerator.fromEquirectangular(exrTexture).texture;
    scene.environment = environmentMap;
    // scene.background = environmentMap;
    pmremGenerator.dispose();
    return environmentMap;
  } catch (error) {
    if (isAbortError(error)) throw error;
    scene.background = new THREE.Color(0xf4f7f9);
    return null;
  }
}

function getPreviewEnvironmentTexture(
  EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader
) {
  if (!previewEnvironmentTexturePromise) {
    previewEnvironmentTexturePromise = new EXRLoaderClass()
      .loadAsync(previewEnvironmentUrl)
      .catch((error) => {
        previewEnvironmentTexturePromise = null;
        throw error;
      });
  }
  return previewEnvironmentTexturePromise;
}

async function loadGltfWithProgress(loader: GLTFLoader, url: string, context: PreviewLoadContext) {
  try {
    const buffer = await fetchModelArrayBuffer(url, context);
    ensurePreviewContext(context);
    updatePreviewLoadingByText("正在解析模型...", 96);
    const gltf = await (loader as ParseableGltfLoader).parseAsync(buffer, basePathFromUrl(url));
    ensurePreviewContext(context);
    updatePreviewLoadingByText("模型解析完成", 100);
    return gltf;
  } catch (error) {
    if (!isAbortError(error)) {
      showPreviewLoadingError(error instanceof Error ? error.message : "模型文件加载失败");
    }
    throw error;
  }
}

async function fetchModelArrayBuffer(url: string, context: PreviewLoadContext) {
  const response = await fetch(url, { signal: context.controller.signal });
  ensurePreviewContext(context);
  if (!response.ok) {
    throw new Error(`模型文件请求失败：${response.status}`);
  }

  const total = Number(response.headers.get("content-length") ?? 0);
  if (!response.body) {
    const buffer = await response.arrayBuffer();
    ensurePreviewContext(context);
    updatePreviewLoadingByText(`已加载 ${formatBytes(buffer.byteLength)}`, 92);
    return buffer;
  }

  const reader = response.body.getReader();
  const chunks: Uint8Array[] = [];
  let loaded = 0;

  while (true) {
    ensurePreviewContext(context);
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;
    chunks.push(value);
    loaded += value.byteLength;
    updatePreviewLoadingFromBytes(loaded, total);
  }
  ensurePreviewContext(context);

  const buffer = new Uint8Array(loaded);
  let offset = 0;
  for (const chunk of chunks) {
    buffer.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return buffer.buffer;
}

function updatePreviewLoadingFromBytes(loaded: number, total: number) {
  if (total > 0) {
    updatePreviewLoadingByText(`${Math.round((loaded / total) * 100)}% (${formatBytes(loaded)} / ${formatBytes(total)})`, (loaded / total) * 92);
    return;
  }
  updatePreviewLoadingByText(`已加载 ${formatBytes(loaded)}`, 28);
}

function updatePreviewLoadingByText(message: string, percent: number) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar) return;
  text.textContent = message;
  bar.style.width = `${Math.min(100, Math.max(0, Math.round(percent)))}%`;
}

function basePathFromUrl(url: string) {
  try {
    return new URL(".", url).href;
  } catch {
    return url.slice(0, url.lastIndexOf("/") + 1);
  }
}

function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, controls: OrbitControls, object: Object3D) {
  const box = new THREE.Box3().setFromObject(object);
  const size = box.getSize(new THREE.Vector3());
  const center = box.getCenter(new THREE.Vector3());
  const maxSize = Math.max(size.x, size.y, size.z) || 1;
  const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));

  camera.up.set(0, 0, 1);
  camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
  camera.near = Math.max(distance / 100, 0.01);
  camera.far = distance * 100;
  camera.updateProjectionMatrix();

  controls.target.copy(center);
  controls.minDistance = distance / 8;
  controls.maxDistance = distance * 8;
  controls.update();
}

function disposePreview() {
  const context = activePreviewContext ?? runtime?.context ?? null;
  if (context) {
    context.disposed = true;
    context.controller.abort();
    cleanupPreviewContextResources(context);
    if (activePreviewContext === context) {
      activePreviewContext = null;
    }
  }
  runtime = null;
}
