function render({ model, el }) {
  el.replaceChildren();

  const root = document.createElement("div");
  root.className = "topovec-camera-viewer";
  root.dataset.helpVisible = "true";

  const toolbar = document.createElement("div");
  toolbar.className = "topovec-camera-toolbar";

  const resetButton = document.createElement("button");
  resetButton.type = "button";
  resetButton.textContent = "Reset";

  const snapButton = document.createElement("button");
  snapButton.type = "button";
  snapButton.textContent = "Snap";

  const projectionButton = document.createElement("button");
  projectionButton.type = "button";

  const status = document.createElement("div");
  status.className = "topovec-camera-status";

  toolbar.append(resetButton, snapButton, projectionButton, status);

  const viewport = document.createElement("div");
  viewport.className = "topovec-camera-viewport";

  const image = document.createElement("img");
  image.className = "topovec-camera-image";
  image.draggable = false;
  image.alt = "topovec render";

  const busyBadge = document.createElement("div");
  busyBadge.className = "topovec-camera-busy";
  busyBadge.textContent = "Rendering...";

  viewport.append(image, busyBadge);

  const help = document.createElement("div");
  help.className = "topovec-camera-help";

  viewport.append(help);

  root.append(toolbar, viewport);
  el.appendChild(root);

  const updateImage = () => {
    const src = model.get("image_data");
    if (src && image.src !== src) {
      image.src = src;
    }
  };

  const updateText = () => {
    status.textContent = model.get("status_text") || "";
    help.textContent = model.get("controls_text") || "";
    const mode = model.get("projection_mode") || "Perspective";
    projectionButton.textContent = `Projection: ${mode}`;
  };

  const updateGeometry = () => {
    const width = model.get("width") || 512;
    const height = model.get("height") || width;
    root.style.maxWidth = "100%";
    viewport.style.aspectRatio = `${width} / ${height}`;
  };

  const updateToolbarVisibility = () => {
    root.dataset.showToolbar = model.get("show_toolbar") ? "true" : "false";
  };

  const usesCustomTransport = () => model.get("use_custom_transport") ? true : false;

  let pendingEvents = [];
  let waitingForRender = false;
  let lastBusy = false;
  let wheelCommitTimer = null;

  const updateBusy = () => {
    const busy = model.get("busy") ? true : false;
    root.dataset.busy = busy ? "true" : "false";
    if (!busy && (lastBusy || waitingForRender)) {
      waitingForRender = false;
      flushPendingEvents();
    }
    lastBusy = busy;
  };

  const clearScheduledCommit = () => {
    if (wheelCommitTimer !== null) {
      clearTimeout(wheelCommitTimer);
      wheelCommitTimer = null;
    }
  };

  const dropPendingCameraCommits = () => {
    pendingEvents = pendingEvents.filter(
      (event) => event.type !== "camera_commit"
    );
  };

  const normalizeEvent = (eventType, payload = {}) => {
    if (eventType === "reset") {
      return { type: "reset_camera" };
    }
    if (eventType === "snap") {
      return { type: "snap_camera" };
    }
    if (eventType === "toggle_perspective") {
      return { type: "toggle_perspective" };
    }
    if (eventType === "camera_commit") {
      return { type: "camera_commit" };
    }
    return {
      type: "camera_delta",
      event_type: eventType,
      dx: payload.dx ?? 0,
      dy: payload.dy ?? 0,
      wheel: payload.wheel ?? 0,
    };
  };

  const emitEvent = (event) => {
    waitingForRender = true;
    if (usesCustomTransport()) {
      model.send(event);
      return;
    }
    const eventType =
      event.type === "reset_camera"
        ? "reset"
        : event.type === "snap_camera"
          ? "snap"
        : event.type === "toggle_perspective"
          ? "toggle_perspective"
          : event.type === "camera_commit"
            ? "camera_commit"
            : event.event_type || "";
    model.set("event_type", eventType);
    model.set("event_dx", event.dx ?? 0);
    model.set("event_dy", event.dy ?? 0);
    model.set("event_wheel", event.wheel ?? 0);
    model.set("event_id", (model.get("event_id") || 0) + 1);
    model.save_changes();
  };

  const flushPendingEvents = () => {
    if (waitingForRender || model.get("busy")) {
      return;
    }
    const nextEvent = pendingEvents.shift();
    if (!nextEvent) {
      return;
    }
    emitEvent(nextEvent);
  };

  const queueEvent = (eventType, payload = {}) => {
    const event = normalizeEvent(eventType, payload);
    if (event.type === "camera_delta") {
      dropPendingCameraCommits();
    }
    if (event.type === "reset_camera" || event.type === "snap_camera") {
      clearScheduledCommit();
      pendingEvents = [event];
    } else {
      const tail = pendingEvents[pendingEvents.length - 1];
      if (
        tail &&
        tail.type === "camera_delta" &&
        event.type === "camera_delta" &&
        tail.event_type === event.event_type
      ) {
        tail.dx += event.dx;
        tail.dy += event.dy;
        tail.wheel += event.wheel;
      } else if (event.type === "camera_commit") {
        dropPendingCameraCommits();
        pendingEvents.push(event);
      } else {
        pendingEvents.push(event);
      }
    }
    flushPendingEvents();
  };

  let pointerId = null;
  let dragMode = null;
  let lastX = 0;
  let lastY = 0;
  let accumDx = 0;
  let accumDy = 0;
  let dragDirty = false;
  let frame = null;

  const flushDrag = () => {
    frame = null;
    if (!dragMode) {
      return;
    }
    const rect = viewport.getBoundingClientRect();
    const width = Math.max(rect.width, 1);
    const height = Math.max(rect.height, 1);
    if (accumDx === 0 && accumDy === 0) {
      return;
    }
    queueEvent(dragMode, {
      dx: accumDx / width,
      dy: accumDy / height,
    });
    accumDx = 0;
    accumDy = 0;
  };

  const scheduleDragFlush = () => {
    if (frame === null) {
      frame = requestAnimationFrame(flushDrag);
    }
  };

  const queueCameraCommit = () => {
    dropPendingCameraCommits();
    queueEvent("camera_commit");
  };

  const scheduleWheelCommit = () => {
    clearScheduledCommit();
    wheelCommitTimer = setTimeout(() => {
      wheelCommitTimer = null;
      queueCameraCommit();
    }, 160);
  };

  const onReset = () => {
    clearScheduledCommit();
    queueEvent("reset");
  };
  const onSnap = () => {
    clearScheduledCommit();
    queueEvent("snap");
  };
  const onToggleProjection = () => {
    clearScheduledCommit();
    queueEvent("toggle_perspective");
  };
  const onContextMenu = (event) => event.preventDefault();
  const onPointerDown = (event) => {
    clearScheduledCommit();
    dropPendingCameraCommits();
    pointerId = event.pointerId;
    dragMode =
      event.button === 2 || event.shiftKey
        ? "pan"
        : "rotate";
    lastX = event.clientX;
    lastY = event.clientY;
    accumDx = 0;
    accumDy = 0;
    dragDirty = false;
    root.dataset.dragMode = dragMode;
    viewport.setPointerCapture(pointerId);
    event.preventDefault();
  };
  const onPointerMove = (event) => {
    if (pointerId !== event.pointerId || dragMode === null) {
      return;
    }
    const deltaX = event.clientX - lastX;
    const deltaY = event.clientY - lastY;
    accumDx += deltaX;
    accumDy += deltaY;
    lastX = event.clientX;
    lastY = event.clientY;
    if (deltaX !== 0 || deltaY !== 0) {
      dragDirty = true;
    }
    scheduleDragFlush();
    event.preventDefault();
  };
  const finishPointer = (event) => {
    if (pointerId !== event.pointerId) {
      return;
    }
    if (frame !== null) {
      cancelAnimationFrame(frame);
      flushDrag();
    }
    const shouldCommit = dragDirty;
    if (viewport.hasPointerCapture(event.pointerId)) {
      viewport.releasePointerCapture(event.pointerId);
    }
    pointerId = null;
    dragMode = null;
    dragDirty = false;
    root.dataset.dragMode = "";
    if (shouldCommit) {
      queueCameraCommit();
    }
    event.preventDefault();
  };
  const onWheel = (event) => {
    clearScheduledCommit();
    dropPendingCameraCommits();
    const primaryDelta =
      Math.abs(event.deltaX) > Math.abs(event.deltaY)
        ? event.deltaX
        : event.deltaY;
    const wheel = Math.max(-8, Math.min(8, primaryDelta / 120));
    if (event.shiftKey) {
      queueEvent("roll", { dx: wheel });
    } else {
      queueEvent("zoom", { wheel });
    }
    scheduleWheelCommit();
    event.preventDefault();
  };
  const onDoubleClick = (event) => {
    root.dataset.helpVisible =
      root.dataset.helpVisible === "false" ? "true" : "false";
    event.preventDefault();
  };

  resetButton.addEventListener("click", onReset);
  snapButton.addEventListener("click", onSnap);
  projectionButton.addEventListener("click", onToggleProjection);
  viewport.addEventListener("contextmenu", onContextMenu);
  viewport.addEventListener("pointerdown", onPointerDown);
  viewport.addEventListener("pointermove", onPointerMove);
  viewport.addEventListener("pointerup", finishPointer);
  viewport.addEventListener("pointercancel", finishPointer);
  viewport.addEventListener("wheel", onWheel, { passive: false });
  viewport.addEventListener("dblclick", onDoubleClick);

  model.on("change:image_data", updateImage);
  model.on("change:status_text", updateText);
  model.on("change:controls_text", updateText);
  model.on("change:projection_mode", updateText);
  model.on("change:busy", updateBusy);
  model.on("change:width", updateGeometry);
  model.on("change:height", updateGeometry);
  model.on("change:show_toolbar", updateToolbarVisibility);

  updateImage();
  updateText();
  updateBusy();
  updateGeometry();
  updateToolbarVisibility();

  return () => {
    if (frame !== null) {
      cancelAnimationFrame(frame);
    }
    clearScheduledCommit();
    resetButton.removeEventListener("click", onReset);
    snapButton.removeEventListener("click", onSnap);
    projectionButton.removeEventListener("click", onToggleProjection);
    viewport.removeEventListener("contextmenu", onContextMenu);
    viewport.removeEventListener("pointerdown", onPointerDown);
    viewport.removeEventListener("pointermove", onPointerMove);
    viewport.removeEventListener("pointerup", finishPointer);
    viewport.removeEventListener("pointercancel", finishPointer);
    viewport.removeEventListener("wheel", onWheel);
    viewport.removeEventListener("dblclick", onDoubleClick);
    model.off("change:image_data", updateImage);
    model.off("change:status_text", updateText);
    model.off("change:controls_text", updateText);
    model.off("change:projection_mode", updateText);
    model.off("change:busy", updateBusy);
    model.off("change:width", updateGeometry);
    model.off("change:height", updateGeometry);
    model.off("change:show_toolbar", updateToolbarVisibility);
  };
}

export default { render };
