const core = window.PetPackCore;
const OFFICIAL_SCENE_MODE_KEY = "official_scene_library";
const SCENE_INTERACTION_MODE_KEY = "scene_interaction";
const WIDGET_INTERACTION_MODE_KEY = "widget_interaction";
const OFFICIAL_SCENE_GROUPS = [
  ["workload", "工作"],
  ["attention", "专注"],
  ["application", "应用"],
  ["relationship", "陪伴"],
];

const SCENE_GROUP_ORDER = ["workload", "attention", "relationship", "application"];
OFFICIAL_SCENE_GROUPS.sort((a, b) => SCENE_GROUP_ORDER.indexOf(a[0]) - SCENE_GROUP_ORDER.indexOf(b[0]));

const state = {
  metadata: {},
  preview: null,
  animations: {},
  draftAnimation: null,
  moodBindings: {},
  behaviorBindings: {},
  behaviorTempo: null,
  rhythmPreview: null,
  appReactions: {},
  selectedFrames: {},
  timers: {},
  expandedAnimations: {},
  selectedAnimationKey: "",
  activeInteractionType: "idle_interaction",
  activeJudgementByType: {},
  activeOfficialSceneGroup: "workload",
  activeSceneInteractionGroup: "workload",
  interactionConfig: {},
  officialSceneRules: [],
  sceneAppEventBindings: {},
  processBehaviorRules: [],
  previewAnimationKey: "",
  previewContext: null,
  pendingBehaviorTarget: null,
  previewPlaying: true,
  previewFrameIndex: 0,
  mainPreviewTimer: null,
  activeAnimationUploadMode: "normal",
  importedPackId: "",
  importedPackSignature: null,
  publishedCdkeyPack: null,
  generatedCdkeyCodes: [],
  account: {
    loggedIn: false,
    developerMax: false,
    username: "",
    cdkeyQuotaBalance: 0,
    paymentProducts: [],
  },
};

function sanitizeOfficialSceneRules(rules) {
  if (!Array.isArray(rules)) return [];
  return rules
    .filter((rule) => rule && typeof rule === "object")
    .map((rule) => {
      const sanitized = {
        scene_id: String(rule.scene_id || rule.sceneId || "").trim(),
      };
      const pet_behaviors = rule.pet_behaviors;
      if (Array.isArray(pet_behaviors)) {
        sanitized.pet_behaviors = pet_behaviors
          .filter((behavior) => behavior && typeof behavior === "object")
          .map((behavior) => {
            const animation = String(behavior.animation || "").trim();
            return {
              slot: Math.max(1, Math.round(Number(behavior.slot) || 1)),
              animation,
              weight: 100,
            };
          });
      }
      return sanitized;
    })
    .filter((rule) => rule.scene_id && Array.isArray(rule.pet_behaviors) && rule.pet_behaviors.some((behavior) => behavior.animation));
}

function sanitizeProcessBehaviorRules(rules) {
  if (!Array.isArray(rules)) return [];
  return rules
    .filter((rule) => rule && typeof rule === "object")
    .map((rule, index) => {
      const owner = rule.owner && typeof rule.owner === "object" ? rule.owner : {};
      const trigger = rule.trigger && typeof rule.trigger === "object" ? rule.trigger : {};
      return {
        id: String(rule.id || `process_rule_${Date.now()}_${index}`),
        owner: {
          type: "pet_behavior",
          interaction_type: String(owner.interaction_type || owner.interactionType || ""),
          judgement: String(owner.judgement || ""),
          slot: Math.max(1, Math.round(Number(owner.slot) || 1)),
        },
        name: String(rule.name || "").trim(),
        trigger: sanitizeProcessTrigger(trigger),
        animation: String(rule.animation || ""),
      };
    })
    .filter((rule) => rule.owner.interaction_type && rule.owner.judgement && rule.animation);
}

function sanitizeProcessTrigger(trigger) {
  const requestedType = String(trigger.type || "pomodoro");
  const legacyTypeMap = {
    app_open: "app_enter",
    app_foreground: "app_duration",
  };
  const type = legacyTypeMap[requestedType] || requestedType;
  if (type === "app_duration" || type === "app_enter" || type === "app_leave") {
    const category = ["coding", "game", "video"].includes(trigger.app_category)
      ? trigger.app_category
      : (["coding", "game", "video"].includes(trigger.category) ? trigger.category : "coding");
    const sanitized = { type, app_category: category };
    const timeRange = trigger.time_range || trigger.timeRange;
    if (timeRange && typeof timeRange === "object") {
      sanitized.time_range = {
        start: String(timeRange.start || "00:00"),
        end: String(timeRange.end || "23:59"),
      };
    }
    if (type === "app_duration") {
      const minutes = Math.max(1, Math.round(Number(trigger.duration_minutes || trigger.durationMinutes) || 30));
      sanitized.duration_minutes = minutes;
    }
    return sanitized;
  }
  if (type === "manual_test") return { type: "manual_test" };
  return { type: "pomodoro", event: String(trigger.event || "running") };
}

function sanitizeSceneAppEventBindings(bindings) {
  const out = {};
  const appKeys = new Set((core.SCENE_APP_CARDS || []).map((item) => item.key));
  const eventKeys = new Set((core.SCENE_APP_EVENTS || []).map((item) => item.key));
  for (const appKey of appKeys) {
    const source = bindings?.[appKey] && typeof bindings[appKey] === "object" ? bindings[appKey] : {};
    out[appKey] = {};
    for (const eventKey of eventKeys) {
      out[appKey][eventKey] = String(source[eventKey] || "");
    }
  }
  return out;
}

const els = {
  name: document.getElementById("petName"),
  author: document.getElementById("author"),
  version: document.getElementById("version"),
  description: document.getElementById("description"),
  descriptionCounter: document.getElementById("descriptionCounter"),
  previewInput: document.getElementById("previewInput"),
  previewDropzone: document.getElementById("previewDropzone"),
  removePreview: document.getElementById("removePreviewBtn"),
  previewThumbCanvas: document.getElementById("previewThumbCanvas"),
  animationList: document.getElementById("animationStateList"),
  currentAnimationName: document.getElementById("currentAnimationName"),
  currentAnimationFps: document.getElementById("currentAnimationFps"),
  currentAnimationLoop: document.getElementById("currentAnimationLoop"),
  currentLoopDuration: document.getElementById("currentLoopDuration"),
  currentLoopInfinite: document.getElementById("currentLoopInfinite"),
  animationUploadTabs: document.querySelectorAll("[data-animation-mode]"),
  animationUploadPanels: {
    normal: document.getElementById("normalAnimationPanel"),
    loop: document.getElementById("loopAnimationPanel"),
  },
  loopAdvancedOptions: document.getElementById("loopAdvancedOptions"),
  loopDurationField: document.getElementById("loopDurationField"),
  loopSegmentInputs: document.getElementById("loopSegmentInputs"),
  loopFrameInputs: {
    intro: document.getElementById("loopIntroFrameInput"),
    loop: document.getElementById("loopLoopFrameInput"),
    outro: document.getElementById("loopOutroFrameInput"),
  },
  loopSheetInputs: {
    intro: document.getElementById("loopIntroSheetInput"),
    loop: document.getElementById("loopLoopSheetInput"),
    outro: document.getElementById("loopOutroSheetInput"),
  },
  loopWebmInputs: {
    intro: document.getElementById("loopIntroWebmInput"),
    loop: document.getElementById("loopLoopWebmInput"),
    outro: document.getElementById("loopOutroWebmInput"),
  },
  unifiedFrameInput: document.getElementById("unifiedFrameInput"),
  unifiedSheetInput: document.getElementById("unifiedSheetInput"),
  unifiedWebmInput: document.getElementById("unifiedWebmInput"),
  unifiedFrameStrip: document.getElementById("unifiedFrameStrip"),
  animationAnchorEditor: document.getElementById("animationAnchorEditor"),
  currentFrameSummary: document.getElementById("currentFrameSummary"),
  animationWorkflowStatus: document.getElementById("animationWorkflowStatus"),
  saveAnimationName: document.getElementById("saveAnimationNameBtn"),
  behaviorEditor: document.getElementById("behaviorEditor"),
  appReactionSelects: {
    chrome: document.getElementById("appReactionChrome"),
    photoshop: document.getElementById("appReactionPhotoshop"),
    vscode: document.getElementById("appReactionVscode"),
    steam: document.getElementById("appReactionSteam"),
    bilibili: document.getElementById("appReactionBilibili"),
  },
  importPetpackInput: document.getElementById("importPetpackInput"),
  importPetpackBtn: document.getElementById("importPetpackBtn"),
  topExportBtn: document.getElementById("topExportBtn"),
  exportBtn: document.getElementById("exportBtn"),
  clearDraftBtn: document.getElementById("clearDraftBtn"),
  errors: document.getElementById("errorsList"),
  warnings: document.getElementById("warningsList"),
  generatedId: document.getElementById("generatedId"),
  frameSize: document.getElementById("frameSizeText"),
  previewCanvas: document.getElementById("previewCanvas"),
  previewAnchorStage: document.getElementById("previewAnchorStage"),
  previewAnchorMarkerLayer: document.getElementById("previewAnchorMarkerLayer"),
  previewCard: document.querySelector(".live-preview-card"),
  previewContext: document.getElementById("previewContextText"),
  previewAnimationSelect: document.getElementById("previewAnimationSelect"),
  previewPlayBtn: document.getElementById("previewPlayBtn"),
  previewFpsSelect: document.getElementById("previewFpsSelect"),
  topActions: document.querySelector(".top-actions"),
  exportName: document.getElementById("exportName"),
  exportVersion: document.getElementById("exportVersion"),
  exportEstimate: document.getElementById("exportEstimate"),
  exportPermission: document.getElementById("exportPermission"),
  cdkeyPublishStatus: document.getElementById("cdkeyPublishStatus"),
  cdkeyQuotaBalance: document.getElementById("cdkeyQuotaBalance"),
  openCdkeyPackages: document.getElementById("openCdkeyPackagesBtn"),
  cdkeyPackagesPage: document.getElementById("cdkeyPackagesPage"),
  buyCdkeyQuota100: document.getElementById("buyCdkeyQuota100Btn"),
  buyCdkeyQuota300: document.getElementById("buyCdkeyQuota300Btn"),
  buyCdkeyQuota1000: document.getElementById("buyCdkeyQuota1000Btn"),
  publishCdkeyPack: document.getElementById("publishCdkeyPackBtn"),
  cdkeyBatchName: document.getElementById("cdkeyBatchName"),
  cdkeyBatchQuantity: document.getElementById("cdkeyBatchQuantity"),
  cdkeyBatchExpiresAt: document.getElementById("cdkeyBatchExpiresAt"),
  createCdkeyBatch: document.getElementById("createCdkeyBatchBtn"),
  cdkeyCodesOutput: document.getElementById("cdkeyCodesOutput"),
  downloadCdkeyCsv: document.getElementById("downloadCdkeyCsvBtn"),
  permissionState: document.getElementById("permissionState"),
  permissionDetail: document.getElementById("permissionDetail"),
  accountMenu: document.getElementById("accountMenu"),
  upgradeDeveloperMax: document.getElementById("upgradeDeveloperMaxBtn"),
  loginActions: document.querySelectorAll('[data-action="login"]'),
  logoutActions: document.querySelectorAll('[data-action="logout"]'),
  upgradeActions: document.querySelectorAll('[data-action="upgrade-max"]'),
  template: document.getElementById("animationCardTemplate"),
};

const DEFAULT_CDKEY_PACKAGES_MARKUP = els.cdkeyPackagesPage?.innerHTML || "";
const LOCAL_DRAFT_KEY = "doudou_diy_local_draft";
const LOCAL_DRAFT_DB_NAME = "doudou_diy_drafts";
const LOCAL_DRAFT_STORE_NAME = "drafts";
const LOCAL_DRAFT_RECORD_KEY = "local";
const DRAFT_PREVIEW_KEY = "__draft__";
const DESKTOP_PREVIEW_ENDPOINT = "http://127.0.0.1:8765/preview-draft";
const DIY_API_BASE_KEY = "doudou_diy_api_base";
const DIY_AUTH_TOKEN_KEY = "doudou_diy_auth_token";
const CLOUD_API_BASE = "https://api.cbox.games";
const DESKTOP_PREVIEW_LOOP_FALLBACK_SECONDS = 10;
const CLEAR_DRAFT_LABEL = "清空草稿";
const CLEAR_DRAFT_CONFIRM_LABEL = "再次点击确认清空";
const CLEAR_DRAFT_CONFIRM_TIMEOUT_MS = 4000;
const ANIMATION_ANCHOR_LABELS = {
  top: "上边缘",
  bottom: "下边缘",
  left: "左边缘",
  right: "右边缘",
  scale: "缩放点",
};
const ANIMATION_ANCHOR_COLORS = {
  top: "#4d8cff",
  bottom: "#42b883",
  left: "#6d3eeb",
  right: "#e03131",
  scale: "#f4a12f",
};
let clearDraftConfirmTimer = null;
let draftAssetRestorePending = false;
let draftRestoreToken = 0;
let sceneApplicationPopover = null;

function trackDiyEvent(eventName, properties = {}) {
  if (typeof fetch !== "function") return;
  const base = diyApiBase();
  const token = localStorage.getItem(DIY_AUTH_TOKEN_KEY) || "";
  fetch(`${base}/analytics/track`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify({ event_name: eventName, source: "diy_web", properties }),
  }).catch(() => {});
}

const BODY_LABELS = {
  idle: "Idle（待机）",
  walk: "Walk（行走）",
  drag: "Drag（拖拽）",
  sleep: "Sleep（睡眠）",
  talk: "Talk（说话）",
  window: "Window（窗口行为）",
};

const BODY_DESCRIPTIONS = {
  idle: "当前客户端必需动画。",
  walk: "当前客户端必需动画。",
  drag: "当前客户端必需动画。",
  sleep: "选填行为动画，未提供时导出会提示警告。",
  talk: "选填行为动画，未提供时导出会提示警告。",
  window: "选填行为动画，未提供时导出会提示警告。",
};

function updateMetadata() {
  collectMetadata();
  updateDescriptionCounter();
  refreshReview();
}

function collectMetadata() {
  state.metadata = {
    name: els.name.value.trim(),
    author: els.author.value.trim(),
    version: els.version.value.trim(),
    description: els.description.value.trim(),
  };
}

function bytesToBase64(bytes) {
  if (!bytes || !bytes.length) return "";
  let binary = "";
  const CHUNK = 0x8000;
  for (let i = 0; i < bytes.length; i += CHUNK) {
    binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
  }
  return btoa(binary);
}

function base64ToBytes(b64) {
  if (!b64) return new Uint8Array();
  const binary = atob(b64);
  const out = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
  return out;
}

function serializeImageMeta(meta) {
  if (!meta || !meta.bytes) return null;
  return {
    name: meta.name || "",
    width: meta.width || 0,
    height: meta.height || 0,
    base64: bytesToBase64(meta.bytes),
  };
}

function serializeVideoMeta(meta) {
  if (!meta) return null;
  return {
    name: meta.name || "",
    width: meta.width || 0,
    height: meta.height || 0,
    durationSeconds: Number(meta.durationSeconds) || 0,
    hasBytes: Boolean(meta.bytes),
  };
}

function emptyAnimationSegment() {
  return { assetType: "png_frames", frames: [], sheet: null, video: null };
}

function cloneAnimationSegment(segment) {
  const cloned = {
    assetType: segment?.assetType || (segment?.video ? "webm" : "png_frames"),
    frames: [...(segment?.frames || [])],
    sheet: segment?.sheet || null,
    video: segment?.video || null,
  };
  const anchors = cloneAnimationAnchors(segment?.anchors);
  if (anchors) cloned.anchors = anchors;
  return cloned;
}

function ensureLoopSegments(animation) {
  animation.segments = animation.segments || {};
  for (const segment of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
    if (!animation.segments[segment]) animation.segments[segment] = emptyAnimationSegment();
    animation.segments[segment].assetType = animation.segments[segment].assetType || "png_frames";
    animation.segments[segment].frames = animation.segments[segment].frames || [];
    animation.segments[segment].sheet = animation.segments[segment].sheet || null;
    animation.segments[segment].video = animation.segments[segment].video || null;
  }
  return animation.segments;
}

function serializeAnimationSegment(segment) {
  const payload = {
    assetType: segment?.assetType || (segment?.video ? "webm" : "png_frames"),
    frames: (segment?.frames || []).map(serializeImageMeta).filter(Boolean),
    sheet: serializeImageMeta(segment?.sheet),
    video: serializeVideoMeta(segment?.video),
  };
  const anchors = cloneAnimationAnchors(segment?.anchors);
  if (anchors) payload.anchors = anchors;
  return payload;
}

function serializeAnimation(animation) {
  if (!animation) return null;
  const payload = {
    key: animation.key,
    required: Boolean(animation.required),
    fps: animation.fps,
    loop: animation.loop !== false,
    assetType: animation.assetType || (animation.video ? "webm" : "png_frames"),
    frames: (animation.frames || []).map(serializeImageMeta).filter(Boolean),
    sheet: serializeImageMeta(animation.sheet),
    video: serializeVideoMeta(animation.video),
  };
  if (core.isLoopAnimation?.(animation)) {
    const segments = ensureLoopSegments(animation);
    payload.kind = "loop";
    payload.loopDurationSeconds = animation.loopDurationSeconds ?? null;
    payload.segments = Object.fromEntries(
      (core.LOOP_SEGMENTS || ["intro", "loop", "outro"]).map((segment) => [
        segment,
        serializeAnimationSegment(segments[segment]),
      ]),
    );
  }
  const anchors = cloneAnimationAnchors(animation.anchors);
  if (anchors) payload.anchors = anchors;
  return payload;
}

function serializeAnimations(animations) {
  const out = {};
  for (const [key, animation] of Object.entries(animations || {})) {
    const payload = serializeAnimation(animation);
    if (payload) out[key] = payload;
  }
  return out;
}

async function rehydrateImageMeta(payload) {
  if (!payload || !payload.base64) return null;
  try {
    const bytes = base64ToBytes(payload.base64);
    const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
    const image = await loadImage(url);
    return {
      name: payload.name || "",
      width: payload.width || image.naturalWidth || image.width || 0,
      height: payload.height || image.naturalHeight || image.height || 0,
      bytes,
      url,
      image,
    };
  } catch (_error) {
    return null;
  }
}

async function rehydrateAnimationSegment(payload) {
  const frames = await Promise.all((payload?.frames || []).map(rehydrateImageMeta));
  const sheet = await rehydrateImageMeta(payload?.sheet);
  const segment = {
    assetType: payload?.assetType || (payload?.video ? "webm" : "png_frames"),
    frames: frames.filter(Boolean),
    sheet: sheet || null,
    video: payload?.video ? { ...payload.video, missingBytes: !payload.video.hasBytes } : null,
  };
  const anchors = cloneAnimationAnchors(payload?.anchors);
  if (anchors) segment.anchors = anchors;
  return segment;
}

async function rehydrateAnimation(payload) {
  if (!payload) return null;
  const frames = await Promise.all((payload.frames || []).map(rehydrateImageMeta));
  const sheet = await rehydrateImageMeta(payload.sheet);
  const animation = {
    key: payload.key,
    required: Boolean(payload.required),
    fps: payload.fps || 8,
    loop: payload.loop !== false,
    assetType: payload.assetType || (payload.video ? "webm" : "png_frames"),
    frames: frames.filter(Boolean),
    sheet: sheet || null,
    video: payload.video ? { ...payload.video, missingBytes: !payload.video.hasBytes } : null,
  };
  const anchors = cloneAnimationAnchors(payload.anchors);
  if (anchors) animation.anchors = anchors;
  if (payload.kind === "loop" || payload.type === "loop") {
    animation.kind = "loop";
    animation.loopDurationSeconds = payload.loopDurationSeconds ?? null;
    animation.segments = {};
    for (const segment of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
      animation.segments[segment] = await rehydrateAnimationSegment(payload.segments?.[segment]);
    }
    ensureLoopSegments(animation);
  }
  return animation;
}

async function rehydrateDraftAssets(draft, restoreToken = null) {
  if (!draft || typeof draft !== "object") return;
  if (draft.animations && typeof draft.animations === "object") {
    const restored = {};
    for (const [key, payload] of Object.entries(draft.animations)) {
      const animation = await rehydrateAnimation(payload);
      if (animation) restored[key] = animation;
    }
    if (restoreToken !== null && restoreToken !== draftRestoreToken) return;
    state.animations = restored;
  }
  if (draft.draftAnimation) {
    const draftAnimation = await rehydrateAnimation(draft.draftAnimation);
    if (restoreToken !== null && restoreToken !== draftRestoreToken) return;
    state.draftAnimation = draftAnimation;
  }
  if (draft.preview) {
    const preview = await rehydrateImageMeta(draft.preview);
    if (restoreToken !== null && restoreToken !== draftRestoreToken) return;
    if (preview) {
      state.preview = preview;
      if (els.previewDropzone) els.previewDropzone.classList.add("has-preview");
      if (els.previewThumbCanvas) drawStill(els.previewThumbCanvas, preview.image);
    }
  }
  if (typeof draft.selectedAnimationKey === "string") {
    state.selectedAnimationKey = draft.selectedAnimationKey;
  }
  if (draft.expandedAnimations && typeof draft.expandedAnimations === "object") {
    state.expandedAnimations = { ...draft.expandedAnimations };
  }
  pruneMissingAnimationReferences();
  renderAllAnimationCards();
  refreshReview();
}

function readDraftRaw() {
  try {
    return localStorage.getItem(LOCAL_DRAFT_KEY);
  } catch (_error) {
    return null;
  }
}

function openDraftDatabase() {
  if (typeof indexedDB === "undefined" || !indexedDB?.open) return Promise.resolve(null);
  return new Promise((resolve) => {
    let request;
    try {
      request = indexedDB.open(LOCAL_DRAFT_DB_NAME, 1);
    } catch (_error) {
      resolve(null);
      return;
    }
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames?.contains?.(LOCAL_DRAFT_STORE_NAME)) {
        db.createObjectStore(LOCAL_DRAFT_STORE_NAME);
      }
    };
    request.onsuccess = (event) => resolve(event.target.result);
    request.onerror = () => resolve(null);
  });
}

function draftStoreRequest(mode, action) {
  return openDraftDatabase().then((db) => {
    if (!db) return null;
    return new Promise((resolve) => {
      let request;
      try {
        const transaction = db.transaction(LOCAL_DRAFT_STORE_NAME, mode);
        const store = transaction.objectStore(LOCAL_DRAFT_STORE_NAME);
        request = action(store);
      } catch (_error) {
        if (typeof db.close === "function") db.close();
        resolve(null);
        return;
      }
      if (!request) {
        if (typeof db.close === "function") db.close();
        resolve(null);
        return;
      }
      request.onsuccess = (event) => {
        if (typeof db.close === "function") db.close();
        resolve(event.target.result ?? null);
      };
      request.onerror = () => {
        if (typeof db.close === "function") db.close();
        resolve(null);
      };
    });
  });
}

function writeDraftToIndexedDb(payload) {
  return draftStoreRequest("readwrite", (store) => store.put(payload, LOCAL_DRAFT_RECORD_KEY)).catch(() => null);
}

function readDraftFromIndexedDb() {
  return draftStoreRequest("readonly", (store) => store.get(LOCAL_DRAFT_RECORD_KEY)).catch(() => null);
}

function deleteDraftFromIndexedDb() {
  return draftStoreRequest("readwrite", (store) => store.delete(LOCAL_DRAFT_RECORD_KEY)).catch(() => null);
}

function parseDraft(raw) {
  if (!raw) return null;
  try {
    const draft = JSON.parse(raw);
    return draft && typeof draft === "object" ? draft : null;
  } catch (_error) {
    return null;
  }
}

function draftSavedAt(draft) {
  const time = Date.parse(draft?.savedAt || "");
  return Number.isFinite(time) ? time : 0;
}

function applyRestoredDraft(draft) {
  if (!draft || typeof draft !== "object") return;
  if (draft.account) {
    state.account.loggedIn = Boolean(draft.account.loggedIn);
    state.account.developerMax = Boolean(draft.account.developerMax);
    state.account.username = draft.account.username || "";
    state.account.cdkeyQuotaBalance = Number(draft.account.cdkeyQuotaBalance) || 0;
  }
  if (draft.metadata) {
    els.name.value = draft.metadata.name || "";
    els.author.value = draft.metadata.author || "";
    els.version.value = draft.metadata.version || "1.0.0";
    els.description.value = draft.metadata.description || "";
    state.metadata = { ...draft.metadata };
  }
  if (draft.moodBindings) state.moodBindings = draft.moodBindings;
  if (draft.behaviorBindings) state.behaviorBindings = draft.behaviorBindings;
  if (draft.behaviorTempo) state.behaviorTempo = core.resolveBehaviorTempo({ behaviorTempo: draft.behaviorTempo });
  if (draft.appReactions) state.appReactions = draft.appReactions;
  if (draft.interactionConfig) state.interactionConfig = draft.interactionConfig;
  if (Array.isArray(draft.officialSceneRules)) state.officialSceneRules = sanitizeOfficialSceneRules(draft.officialSceneRules);
  if (draft.sceneAppEventBindings && typeof draft.sceneAppEventBindings === "object") {
    state.sceneAppEventBindings = draft.sceneAppEventBindings;
  }
  if (Array.isArray(draft.processBehaviorRules)) state.processBehaviorRules = sanitizeProcessBehaviorRules(draft.processBehaviorRules);
  state.importedPackId = draft.importedPackId || "";
  state.importedPackSignature = draft.importedPackSignature || null;
  // Seed animation keys synchronously so pruneMissingAnimationReferences does not
  // drop bindings that reference frames whose bytes are still decoding in the
  // background rehydration below. The async pass replaces these stubs with full
  // frame data once each image has loaded.
  if (draft.animations && typeof draft.animations === "object") {
    for (const [key, payload] of Object.entries(draft.animations)) {
      if (!state.animations[key]) {
        state.animations[key] = {
          key,
          required: Boolean(payload && payload.required),
          fps: (payload && payload.fps) || 8,
          loop: !(payload && payload.loop === false),
          kind: payload && (payload.kind === "loop" || payload.type === "loop") ? "loop" : undefined,
          loopDurationSeconds: payload?.loopDurationSeconds ?? null,
          assetType: payload?.assetType || (payload?.video ? "webm" : "png_frames"),
          frames: [],
          sheet: null,
          video: payload?.video ? { ...payload.video, missingBytes: !payload.video.hasBytes } : null,
        };
        const anchors = cloneAnimationAnchors(payload?.anchors);
        if (anchors) state.animations[key].anchors = anchors;
        if (state.animations[key].kind === "loop") ensureLoopSegments(state.animations[key]);
      }
    }
  }
  if (typeof draft.selectedAnimationKey === "string" && draft.selectedAnimationKey) {
    state.selectedAnimationKey = draft.selectedAnimationKey;
  }
  pruneMissingAnimationReferences();
  // Image bytes load asynchronously; avoid overwriting the stored payload with
  // the empty synchronous placeholders while decoding is still in flight.
  const restoreToken = ++draftRestoreToken;
  draftAssetRestorePending = true;
  Promise.resolve(rehydrateDraftAssets(draft, restoreToken))
    .catch(() => {})
    .finally(() => {
      if (restoreToken === draftRestoreToken) draftAssetRestorePending = false;
    });
}

function restoreIndexedDbDraft(localDraft) {
  readDraftFromIndexedDb().then((raw) => {
    const indexedDraft = parseDraft(raw);
    if (!indexedDraft) return;
    if (localDraft && draftSavedAt(localDraft) >= draftSavedAt(indexedDraft)) return;
    applyRestoredDraft(indexedDraft);
  }).catch(() => {});
}

function restoreDraft() {
  const draft = parseDraft(readDraftRaw());
  if (draft) applyRestoredDraft(draft);
  restoreIndexedDbDraft(draft);
}

function persistDraft() {
  if (draftAssetRestorePending) return;
  collectMetadata();
  let payload;
  try {
    payload = JSON.stringify({
      metadata: state.metadata,
      animations: serializeAnimations(state.animations),
      draftAnimation: serializeAnimation(state.draftAnimation),
      preview: serializeImageMeta(state.preview),
      selectedAnimationKey: state.selectedAnimationKey,
      expandedAnimations: state.expandedAnimations,
      moodBindings: state.moodBindings,
      behaviorBindings: state.behaviorBindings,
      behaviorTempo: state.behaviorTempo,
      appReactions: state.appReactions,
      interactionConfig: state.interactionConfig,
      officialSceneRules: sanitizeOfficialSceneRules(state.officialSceneRules),
      sceneAppEventBindings: sanitizeSceneAppEventBindings(state.sceneAppEventBindings),
      processBehaviorRules: sanitizeProcessBehaviorRules(state.processBehaviorRules),
      importedPackId: state.importedPackId,
      importedPackSignature: state.importedPackSignature,
      account: state.account,
      savedAt: new Date().toISOString(),
    });
  } catch (_error) {
    return;
  }
  writeDraftToIndexedDb(payload);
  try {
    localStorage.setItem(LOCAL_DRAFT_KEY, payload);
  } catch (_error) {
    // Quota exceeded — likely from large animation frame data. Keep the live session intact.
  }
}

function clearDraft() {
  try {
    localStorage.removeItem(LOCAL_DRAFT_KEY);
  } catch (_error) {
    // Best-effort removal; nothing else to do.
  }
  deleteDraftFromIndexedDb();
}

function resetEditorState() {
  state.metadata = {};
  state.preview = null;
  state.animations = {};
  state.draftAnimation = null;
  state.moodBindings = {};
  state.behaviorBindings = {};
  state.behaviorTempo = null;
  state.rhythmPreview = null;
  state.appReactions = {};
  state.selectedFrames = {};
  state.expandedAnimations = {};
  state.selectedAnimationKey = "";
  state.activeInteractionType = "idle_interaction";
  state.activeJudgementByType = {};
  state.interactionConfig = {};
  state.officialSceneRules = [];
  state.sceneAppEventBindings = {};
  state.processBehaviorRules = [];
  state.previewAnimationKey = "";
  state.previewContext = null;
  state.pendingBehaviorTarget = null;
  state.importedPackId = "";
  state.previewFrameIndex = 0;
  state.previewPlaying = true;
  state.importedPackSignature = null;
  initializeState();
  if (els.name) els.name.value = "";
  if (els.author) els.author.value = "";
  if (els.version) els.version.value = "1.0.0";
  if (els.description) els.description.value = "";
  if (els.previewInput) els.previewInput.value = "";
  if (els.previewDropzone) els.previewDropzone.classList.remove("has-preview");
  if (els.previewThumbCanvas) clearCanvas(els.previewThumbCanvas);
  updateDescriptionCounter();
  renderAllAnimationCards();
  renderBehaviorEditor();
}

function resetClearDraftButton() {
  if (clearDraftConfirmTimer) {
    clearTimeout(clearDraftConfirmTimer);
    clearDraftConfirmTimer = null;
  }
  if (!els.clearDraftBtn) return;
  els.clearDraftBtn.textContent = CLEAR_DRAFT_LABEL;
  els.clearDraftBtn.classList.remove("is-confirming");
}

function handleClearDraftClick() {
  if (clearDraftConfirmTimer) {
    resetClearDraftButton();
    clearDraft();
    resetEditorState();
    refreshReview();
    return;
  }
  if (!els.clearDraftBtn) return;
  els.clearDraftBtn.textContent = CLEAR_DRAFT_CONFIRM_LABEL;
  els.clearDraftBtn.classList.add("is-confirming");
  clearDraftConfirmTimer = setTimeout(() => {
    clearDraftConfirmTimer = null;
    resetClearDraftButton();
  }, CLEAR_DRAFT_CONFIRM_TIMEOUT_MS);
}

function defaultAnimation(key, required, fps) {
  return {
    key,
    required,
    fps,
    loop: true,
    kind: "sequence",
    loopDurationSeconds: 10,
    assetType: "png_frames",
    frames: [],
    sheet: null,
    video: null,
  };
}

function initializeState() {
  state.behaviorTempo = core.resolveBehaviorTempo({ behaviorTempo: state.behaviorTempo });
  for (const group of core.MOOD_GROUPS) state.moodBindings[group] = [];
  for (const group of core.BEHAVIOR_GROUPS) state.behaviorBindings[group] = [];
  for (const appName of core.APP_REACTION_KEYS) state.appReactions[appName] = "";
  state.sceneAppEventBindings = sanitizeSceneAppEventBindings(state.sceneAppEventBindings);
  for (const type of core.INTERACTION_TYPES) {
    state.interactionConfig[type.key] = {
      judgements: Object.fromEntries(type.judgements.map(([key]) => [
        key,
        { weights: {}, cooldowns: {}, distances: {}, movement: {}, mouseInteractions: {} },
      ])),
    };
  }
}

function createAnimationCard(animation, options = {}) {
  const node = els.template.content.firstElementChild.cloneNode(true);
  const title = node.querySelector("h3");
  const desc = node.querySelector(".state-header p");
  const pill = node.querySelector(".state-pill");
  const toggle = node.querySelector(".card-toggle");
  const confirm = node.querySelector(".confirm-card");
  const fps = node.querySelector(".fps-input");
  const reverse = node.querySelector(".reverse-input");
  const bindings = node.querySelector(".mood-bindings");
  const behaviorBindings = node.querySelector(".behavior-bindings");
  const frameInput = node.querySelector(".frame-input");
  const sheetInput = node.querySelector(".sheet-input");
  const body = node.querySelector(".state-body");
  const addSequence = node.querySelector(".add-sequence");

  node.dataset.key = animation.key;
  node.classList.add(animation.required ? "required" : "optional");
  if (options.isMood) node.classList.add("mood");
  if (options.isBehavior) node.classList.add("behavior");
  title.textContent = options.title || labelForAnimation(animation.key);
  desc.textContent = options.description || "上传多张单帧或一张横向 sprite sheet。";
  fps.value = animation.fps;
  updateStatePill(pill, animation);
  applyCardExpansion(node, isAnimationExpanded(animation.key, options.openByDefault));

  toggle.addEventListener("click", () => {
    const expanded = node.classList.contains("collapsed");
    state.expandedAnimations[animation.key] = expanded;
    applyCardExpansion(node, expanded);
  });

  confirm.addEventListener("click", () => advanceToNextAnimation(animation.key));
  addSequence.addEventListener("click", () => frameInput.click());

  reverse.addEventListener("change", () => {
    if (animation.frames.length) {
      animation.frames.reverse();
      state.selectedFrames[animation.key] = null;
      renderFrames(node, animation);
      refreshReview();
    }
  });

  if (options.isMood) {
    behaviorBindings.hidden = true;
    for (const group of core.MOOD_GROUPS) {
      const label = document.createElement("label");
      const input = document.createElement("input");
      input.type = "checkbox";
      input.value = group;
      input.checked = (state.moodBindings[group] || []).includes(animation.key);
      input.addEventListener("change", () => updateMoodBinding(animation.key, group, input.checked));
      label.append(input, moodGroupLabel(group));
      bindings.append(label);
    }
  } else if (options.isBehavior) {
    bindings.hidden = true;
    renderBehaviorBindingChoices(behaviorBindings, animation.key);
  } else {
    bindings.hidden = true;
    behaviorBindings.hidden = true;
  }

  fps.addEventListener("input", () => {
    animation.fps = Number(fps.value);
    refreshReview();
  });

  frameInput.addEventListener("change", async () => {
    const frames = await Promise.all([...frameInput.files].map(readImageFile));
    if (!frames.every((frame) => enforceGuestImageLimit(frame))) return;
    animation.assetType = "png_frames";
    animation.frames.push(...frames);
    animation.sheet = null;
    animation.video = null;
    state.selectedFrames[animation.key] = null;
    renderFrames(node, animation);
    refreshReview();
  });

  sheetInput.addEventListener("change", async () => {
    if (!sheetInput.files[0]) return;
    const sheet = await readImageFile(sheetInput.files[0]);
    if (!enforceGuestImageLimit(sheet, { sheet: true })) return;
    animation.assetType = "sprite_sheet";
    animation.sheet = sheet;
    animation.frames = [];
    animation.video = null;
    state.selectedFrames[animation.key] = null;
    renderFrames(node, animation);
    refreshReview();
  });

  body.addEventListener("click", (event) => {
    const action = event.target.dataset.action;
    if (!action) return;
    moveSelectedFrame(animation, action);
    renderFrames(node, animation);
    refreshReview();
  });

  renderFrames(node, animation);
  return node;
}

function orderedAnimations() {
  return core.orderAnimationEntries(state.animations);
}

function renderAllAnimationCards() {
  renderUnifiedAnimationEditor();
  renderAnimationLibrary();
  renderBehaviorEditor();
  renderAppReactionSelectors();
  renderPreviewAnimationSelect();
}

function selectedAnimation() {
  if (state.draftAnimation) return state.draftAnimation;
  if (!state.animations[state.selectedAnimationKey]) {
    state.selectedAnimationKey = Object.keys(state.animations)[0] || "";
  }
  return state.animations[state.selectedAnimationKey];
}

function interactionTypeLabel(typeKey) {
  return core.INTERACTION_TYPES.find((type) => type.key === typeKey)?.label || typeKey;
}

function judgementLabel(typeKey, judgementKey) {
  const type = core.INTERACTION_TYPES.find((item) => item.key === typeKey);
  return type?.judgements.find(([key]) => key === judgementKey)?.[1] || judgementKey;
}

function behaviorTargetLabel(target = state.pendingBehaviorTarget) {
  if (!target) return "";
  return `${interactionTypeLabel(target.typeKey)}互动 / ${judgementLabel(target.typeKey, target.judgementKey)} / 宠物行为${(target.rowIndex || 0) + 1}`;
}

function animationBindingLocations(key) {
  const locations = [];
  for (const [group, names] of Object.entries(state.moodBindings || {})) {
    if (normalizeList(names).includes(key)) locations.push(`心情 / ${moodGroupLabel(group)}`);
  }
  for (const [group, names] of Object.entries(state.behaviorBindings || {})) {
    if (!normalizeList(names).includes(key)) continue;
    for (const type of core.INTERACTION_TYPES) {
      if (type.key === "application_interaction") continue;
      const match = type.judgements.find(([judgementKey]) => judgementKey === group);
      if (match) locations.push(`${type.label} / ${match[1]}`);
    }
  }
  for (const [appName, names] of Object.entries(state.appReactions || {})) {
    if (normalizeList(names).includes(key)) locations.push(`应用 / ${judgementLabel("application_interaction", appName)}`);
  }
  return [...new Set(locations)];
}

function animationBindingSummary(key) {
  const locations = animationBindingLocations(key);
  if (!locations.length) return "未绑定";
  if (locations.length <= 2) return `已绑定：${locations.join("、")}`;
  return `已绑定：${locations.slice(0, 2).join("、")} 等 ${locations.length} 处`;
}

function renderUnifiedAnimationEditor() {
  const animation = selectedAnimation();
  if (!els.currentAnimationName) return;
  if (!animation) {
    const showLoopPanel = state.activeAnimationUploadMode === "loop";
    els.currentAnimationName.value = "";
    els.currentAnimationFps.value = 8;
    if (els.currentAnimationLoop) els.currentAnimationLoop.checked = false;
    if (els.currentLoopDuration) els.currentLoopDuration.value = 10;
    if (els.currentLoopInfinite) els.currentLoopInfinite.checked = false;
    if (els.currentLoopDuration) els.currentLoopDuration.disabled = !showLoopPanel;
    if (els.currentLoopInfinite) els.currentLoopInfinite.disabled = !showLoopPanel;
    if (els.loopAdvancedOptions) els.loopAdvancedOptions.hidden = !showLoopPanel;
    if (els.loopSegmentInputs) els.loopSegmentInputs.hidden = !showLoopPanel;
    updateAnimationUploadTabs(showLoopPanel);
    if (els.unifiedFrameStrip) {
      els.unifiedFrameStrip.replaceChildren();
      els.unifiedFrameStrip.classList.remove("is-loop");
    }
    if (els.currentFrameSummary) els.currentFrameSummary.textContent = "";
    updateAnimationWorkflowStatus(null);
    renderAnimationAnchorEditor(null);
    return;
  }
  els.currentAnimationName.value = animation.key;
  els.currentAnimationFps.value = animation.fps || 8;
  renderLoopControls(animation);
  syncMainPreviewToAnimation(state.draftAnimation ? DRAFT_PREVIEW_KEY : animation.key, state.draftAnimation ? { kind: "draft" } : null);
  updateAnimationWorkflowStatus(animation);
  renderUnifiedFrames(animation);
  renderAnimationAnchorEditor(animation);
}

function updateAnimationUploadTabs(isLoop) {
  const activeMode = isLoop ? "loop" : "normal";
  state.activeAnimationUploadMode = activeMode;
  els.animationUploadTabs?.forEach((tab) => {
    const active = tab.dataset.animationMode === activeMode;
    tab.classList.toggle("active", active);
    tab.setAttribute("aria-selected", String(active));
    tab.tabIndex = active ? 0 : -1;
  });
  for (const [mode, panel] of Object.entries(els.animationUploadPanels || {})) {
    if (!panel) continue;
    panel.hidden = mode !== activeMode;
  }
}

function renderLoopControls(animation) {
  const isLoop = Boolean(core.isLoopAnimation?.(animation));
  state.activeAnimationUploadMode = isLoop ? "loop" : "normal";
  updateAnimationUploadTabs(isLoop);
  if (els.currentAnimationLoop) {
    els.currentAnimationLoop.checked = isLoop;
    els.currentAnimationLoop.setAttribute("aria-expanded", String(isLoop));
  }
  if (els.loopAdvancedOptions) els.loopAdvancedOptions.hidden = !isLoop;
  if (els.loopSegmentInputs) els.loopSegmentInputs.hidden = !isLoop;
  if (els.loopDurationField) els.loopDurationField.classList.toggle("disabled", !isLoop);
  if (els.currentLoopInfinite) {
    els.currentLoopInfinite.checked = isLoop && (animation.loopDurationSeconds === null || animation.loopDurationSeconds === undefined);
    els.currentLoopInfinite.disabled = !isLoop;
  }
  if (els.currentLoopDuration) {
    const infinite = isLoop && (animation.loopDurationSeconds === null || animation.loopDurationSeconds === undefined);
    els.currentLoopDuration.disabled = !isLoop || infinite;
    els.currentLoopDuration.value = infinite ? "" : String(animation.loopDurationSeconds ?? 10);
  }
}

function setCurrentAnimationLoopEnabled(enabled) {
  const animation = selectedAnimation() || ensureCurrentAnimationDraft({ warn: true });
  if (!animation) return;
  state.activeAnimationUploadMode = enabled ? "loop" : "normal";
  if (enabled) {
    animation.kind = "loop";
    animation.loop = true;
    animation.loopDurationSeconds = animation.loopDurationSeconds ?? 10;
    ensureLoopSegments(animation);
  } else {
    animation.kind = "sequence";
    animation.loopDurationSeconds = 10;
  }
  renderAllAnimationCards();
  refreshReview();
}

function applyActiveAnimationUploadMode(animation) {
  if (!animation) return;
  if (state.activeAnimationUploadMode === "loop") {
    animation.kind = "loop";
    animation.loop = true;
    animation.loopDurationSeconds = els.currentLoopInfinite?.checked
      ? null
      : (Number(els.currentLoopDuration?.value) || animation.loopDurationSeconds || 10);
    ensureLoopSegments(animation);
    return;
  }
  animation.kind = "sequence";
  animation.loopDurationSeconds = 10;
}

function updateCurrentLoopDuration() {
  const animation = selectedAnimation();
  if (!animation || !core.isLoopAnimation?.(animation)) return;
  if (els.currentLoopInfinite?.checked) {
    animation.loopDurationSeconds = null;
  } else {
    const limits = core.LOOP_DURATION_LIMITS || { min: 0, max: 86400 };
    const number = Number(els.currentLoopDuration?.value);
    animation.loopDurationSeconds = Number.isFinite(number)
      ? Math.max(limits.min, Math.min(limits.max, Math.round(number)))
      : 10;
    if (els.currentLoopDuration) els.currentLoopDuration.value = String(animation.loopDurationSeconds);
  }
  renderUnifiedAnimationEditor();
  refreshReview();
}

function setAnimationUploadMode(mode) {
  state.activeAnimationUploadMode = mode === "loop" ? "loop" : "normal";
  const animation = selectedAnimation();
  if (!animation) {
    renderUnifiedAnimationEditor();
    return;
  }
  setCurrentAnimationLoopEnabled(state.activeAnimationUploadMode === "loop");
}

function bindAnimationUploadTabs() {
  const tabs = [...(els.animationUploadTabs || [])];
  tabs.forEach((tab, index) => {
    tab.addEventListener("click", () => setAnimationUploadMode(tab.dataset.animationMode));
    tab.addEventListener("keydown", (event) => {
      if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
      event.preventDefault();
      const step = event.key === "ArrowRight" ? 1 : -1;
      const next = tabs[(index + step + tabs.length) % tabs.length];
      if (!next) return;
      setAnimationUploadMode(next.dataset.animationMode);
      next.focus?.();
    });
  });
}

function updateAnimationWorkflowStatus(animation) {
  if (!els.animationWorkflowStatus) return;
  const targetText = behaviorTargetLabel();
  if (state.pendingBehaviorTarget && state.draftAnimation) {
    els.animationWorkflowStatus.textContent = `正在为：${targetText} 新建动画，保存后会自动绑定到这个位置。`;
    return;
  }
  if (state.draftAnimation) {
    els.animationWorkflowStatus.textContent = `当前编辑：未保存动画草稿 ${animation?.key || "未命名"}。此动画保存前不会出现在行为编辑中。`;
    return;
  }
  if (animation?.key && state.animations[animation.key]) {
    els.animationWorkflowStatus.textContent = `当前编辑：已保存动画 ${animation.key}。可在行为编辑中绑定。`;
    return;
  }
  els.animationWorkflowStatus.textContent = "新建动画时，请先输入名称并上传素材。";
}

function syncMainPreviewToAnimation(key, context = null) {
  if (!key) return;
  if (context) state.previewContext = context;
  if (state.previewAnimationKey === key) {
    state.previewFrameIndex = 0;
    return;
  }
  state.previewAnimationKey = key;
  state.previewFrameIndex = 0;
  if (els.previewAnimationSelect) els.previewAnimationSelect.value = key;
}

function syncBehaviorPreview(typeKey, judgementKey, animationKey, rowIndex = 0) {
  syncMainPreviewToAnimation(animationKey, {
    kind: "behavior",
    typeKey,
    judgementKey,
    animationKey,
    rowIndex,
  });
  if (els.previewContext) els.previewContext.textContent = currentPreviewContextText();
}

function selectedFrameBucket(animation, segment = "") {
  return segment ? `${animation.key}:${segment}` : animation.key;
}

function videoSummary(video) {
  if (!video) return "WebM";
  const seconds = Number(video.durationSeconds) || 0;
  const duration = seconds ? `${seconds.toFixed(seconds >= 10 ? 1 : 2)} 秒` : "未读取时长";
  return `WebM ${video.width || 0} x ${video.height || 0}, ${duration}`;
}

function appendVideoThumb(container, label, video) {
  const tile = document.createElement("span");
  tile.className = "frame-thumb selected webm-thumb";
  tile.textContent = "WebM";
  tile.title = `${label}: ${videoSummary(video)}`;
  container.append(tile);
}

function renderLoopSegmentFrames(container, animation, segmentName, segment) {
  const section = document.createElement("div");
  section.className = "loop-frame-section";
  const head = document.createElement("div");
  head.className = "loop-frame-section-head";
  const title = document.createElement("strong");
  title.textContent = core.LOOP_SEGMENT_LABELS?.[segmentName] || segmentName;
  const count = document.createElement("span");
  count.textContent = `${core.animationSegmentFrameCount?.(segment) || animationFrameCount(segment)} 帧`;
  head.append(title, count);
  const track = document.createElement("div");
  track.className = "loop-frame-track";
  section.append(head, track);
  const bucket = selectedFrameBucket(animation, segmentName);
  if (core.isVideoAnimationAsset?.(segment)) {
    appendVideoThumb(track, `${animation.key} ${segmentName}`, segment.video);
  } else if (segment.sheet) {
    const img = document.createElement("img");
    img.className = "frame-thumb selected";
    img.src = segment.sheet.url;
    img.alt = `${animation.key} ${segmentName} sprite sheet`;
    track.append(img);
  } else {
    (segment.frames || []).forEach((frame, index) => {
      const img = document.createElement("img");
      img.className = "frame-thumb";
      if (state.selectedFrames[bucket] === index) img.classList.add("selected");
      img.src = frame.url;
      img.alt = `${animation.key} ${segmentName} frame ${index + 1}`;
      img.title = `${index + 1}. ${frame.name}`;
      img.addEventListener("click", () => {
        state.selectedFrames[bucket] = index;
        state.selectedFrames[animation.key] = { segment: segmentName, index };
        renderUnifiedFrames(animation);
        renderAnimationAnchorEditor(animation);
      });
      track.append(img);
    });
  }
  container.append(section);
}

function renderUnifiedFrames(animation) {
  if (!els.unifiedFrameStrip) return;
  els.unifiedFrameStrip.replaceChildren();
  const isLoop = Boolean(core.isLoopAnimation?.(animation));
  els.unifiedFrameStrip.classList.toggle("is-loop", isLoop);
  if (isLoop) {
    ensureLoopSegments(animation);
    for (const segmentName of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
      renderLoopSegmentFrames(
        els.unifiedFrameStrip,
        animation,
        segmentName,
        animation.segments[segmentName],
      );
    }
    const parts = (core.LOOP_SEGMENTS || ["intro", "loop", "outro"]).map((segmentName) => {
      const segment = animation.segments[segmentName];
      const count = core.animationSegmentFrameCount?.(segment) || animationFrameCount(segment);
      return `${core.LOOP_SEGMENT_LABELS?.[segmentName] || segmentName} ${count} 帧`;
    });
    els.currentFrameSummary.textContent = parts.join(" / ");
    return;
  }
  if (core.isVideoAnimationAsset?.(animation)) {
    appendVideoThumb(els.unifiedFrameStrip, animation.key, animation.video);
    els.currentFrameSummary.textContent = videoSummary(animation.video);
    return;
  }
  if (animation.sheet) {
    const img = document.createElement("img");
    img.className = "frame-thumb selected";
    img.src = animation.sheet.url;
    img.alt = `${animation.key} sprite sheet`;
    els.unifiedFrameStrip.append(img);
    const sheet = core.inferSheet(animation.sheet);
    els.currentFrameSummary.textContent = sheet.errors.length
      ? sheet.errors.join(" ")
      : `sheet ${animation.sheet.width} x ${animation.sheet.height}, ${sheet.frames} 帧`;
    return;
  }
  animation.frames.forEach((frame, index) => {
    const img = document.createElement("img");
    img.className = "frame-thumb";
    if (state.selectedFrames[animation.key] === index) img.classList.add("selected");
    img.src = frame.url;
    img.alt = `${animation.key} 第 ${index + 1} 帧`;
    img.title = `${index + 1}. ${frame.name}`;
    img.addEventListener("click", () => {
      state.selectedFrames[animation.key] = index;
      renderUnifiedFrames(animation);
      renderAnimationAnchorEditor(animation);
    });
    els.unifiedFrameStrip.append(img);
  });
  els.currentFrameSummary.textContent = animation.frames.length
    ? `${animation.frames.length} 帧，${frameDimensionText(animation)}`
    : "等待上传素材";
}

function anchorPointKeys() {
  return core.ANIMATION_ANCHOR_POINT_KEYS || ["top", "bottom", "left", "right", "scale"];
}

function clampAnchorValue(value, max) {
  const upper = Math.max(0, Math.round(Number(max) || 0));
  const number = Math.round(Number(value));
  if (!Number.isFinite(number)) return 0;
  return Math.max(0, Math.min(upper, number));
}

function cloneAnimationAnchors(anchors) {
  if (!anchors || typeof anchors !== "object") return null;
  const points = {};
  for (const key of anchorPointKeys()) {
    const point = anchors.points?.[key];
    if (!point || typeof point !== "object") return null;
    const x = Math.round(Number(point.x));
    const y = Math.round(Number(point.y));
    if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
    points[key] = { x, y };
  }
  const cloned = { points };
  const inset = Math.round(Number(anchors.source_ground_bottom_inset));
  if (Number.isFinite(inset) && inset >= 0) cloned.source_ground_bottom_inset = inset;
  return cloned;
}

function animationAnchorDimensions(animation) {
  if (!animation) return { width: 0, height: 0 };
  if (core.isLoopAnimation?.(animation)) {
    const loop = ensureLoopSegments(animation).loop;
    if (core.isVideoAnimationAsset?.(loop)) {
      return { width: loop.video?.width || 0, height: loop.video?.height || 0 };
    }
    if (loop.frames?.length) {
      const size = core.inferFrameSize(loop.frames);
      return { width: size.frameWidth || 0, height: size.frameHeight || 0 };
    }
    if (loop.sheet) {
      const sheet = core.inferSheet(loop.sheet);
      if (!sheet.errors.length) return { width: sheet.frameWidth || 0, height: sheet.frameHeight || 0 };
    }
  }
  if (core.isVideoAnimationAsset?.(animation)) {
    return { width: animation.video?.width || 0, height: animation.video?.height || 0 };
  }
  if (animation.frames?.length) {
    const size = core.inferFrameSize(animation.frames);
    return { width: size.frameWidth || 0, height: size.frameHeight || 0 };
  }
  if (animation.sheet) {
    const sheet = core.inferSheet(animation.sheet);
    if (!sheet.errors.length) return { width: sheet.frameWidth || 0, height: sheet.frameHeight || 0 };
  }
  return { width: 0, height: 0 };
}

function normalizeEditorAnchors(anchors, dimensions) {
  if (!anchors || typeof anchors !== "object") return null;
  const points = {};
  for (const key of anchorPointKeys()) {
    const point = anchors.points?.[key];
    if (!point || typeof point !== "object") return null;
    points[key] = {
      x: clampAnchorValue(point.x, dimensions.width),
      y: clampAnchorValue(point.y, dimensions.height),
    };
  }
  return {
    points,
    source_ground_bottom_inset: Math.max(0, Math.round(Number(anchors.source_ground_bottom_inset) || 0)),
  };
}

function estimateAnimationAnchors(animation) {
  const sampled = sampleAnimationAlphaBoxes(animation);
  if (!sampled || !sampled.boxes.length) return null;
  const left = Math.min(...sampled.boxes.map((box) => box.x));
  const top = Math.min(...sampled.boxes.map((box) => box.y));
  const right = Math.max(...sampled.boxes.map((box) => box.x + box.w));
  const bottom = Math.max(...sampled.boxes.map((box) => box.y + box.h));
  const width = sampled.canvasWidth || right;
  const height = sampled.canvasHeight || bottom;
  const centerX = Math.round((left + right) / 2);
  const centerY = Math.round((top + bottom) / 2);
  return normalizeEditorAnchors({
    points: {
      top: { x: centerX, y: top },
      bottom: { x: centerX, y: bottom },
      left: { x: left, y: centerY },
      right: { x: right, y: centerY },
      scale: { x: right, y: bottom },
    },
    source_ground_bottom_inset: Math.max(0, Math.round(height - bottom)),
  }, { width, height });
}

function defaultAnimationAnchors(animation) {
  const dimensions = animationAnchorDimensions(animation);
  const width = dimensions.width || 0;
  const height = dimensions.height || 0;
  return {
    points: {
      top: { x: Math.round(width / 2), y: 0 },
      bottom: { x: Math.round(width / 2), y: height },
      left: { x: 0, y: Math.round(height / 2) },
      right: { x: width, y: Math.round(height / 2) },
      scale: { x: width, y: height },
    },
    source_ground_bottom_inset: 0,
  };
}

function ensureAnimationAnchors(animation) {
  if (!animation) return null;
  const dimensions = animationAnchorDimensions(animation);
  const current = normalizeEditorAnchors(animation.anchors, dimensions);
  if (current) {
    animation.anchors = current;
    return current;
  }
  const estimated = estimateAnimationAnchors(animation);
  animation.anchors = estimated || defaultAnimationAnchors(animation);
  return animation.anchors;
}

function applyEstimatedAnchors(animation) {
  const estimated = estimateAnimationAnchors(animation);
  if (!estimated) return false;
  animation.anchors = estimated;
  return true;
}

function clearAnimationAnchors(animation) {
  if (!animation) return;
  delete animation.anchors;
}

function anchorPointFromPointer(rect, dimensions, clientX, clientY) {
  const sourceWidth = Math.max(1, Math.round(Number(dimensions?.width) || 1));
  const sourceHeight = Math.max(1, Math.round(Number(dimensions?.height) || 1));
  const previewWidth = Math.max(1, Number(rect?.width) || 1);
  const previewHeight = Math.max(1, Number(rect?.height) || 1);
  const content = containedRect(sourceWidth, sourceHeight, previewWidth, previewHeight);
  const left = Number(rect?.left) || 0;
  const top = Number(rect?.top) || 0;
  return {
    x: clampAnchorValue(((Number(clientX) - left - content.left) / content.width) * sourceWidth, sourceWidth),
    y: clampAnchorValue(((Number(clientY) - top - content.top) / content.height) * sourceHeight, sourceHeight),
  };
}

function setAnimationAnchorPoint(animation, anchors, key, point, dimensions) {
  if (!animation || !anchors?.points?.[key]) return;
  anchors.points[key] = {
    x: clampAnchorValue(point?.x, dimensions.width),
    y: clampAnchorValue(point?.y, dimensions.height),
  };
  if (anchors.points.bottom) {
    anchors.source_ground_bottom_inset = Math.max(
      0,
      Math.round((dimensions.height || anchors.points.bottom.y) - anchors.points.bottom.y),
    );
  }
  animation.anchors = anchors;
}

function positionAnchorMarker(marker, point, dimensions) {
  if (!marker || !point || !dimensions.width || !dimensions.height) return;
  const stage = els.previewAnchorStage;
  const rect = stage?.getBoundingClientRect?.() || { width: 0, height: 0 };
  const previewWidth = rect.width || Number(els.previewCanvas?.dataset?.cssWidth) || els.previewCanvas?.width || 1;
  const previewHeight = rect.height || Number(els.previewCanvas?.dataset?.cssHeight) || els.previewCanvas?.height || 1;
  const content = containedRect(dimensions.width, dimensions.height, previewWidth, previewHeight);
  const left = content.left + (point.x / dimensions.width) * content.width;
  const top = content.top + (point.y / dimensions.height) * content.height;
  if (marker.style) {
    marker.style.left = `${Math.round(left * 100) / 100}px`;
    marker.style.top = `${Math.round(top * 100) / 100}px`;
  }
}

function bindAnchorMarkerDrag(marker, stage, animation, anchors, key, dimensions, onChange, onCommit) {
  if (!marker || !stage || !dimensions.width || !dimensions.height) return;
  const moveTo = (event) => {
    const rect = stage.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
    const point = anchorPointFromPointer(rect, dimensions, event.clientX, event.clientY);
    setAnimationAnchorPoint(animation, anchors, key, point, dimensions);
    onChange?.(key);
    event.preventDefault?.();
  };

  marker.addEventListener("pointerdown", (event) => {
    marker.focus?.();
    marker.classList?.add("is-dragging");
    marker.setPointerCapture?.(event.pointerId);
    moveTo(event);
    const onPointerMove = (moveEvent) => moveTo(moveEvent);
    const stop = (upEvent) => {
      marker.classList?.remove("is-dragging");
      marker.releasePointerCapture?.(upEvent.pointerId);
      document.removeEventListener?.("pointermove", onPointerMove);
      document.removeEventListener?.("pointerup", stop);
      document.removeEventListener?.("pointercancel", stop);
      onCommit?.(key);
    };
    document.addEventListener?.("pointermove", onPointerMove);
    document.addEventListener?.("pointerup", stop);
    document.addEventListener?.("pointercancel", stop);
  });

  marker.addEventListener("keydown", (event) => {
    const deltas = {
      ArrowUp: [0, -1],
      ArrowDown: [0, 1],
      ArrowLeft: [-1, 0],
      ArrowRight: [1, 0],
    };
    const delta = deltas[event.key];
    if (!delta) return;
    const step = event.shiftKey ? 10 : 1;
    const point = anchors.points[key];
    setAnimationAnchorPoint(animation, anchors, key, {
      x: point.x + delta[0] * step,
      y: point.y + delta[1] * step,
    }, dimensions);
    onChange?.(key);
    onCommit?.(key);
    event.preventDefault?.();
  });
}

function syncAnimationAnchorReadouts(anchors, dimensions, activeKey = "") {
  if (!els.animationAnchorEditor || !anchors?.points) return;
  for (const key of anchorPointKeys()) {
    const point = anchors.points[key];
    const row = els.animationAnchorEditor.querySelector?.(`.animation-anchor-row.anchor-${key}`);
    const readout = row?.querySelector?.(".animation-anchor-value");
    if (readout && point) readout.textContent = `${point.x}, ${point.y}`;
    row?.classList?.toggle("is-active", key === activeKey);
  }
  const meta = els.animationAnchorEditor.querySelector?.(".animation-anchor-head span");
  if (meta) {
    meta.textContent = dimensions.width && dimensions.height
      ? `${dimensions.width} x ${dimensions.height}`
      : "待上传素材";
  }
}

function previewAnchorTargetAnimation() {
  const animation = selectedAnimation();
  if (!animation || !animationFrameCount(animation)) return null;
  return previewAnimation() === animation ? animation : null;
}

function syncPreviewAnchorOverlayPositions(animation, anchors, dimensions, activeKey = "") {
  if (!els.previewAnchorMarkerLayer || !anchors?.points) return;
  for (const key of anchorPointKeys()) {
    const marker = els.previewAnchorMarkerLayer.querySelector?.(`[data-anchor-key="${key}"]`);
    const point = anchors.points[key];
    if (!marker || !point) continue;
    positionAnchorMarker(marker, point, dimensions);
    marker.classList?.toggle("is-active", key === activeKey);
    marker.title = `${ANIMATION_ANCHOR_LABELS[key] || key} (${point.x}, ${point.y})`;
    marker.setAttribute("aria-label", `${ANIMATION_ANCHOR_LABELS[key] || key} (${point.x}, ${point.y})`);
  }
}

function renderPreviewAnchorOverlay() {
  const stage = els.previewAnchorStage;
  const layer = els.previewAnchorMarkerLayer;
  if (!stage || !layer) return;
  layer.replaceChildren();
  const animation = previewAnchorTargetAnimation();
  if (!animation) {
    layer.hidden = true;
    stage.classList.remove("has-anchor-markers");
    return;
  }
  const dimensions = animationAnchorDimensions(animation);
  if (!dimensions.width || !dimensions.height) {
    layer.hidden = true;
    stage.classList.remove("has-anchor-markers");
    return;
  }
  const anchors = ensureAnimationAnchors(animation);
  layer.hidden = false;
  stage.classList.add("has-anchor-markers");

  const sync = (activeKey = "") => {
    syncPreviewAnchorOverlayPositions(animation, anchors, dimensions, activeKey);
    syncAnimationAnchorReadouts(anchors, dimensions, activeKey);
  };
  const commit = () => {
    renderAnimationLibrary();
    refreshReview();
  };

  for (const key of anchorPointKeys()) {
    const marker = document.createElement("button");
    marker.type = "button";
    marker.className = `animation-anchor-marker anchor-${key}`;
    marker.setAttribute("data-anchor-key", key);
    marker.style?.setProperty("--anchor-color", ANIMATION_ANCHOR_COLORS[key] || "#151a27");
    bindAnchorMarkerDrag(marker, stage, animation, anchors, key, dimensions, sync, commit);
    layer.append(marker);
  }
  sync();
}

function renderAnimationAnchorEditor(animation) {
  if (!els.animationAnchorEditor) return;
  els.animationAnchorEditor.replaceChildren();
  if (!animation) {
    els.animationAnchorEditor.hidden = true;
    renderPreviewAnchorOverlay();
    return;
  }
  els.animationAnchorEditor.hidden = false;
  const dimensions = animationAnchorDimensions(animation);
  const anchors = ensureAnimationAnchors(animation);
  const canEstimate = Boolean(estimateAnimationAnchors(animation));

  const head = document.createElement("div");
  head.className = "animation-anchor-head";
  const title = document.createElement("strong");
  title.textContent = "动画五点打点";
  const meta = document.createElement("span");
  meta.textContent = dimensions.width && dimensions.height
    ? `${dimensions.width} x ${dimensions.height}`
    : "待上传素材";
  head.append(title, meta);

  const grid = document.createElement("div");
  grid.className = "animation-anchor-grid";
  for (const key of anchorPointKeys()) {
    const row = document.createElement("div");
    row.className = `animation-anchor-row anchor-${key}`;
    const label = document.createElement("span");
    label.className = "animation-anchor-label";
    label.textContent = ANIMATION_ANCHOR_LABELS[key] || key;
    const value = document.createElement("span");
    value.className = "animation-anchor-value";
    row.append(label, value);
    row.addEventListener("click", () => {
      const marker = els.previewAnchorMarkerLayer?.querySelector?.(`[data-anchor-key="${key}"]`);
      marker?.focus?.();
    });
    grid.append(row);
  }
  syncAnimationAnchorReadouts(anchors, dimensions);

  const actions = document.createElement("div");
  actions.className = "animation-anchor-actions";
  const auto = document.createElement("button");
  auto.type = "button";
  auto.className = "button secondary-button";
  auto.textContent = "自动估算";
  auto.disabled = !canEstimate;
  auto.addEventListener("click", () => {
    if (!applyEstimatedAnchors(animation)) return;
    renderAnimationAnchorEditor(animation);
    renderAnimationLibrary();
    refreshReview();
  });
  const reset = document.createElement("button");
  reset.type = "button";
  reset.className = "button secondary-button";
  reset.textContent = "重置默认";
  reset.addEventListener("click", () => {
    clearAnimationAnchors(animation);
    renderAnimationAnchorEditor(animation);
    renderAnimationLibrary();
    refreshReview();
  });
  actions.append(auto, reset);
  els.animationAnchorEditor.append(head, grid, actions);
  renderPreviewAnchorOverlay();
}

function renderAnimationLibrary() {
  if (!els.animationList) return;
  els.animationList.replaceChildren();
  if (!orderedAnimations().length) {
    const empty = document.createElement("div");
    empty.className = "empty-state";
    empty.textContent = "暂无动画。";
    els.animationList.append(empty);
    return;
  }
  orderedAnimations().forEach(({ animation, type }) => {
    const card = document.createElement("div");
    card.className = "library-card";
    if (animation.key === state.selectedAnimationKey) card.classList.add("active");
    const count = animationFrameCount(animation);
    card.classList.toggle("empty", !count);
    const meta = animation.frames?.length ? frameDimensionText(animation) : (animation.sheet ? "sprite sheet" : "未上传");

    const selectButton = document.createElement("button");
    selectButton.type = "button";
    selectButton.className = "library-select";
    selectButton.setAttribute("aria-label", `编辑动画 ${animation.key}`);

    const thumb = document.createElement("span");
    thumb.className = "library-thumb";
    const canvas = document.createElement("canvas");
    canvas.width = 72;
    canvas.height = 72;
    canvas.className = "library-thumb-canvas";
    thumb.append(canvas);
    if (!count) {
      const emptyLabel = document.createElement("span");
      emptyLabel.className = "library-empty-label";
      emptyLabel.textContent = "空素材";
      thumb.append(emptyLabel);
    }
    renderLibraryThumbnail(canvas, animation);
    const anchorHint = count ? buildAnchorHint(animation) : null;
    if (count) drawAnchorGuide(canvas, animation, anchorHint?.measured?.analysis || null);

    const name = document.createElement("strong");
    name.textContent = animation.key;
    const metaText = document.createElement("span");
    metaText.className = "library-meta";
    metaText.textContent = type === "body" ? meta : `${count ? `${count} 帧` : "未上传"} · 素材动画`;
    const fpsText = document.createElement("span");
    fpsText.className = "library-fps";
    fpsText.textContent = `${animation.fps || 8} FPS`;
    const bindingText = document.createElement("span");
    bindingText.className = "library-binding";
    bindingText.textContent = animationBindingSummary(animation.key);
    selectButton.append(thumb, name, metaText, fpsText, bindingText);
    if (cloneAnimationAnchors(animation.anchors)) {
      const anchorChip = document.createElement("span");
      anchorChip.className = "library-anchor-saved";
      anchorChip.textContent = "五点已设";
      selectButton.append(anchorChip);
    }
    if (anchorHint && anchorHint.chip) selectButton.append(anchorHint.chip);

    const deleteButton = document.createElement("button");
    deleteButton.type = "button";
    deleteButton.className = "library-delete";
    deleteButton.dataset.deleteAnimation = animation.key;
    deleteButton.textContent = "×";
    deleteButton.title = `删除动画 ${animation.key}`;
    deleteButton.setAttribute("aria-label", `删除动画 ${animation.key}`);
    deleteButton.addEventListener("click", (event) => {
      event.stopPropagation();
      deleteAnimation(animation.key);
    });

    selectButton.addEventListener("click", () => {
      state.selectedAnimationKey = animation.key;
      state.selectedFrames[animation.key] = null;
      syncMainPreviewToAnimation(animation.key, { kind: "library", animationKey: animation.key });
      renderAllAnimationCards();
      refreshReview();
    });
    card.append(selectButton, deleteButton);
    els.animationList.append(card);
  });
}

function renderLibraryThumbnail(canvas, animation) {
  if (!canvas || !animationFrameCount(animation)) {
    if (canvas) clearCanvas(canvas);
    return;
  }
  drawAnimationFrame(canvas, animation, 0);
}

function deleteAnimation(key) {
  if (!state.animations[key]) return;
  delete state.animations[key];
  delete state.selectedFrames[key];
  delete state.expandedAnimations[key];
  removeAnimationReferences(key);

  const nextKey = Object.keys(state.animations)[0] || "";
  if (state.selectedAnimationKey === key) state.selectedAnimationKey = nextKey;
  if (state.previewAnimationKey === key) {
    state.previewAnimationKey = state.selectedAnimationKey || nextKey;
    state.previewFrameIndex = 0;
    state.previewContext = state.previewAnimationKey
      ? { kind: "library", animationKey: state.previewAnimationKey }
      : null;
  }
  renderAllAnimationCards();
  refreshReview();
}

function removeAnimationReferences(key) {
  for (const bindings of [state.moodBindings, state.behaviorBindings]) {
    for (const [group, names] of Object.entries(bindings)) {
      bindings[group] = normalizeList(names).filter((name) => name !== key);
    }
  }
  for (const [appName, value] of Object.entries(state.appReactions)) {
    const names = normalizeList(value).filter((name) => name !== key);
    state.appReactions[appName] = Array.isArray(value) ? names : (names[0] || "");
  }
  state.sceneAppEventBindings = sanitizeSceneAppEventBindings(state.sceneAppEventBindings);
  for (const eventMap of Object.values(state.sceneAppEventBindings)) {
    for (const eventKey of Object.keys(eventMap)) {
      if (eventMap[eventKey] === key) eventMap[eventKey] = "";
    }
  }
  for (const typeConfig of Object.values(state.interactionConfig || {})) {
    for (const judgement of Object.values(typeConfig.judgements || {})) {
      if (judgement.weights) delete judgement.weights[key];
      if (judgement.cooldowns) delete judgement.cooldowns[key];
      if (judgement.distances) delete judgement.distances[key];
      if (judgement.leaveWake) delete judgement.leaveWake[key];
      if (judgement.movement) delete judgement.movement[key];
      if (judgement.mouseInteractions) delete judgement.mouseInteractions[key];
    }
  }
  state.processBehaviorRules = state.processBehaviorRules.filter((rule) => rule.animation !== key);
}

function pruneMissingAnimationReferences() {
  const validKeys = new Set(Object.keys(state.animations || {}));
  for (const bindings of [state.moodBindings, state.behaviorBindings]) {
    for (const [group, names] of Object.entries(bindings)) {
      bindings[group] = normalizeList(names).filter((name) => validKeys.has(name));
    }
  }
  for (const [appName, value] of Object.entries(state.appReactions)) {
    const names = normalizeList(value).filter((name) => validKeys.has(name));
    state.appReactions[appName] = Array.isArray(value) ? names : (names[0] || "");
  }
  state.sceneAppEventBindings = sanitizeSceneAppEventBindings(state.sceneAppEventBindings);
  for (const eventMap of Object.values(state.sceneAppEventBindings)) {
    for (const [eventKey, animationKey] of Object.entries(eventMap)) {
      if (animationKey && !validKeys.has(animationKey)) eventMap[eventKey] = "";
    }
  }
  for (const typeConfig of Object.values(state.interactionConfig || {})) {
    for (const judgement of Object.values(typeConfig.judgements || {})) {
      if (judgement.weights) {
        for (const key of Object.keys(judgement.weights)) {
          if (!validKeys.has(key)) delete judgement.weights[key];
        }
      }
      if (judgement.cooldowns) {
        for (const key of Object.keys(judgement.cooldowns)) {
          if (!validKeys.has(key)) delete judgement.cooldowns[key];
        }
      }
      if (judgement.distances) {
        for (const key of Object.keys(judgement.distances)) {
          if (!validKeys.has(key)) delete judgement.distances[key];
        }
      }
      if (judgement.leaveWake) {
        for (const key of Object.keys(judgement.leaveWake)) {
          if (!validKeys.has(key)) delete judgement.leaveWake[key];
        }
      }
      if (judgement.movement) {
        for (const key of Object.keys(judgement.movement)) {
          if (!validKeys.has(key)) delete judgement.movement[key];
        }
      }
      if (judgement.mouseInteractions) {
        for (const key of Object.keys(judgement.mouseInteractions)) {
          if (!validKeys.has(key)) delete judgement.mouseInteractions[key];
        }
      }
    }
  }
  state.processBehaviorRules = sanitizeProcessBehaviorRules(state.processBehaviorRules)
    .filter((rule) => validKeys.has(rule.animation));
}

function labelForAnimation(key) {
  if (BODY_LABELS[key]) return BODY_LABELS[key];
  if (key.startsWith("mood_")) return `Mood（${key.replace("mood_", "")}）`;
  if (key.startsWith("micro_")) return `Micro（${key.replace("micro_", "")}）`;
  if (key.startsWith("rare_")) return `Rare（${key.replace("rare_", "")}）`;
  if (key.startsWith("app_")) return `App（${key.replace("app_", "")}）`;
  return key;
}

function descriptionForAnimation(key, type) {
  if (BODY_DESCRIPTIONS[key]) return BODY_DESCRIPTIONS[key];
  if (type === "mood") return "可绑定到点击、拖拽、待机和说话触发。";
  if (type === "behavior") return "可绑定到待机、鼠标；窗口边缘和桌面边缘在待机互动里配置，应用在下方应用面板绑定。";
  return "上传多张单帧或一张横向 sprite sheet。";
}

function updateStatePill(pill, animation) {
  const count = animationFrameCount(animation);
  pill.textContent = count ? `${count} 帧` : (animation.required ? "必传" : "选填");
}

function isAnimationExpanded(key, openByDefault = false) {
  return Object.prototype.hasOwnProperty.call(state.expandedAnimations, key)
    ? state.expandedAnimations[key]
    : openByDefault;
}

function applyCardExpansion(card, expanded) {
  const toggle = card.querySelector(".card-toggle");
  card.classList.toggle("collapsed", !expanded);
  card.classList.toggle("expanded", expanded);
  toggle.textContent = expanded ? "收起" : "展开";
  toggle.setAttribute("aria-expanded", String(expanded));
}

function advanceToNextAnimation(key) {
  const cards = [...document.querySelectorAll(".state-card")];
  const index = cards.findIndex((card) => card.dataset.key === key);
  if (index < 0) return;

  state.expandedAnimations[key] = false;
  applyCardExpansion(cards[index], false);

  const next = cards[index + 1];
  if (!next) return;
  state.expandedAnimations[next.dataset.key] = true;
  applyCardExpansion(next, true);
  next.scrollIntoView({ behavior: "smooth", block: "center" });
}

function renderAppReactionSelectors() {
  const behaviorNames = orderedAnimations().map(({ animation }) => animation.key);
  for (const [appName, select] of Object.entries(els.appReactionSelects)) {
    if (!select) continue;
    const current = state.appReactions[appName] || "";
    select.replaceChildren();
    const none = document.createElement("option");
    none.value = "";
    none.textContent = "不绑定";
    select.append(none);
    for (const name of behaviorNames) {
      const option = document.createElement("option");
      option.value = name;
      option.textContent = labelForAnimation(name);
      select.append(option);
    }
    select.value = behaviorNames.includes(current) ? current : "";
  }
}

function availableAnimationKeys() {
  return orderedAnimations()
    .map(({ animation }) => animation.key)
    .filter((key) => animationFrameCount(state.animations[key]) || state.animations[key]);
}

function ensureInteractionConfig(typeKey, judgementKey) {
  // Judgement buckets only group behavior rows now; probability and cooldown
  // belong to each behavior animation.
  if (!state.interactionConfig[typeKey]) {
    state.interactionConfig[typeKey] = { judgements: {} };
  }
  if (!state.interactionConfig[typeKey].judgements) {
    state.interactionConfig[typeKey].judgements = {};
  }
  if (judgementKey && !state.interactionConfig[typeKey].judgements[judgementKey]) {
    state.interactionConfig[typeKey].judgements[judgementKey] = {
      weights: {},
      cooldowns: {},
      distances: {},
      movement: {},
    };
  }
  if (judgementKey) {
    const judgement = state.interactionConfig[typeKey].judgements[judgementKey];
    if (judgement) {
      judgement.weights = judgement.weights || {};
      judgement.cooldowns = judgement.cooldowns || {};
      judgement.distances = judgement.distances || {};
      judgement.movement = judgement.movement || {};
      judgement.mouseInteractions = judgement.mouseInteractions || {};
      if (typeof judgement.cooldown_seconds === "number") {
        for (const animationKey of selectedAnimationsForJudgement(typeKey, judgementKey)) {
          if (judgement.cooldowns[animationKey] === undefined) {
            judgement.cooldowns[animationKey] = judgement.cooldown_seconds;
          }
        }
      }
      delete judgement.chance;
      delete judgement.cooldown_seconds;
    }
  }
  return judgementKey
    ? state.interactionConfig[typeKey].judgements[judgementKey]
    : state.interactionConfig[typeKey];
}

function captureBehaviorEditorScroll() {
  const judgementList = behaviorEditorScrollTarget();
  return {
    interactionType: state.activeInteractionType,
    judgementTop: judgementList?.scrollTop || 0,
    judgementLeft: judgementList?.scrollLeft || 0,
    windowTop: Number.isFinite(window?.pageYOffset) ? window.pageYOffset : null,
    windowLeft: Number.isFinite(window?.pageXOffset) ? window.pageXOffset : null,
  };
}

function restoreBehaviorEditorScroll(snapshot) {
  if (!snapshot || snapshot.interactionType !== state.activeInteractionType) return;
  const judgementList = behaviorEditorScrollTarget();
  if (judgementList) {
    judgementList.scrollTop = snapshot.judgementTop || 0;
    judgementList.scrollLeft = snapshot.judgementLeft || 0;
  }
  if (snapshot.windowTop !== null && typeof window?.scrollTo === "function") {
    window.scrollTo(snapshot.windowLeft || 0, snapshot.windowTop);
  }
}

function behaviorEditorScrollTarget() {
  const query = els.behaviorEditor?.querySelector?.bind?.(els.behaviorEditor);
  if (!query) return null;
  return query(".active-judgement-panel") || query(".judgement-config-list");
}

function activeJudgementForType(type) {
  state.activeJudgementByType = state.activeJudgementByType || {};
  const available = type.judgements.map(([key]) => key);
  let activeKey = state.activeJudgementByType[type.key];
  if (!available.includes(activeKey)) {
    activeKey = available[0] || "";
    state.activeJudgementByType[type.key] = activeKey;
  }
  if (activeKey) ensureInteractionConfig(type.key, activeKey);
  return activeKey;
}

function renderJudgementTabs(activeType, activeJudgementKey) {
  const tabs = document.createElement("nav");
  tabs.className = "judgement-tab-strip";
  tabs.setAttribute("aria-label", `${activeType.label}互动判定种类`);
  activeType.judgements.forEach(([judgementKey, labelText]) => {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "judgement-tab";
    if (judgementKey === activeJudgementKey) button.classList.add("active");
    button.setAttribute("aria-pressed", String(judgementKey === activeJudgementKey));

    const title = document.createElement("strong");
    title.textContent = labelText;
    const count = document.createElement("span");
    const selectedCount = selectedAnimationsForJudgement(activeType.key, judgementKey).length;
    count.textContent = selectedCount ? `${selectedCount} 个动画` : "未配置";
    button.append(title, count);

    button.addEventListener("click", () => {
      state.activeJudgementByType = state.activeJudgementByType || {};
      state.activeJudgementByType[activeType.key] = judgementKey;
      renderBehaviorEditor();
    });
    tabs.append(button);
  });
  return tabs;
}

function behaviorModeToneClass(typeKey) {
  return `behavior-mode-tab-${String(typeKey || "").replace(/_/g, "-")}`;
}

function isWidgetInteractionType(typeKey) {
  return core.WIDGET_INTERACTION_CARDS.some((card) => card.typeKey === typeKey);
}

function renderBehaviorEditor(options = {}) {
  if (!els.behaviorEditor) return;
  const scrollSnapshot = options.preserveScroll ? captureBehaviorEditorScroll() : null;
  els.behaviorEditor.replaceChildren();
  const keys = availableAnimationKeys();
  const maxBehaviors = state.account.developerMax
    ? core.DEVELOPER_MAX_PET_BEHAVIORS
    : core.ORDINARY_MAX_PET_BEHAVIORS;
  const sceneInteractionActive = state.activeInteractionType === SCENE_INTERACTION_MODE_KEY
    || state.activeInteractionType === OFFICIAL_SCENE_MODE_KEY;
  const widgetInteractionActive = state.activeInteractionType === WIDGET_INTERACTION_MODE_KEY
    || isWidgetInteractionType(state.activeInteractionType);
  const officialSceneActive = sceneInteractionActive;
  if (state.activeInteractionType === OFFICIAL_SCENE_MODE_KEY) {
    state.activeInteractionType = SCENE_INTERACTION_MODE_KEY;
  }
  if (widgetInteractionActive && state.activeInteractionType !== WIDGET_INTERACTION_MODE_KEY) {
    state.activeInteractionType = WIDGET_INTERACTION_MODE_KEY;
  }
  if (!sceneInteractionActive && state.activeInteractionType === "application_interaction") {
    state.activeInteractionType = SCENE_INTERACTION_MODE_KEY;
  }
  if (!sceneInteractionActive
    && !widgetInteractionActive
    && !core.INTERACTION_TYPES.some((type) => type.key === state.activeInteractionType)) {
    state.activeInteractionType = core.INTERACTION_TYPES[0].key;
  }
  const activeType = sceneInteractionActive || widgetInteractionActive
    ? null
    : core.INTERACTION_TYPES.find((type) => type.key === state.activeInteractionType);
  if (activeType) ensureInteractionConfig(activeType.key);  // seed empty container for nested judgements
  const activeJudgementKey = activeType ? activeJudgementForType(activeType) : "";

  const shell = document.createElement("div");
  shell.className = "behavior-workbench";

  const modes = document.createElement("nav");
  modes.className = "behavior-mode-tabs";
  modes.setAttribute("aria-label", "互动模式");
  for (const type of core.INTERACTION_TYPES) {
    if (type.key === "application_interaction") continue;
    if (isWidgetInteractionType(type.key)) continue;
    const button = document.createElement("button");
    button.type = "button";
    button.className = `behavior-mode-tab ${behaviorModeToneClass(type.key)}`;
    if (!officialSceneActive && type.key === state.activeInteractionType) button.classList.add("active");
    const configured = type.judgements.filter(([key]) => selectedAnimationsForJudgement(type.key, key).length).length;
    button.innerHTML = `<strong></strong><span></span>`;
    button.querySelector("strong").textContent = `${type.label}互动`;
    button.querySelector("span").textContent = `${configured}/${type.judgements.length} 个判定已配置`;
    button.addEventListener("click", () => {
      state.activeInteractionType = type.key;
      renderBehaviorEditor();
    });
    modes.append(button);
  }
  appendWidgetInteractionModeTab(modes, widgetInteractionActive);
  appendSceneInteractionModeTab(modes, sceneInteractionActive);

  const detail = document.createElement("section");
  detail.className = "behavior-detail-panel";
  // The active judgement tab only scopes which behavior rows are edited.

  const activePanel = document.createElement("div");
  activePanel.className = "active-judgement-panel";
  if (sceneInteractionActive) {
    activePanel.append(renderSceneInteractionPanel(keys));
    detail.append(activePanel);
  } else if (widgetInteractionActive) {
    activePanel.append(renderWidgetInteractionPanel(keys, maxBehaviors));
    detail.append(activePanel);
  } else {
    const judgementTabs = renderJudgementTabs(activeType, activeJudgementKey);
    const activeJudgement = activeType.judgements.find(([judgementKey]) => judgementKey === activeJudgementKey);
    if (activeJudgement) {
      activePanel.append(renderPetBehaviorPanel(activeType, activeJudgement[0], keys, maxBehaviors));
    }
    detail.append(judgementTabs, activePanel);
  }

  shell.append(renderBehaviorTempoPanel(), modes, detail);
  els.behaviorEditor.append(shell);
  restoreBehaviorEditorScroll(scrollSnapshot);
}

// ---- Behaviour pacing: activity slider + presets (Step 4) ----

function setBehaviorTempoProfile(profile) {
  state.behaviorTempo = core.resolveBehaviorTempo({ behaviorTempo: profile });
  state.rhythmPreview = null;  // tempo changed → stale preview
  persistDraft();
  renderBehaviorEditor({ preserveScroll: true });
}

function renderBehaviorTempoPanel() {
  const tempo = core.resolveBehaviorTempo({ behaviorTempo: state.behaviorTempo });
  const panel = document.createElement("section");
  panel.className = "behavior-tempo-panel";

  const head = document.createElement("div");
  head.className = "tempo-head";
  const headTitle = document.createElement("strong");
  headTitle.textContent = "整体活跃度";
  const headHint = document.createElement("span");
  headHint.textContent = "决定宠物多久“动一下”，以及在桌面移动的频率。安静、有呼吸感的节奏体验通常更好。";
  head.append(headTitle, headHint);
  panel.append(head);

  const presets = document.createElement("div");
  presets.className = "tempo-presets";
  for (const key of core.BEHAVIOR_TEMPO_PROFILES) {
    const preset = core.BEHAVIOR_TEMPO_PRESETS[key];
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "tempo-preset-btn";
    if (tempo.profile === key) btn.classList.add("active");
    const name = document.createElement("strong");
    name.textContent = preset.label;
    const meta = document.createElement("span");
    meta.textContent = `驻留 ${preset.idle_dwell_seconds[0]}–${preset.idle_dwell_seconds[1]}s · 位移冷却 ${preset.motion_cooldown_seconds}s`;
    btn.append(name, meta);
    btn.addEventListener("click", () => setBehaviorTempoProfile(key));
    presets.append(btn);
  }
  panel.append(presets);

  const readout = document.createElement("p");
  readout.className = "tempo-readout";
  readout.textContent = `当前：主动行为之间静止 ${tempo.idle_dwell_seconds[0]}–${tempo.idle_dwell_seconds[1]} 秒；所有位移类动画共享 ${tempo.motion_cooldown_seconds} 秒冷却。`;
  panel.append(readout);

  const simBar = document.createElement("div");
  simBar.className = "rhythm-sim-bar";
  const simBtn = document.createElement("button");
  simBtn.type = "button";
  simBtn.className = "rhythm-sim-btn";
  simBtn.textContent = "预览 5 分钟挂机节奏";
  simBtn.addEventListener("click", runRhythmSimulation);
  simBar.append(simBtn);
  panel.append(simBar);

  if (state.rhythmPreview) panel.append(renderRhythmPreview(state.rhythmPreview));
  return panel;
}

// ---- Rhythm simulation preview (Step 5) ----

function runRhythmSimulation() {
  try {
    const manifest = core.buildManifest(projectForValidation());
    // Vary the seed per click so repeated previews show different (but
    // representative) runs; the simulator is otherwise fully deterministic.
    const seed = (Date.now() % 100000) + 1;
    state.rhythmPreview = core.simulateBehaviorTimeline(manifest, {
      durationSeconds: 300,
      seed,
      isProUser: !!state.account.developerMax,
    });
  } catch (error) {
    state.rhythmPreview = { error: String((error && error.message) || error) };
  }
  renderBehaviorEditor({ preserveScroll: true });
}

function rhythmKindLabel(kind) {
  return kind === "motion" ? "桌面移动" : kind === "active" ? "原地反应" : "安静待机";
}

function formatRhythmTime(t) {
  const total = Math.round(t);
  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}

function renderRhythmPreview(result) {
  const wrap = document.createElement("div");
  wrap.className = "rhythm-sim";
  if (result.error) {
    wrap.textContent = `无法模拟节奏：${result.error}`;
    return wrap;
  }
  const total = result.stats.durationSeconds || 300;
  const track = document.createElement("div");
  track.className = "rhythm-track";
  for (const ev of result.events) {
    const seg = document.createElement("div");
    seg.className = `rhythm-seg ${ev.kind}`;
    seg.style.flexGrow = String(Math.max(0.3, (ev.duration || 2)));
    seg.title = `${formatRhythmTime(ev.t)} · ${rhythmKindLabel(ev.kind)}${ev.animation ? " · " + ev.animation : ""}`;
    track.append(seg);
  }
  wrap.append(track);

  const legend = document.createElement("div");
  legend.className = "rhythm-legend";
  for (const [kind, label] of [["idle", "安静待机"], ["active", "原地反应"], ["motion", "桌面移动"]]) {
    const dot = document.createElement("span");
    dot.className = `rhythm-seg ${kind}`;
    legend.append(dot, document.createTextNode(label + "　"));
  }
  wrap.append(legend);

  const s = result.stats;
  const stats = document.createElement("p");
  stats.className = "rhythm-stats";
  stats.textContent = `5 分钟内：桌面移动 ${s.motionCount} 次（约 ${s.motionPerMinute.toFixed(1)} 次/分），原地反应 ${s.activeCount - s.motionCount} 次，其余时间安静待机。`;
  wrap.append(stats);

  if (s.motionPerMinute >= 2) {
    const warn = document.createElement("p");
    warn.className = "rhythm-warn";
    warn.textContent = "⚠ 移动偏频繁，可能显得躁动。建议把活跃度调向“安静”，或减少位移类动画绑定。";
    wrap.append(warn);
  }
  return wrap;
}

function appendSceneInteractionModeTab(modes, active) {
  const configuredApps = countConfiguredSceneAppCards();
  const configuredScenes = sanitizeOfficialSceneRules(state.officialSceneRules).length;
  const button = document.createElement("button");
  button.type = "button";
  button.className = `behavior-mode-tab ${behaviorModeToneClass(SCENE_INTERACTION_MODE_KEY)} official-scene-mode-tab scene-interaction-mode-tab`;
  if (active) button.classList.add("active");
  button.innerHTML = `<strong></strong><span></span>`;
  button.querySelector("strong").textContent = "场景互动";
  button.querySelector("span").textContent = `应用 ${configuredApps}/${core.SCENE_APP_CARDS.length} · 工作 ${configuredScenes} 个场景`;
  button.addEventListener("click", () => {
    state.activeInteractionType = SCENE_INTERACTION_MODE_KEY;
    renderBehaviorEditor();
  });
  modes.append(button);
}

function appendWidgetInteractionModeTab(modes, active) {
  const totalJudgements = core.WIDGET_INTERACTION_CARDS.reduce((sum, card) => {
    const type = core.INTERACTION_TYPES.find((item) => item.key === card.typeKey);
    return sum + (type?.judgements?.length || 0);
  }, 0);
  const configured = core.WIDGET_INTERACTION_CARDS.reduce((sum, card) => {
    const type = core.INTERACTION_TYPES.find((item) => item.key === card.typeKey);
    if (!type) return sum;
    return sum + type.judgements.filter(([key]) => selectedAnimationsForJudgement(type.key, key).length).length;
  }, 0);
  const button = document.createElement("button");
  button.type = "button";
  button.className = `behavior-mode-tab ${behaviorModeToneClass(WIDGET_INTERACTION_MODE_KEY)} widget-interaction-mode-tab`;
  if (active) button.classList.add("active");
  button.innerHTML = `<strong></strong><span></span>`;
  button.querySelector("strong").textContent = "小组件互动";
  button.querySelector("span").textContent = `${core.WIDGET_INTERACTION_CARDS.length} 张卡片 · ${configured}/${totalJudgements} 个判定已配置`;
  button.addEventListener("click", () => {
    state.activeInteractionType = WIDGET_INTERACTION_MODE_KEY;
    renderBehaviorEditor();
  });
  modes.append(button);
}

function renderWidgetInteractionPanel(keys, maxBehaviors) {
  const panel = document.createElement("section");
  panel.className = "widget-interaction-panel";
  for (const card of core.WIDGET_INTERACTION_CARDS) {
    const type = core.INTERACTION_TYPES.find((item) => item.key === card.typeKey);
    if (!type) continue;
    ensureInteractionConfig(type.key);
    const activeJudgementKey = activeJudgementForType(type);
    const activeJudgement = type.judgements.find(([judgementKey]) => judgementKey === activeJudgementKey);

    const cardNode = document.createElement("article");
    cardNode.className = "widget-interaction-card";
    cardNode.setAttribute("data-widget-interaction-card", card.key);

    const head = document.createElement("div");
    head.className = "widget-interaction-card-head";
    const title = document.createElement("h3");
    title.textContent = card.label;
    const description = document.createElement("p");
    description.textContent = card.description || "";
    head.append(title, description);

    cardNode.append(head, renderJudgementTabs(type, activeJudgementKey));
    if (activeJudgement) {
      cardNode.append(renderPetBehaviorPanel(type, activeJudgement[0], keys, maxBehaviors));
    }
    panel.append(cardNode);
  }
  return panel;
}

function appendOfficialSceneModeTab(modes, active) {
  appendSceneInteractionModeTab(modes, active);
}

function sceneAppEventAnimation(appKey, eventKey) {
  if (eventKey === "duration" && core.APP_REACTION_KEYS.includes(appKey)) {
    return normalizeList(state.appReactions[appKey])[0] || "";
  }
  state.sceneAppEventBindings = sanitizeSceneAppEventBindings(state.sceneAppEventBindings);
  return state.sceneAppEventBindings?.[appKey]?.[eventKey] || "";
}

function setSceneAppEventAnimation(appKey, eventKey, animationKey) {
  const value = String(animationKey || "");
  if (eventKey === "duration" && core.APP_REACTION_KEYS.includes(appKey)) {
    state.appReactions[appKey] = value ? [value] : "";
    syncJudgementProbabilities("application_interaction", appKey);
  } else {
    state.sceneAppEventBindings = sanitizeSceneAppEventBindings(state.sceneAppEventBindings);
    state.sceneAppEventBindings[appKey][eventKey] = value;
  }
  persistDraft();
  refreshReview();
  renderBehaviorEditor({ preserveScroll: true });
}

function closeSceneApplicationPopover() {
  if (!sceneApplicationPopover) return;
  document.removeEventListener("pointerdown", handleSceneApplicationPopoverOutside);
  document.removeEventListener("keydown", handleSceneApplicationPopoverKeydown);
  sceneApplicationPopover.remove();
  sceneApplicationPopover = null;
}

function handleSceneApplicationPopoverOutside(event) {
  if (!sceneApplicationPopover) return;
  if (!sceneApplicationPopover.contains(event.target)) closeSceneApplicationPopover();
}

function handleSceneApplicationPopoverKeydown(event) {
  if (event.key === "Escape") closeSceneApplicationPopover();
}

function positionSceneApplicationPopover(popover, anchor) {
  const rect = anchor.getBoundingClientRect();
  const width = Math.min(320, Math.max(260, window.innerWidth - 32));
  popover.style.width = `${width}px`;
  popover.style.left = "16px";
  popover.style.top = "16px";
  const popoverRect = popover.getBoundingClientRect();
  const left = Math.max(16, Math.min(window.innerWidth - popoverRect.width - 16, rect.left));
  const below = rect.bottom + 10;
  const above = rect.top - popoverRect.height - 10;
  const top = below + popoverRect.height <= window.innerHeight - 16
    ? below
    : Math.max(16, above);
  popover.style.left = `${left}px`;
  popover.style.top = `${top}px`;
}

function openSceneApplicationPopover(card, eventConfig, anchor, keys) {
  closeSceneApplicationPopover();
  const currentAnimation = sceneAppEventAnimation(card.key, eventConfig.key);
  const popover = document.createElement("div");
  popover.className = "scene-app-popover";
  popover.setAttribute("role", "dialog");
  popover.setAttribute("aria-label", `${card.label} ${eventConfig.label}动画配置`);

  const head = document.createElement("div");
  head.className = "scene-app-popover-head";
  const title = document.createElement("strong");
  title.textContent = `${eventConfig.label}动画`;
  const subtitle = document.createElement("span");
  subtitle.textContent = card.label;
  head.append(title, subtitle);

  const field = document.createElement("label");
  field.className = "scene-app-popover-field";
  field.append(Object.assign(document.createElement("span"), { textContent: "选择动画" }));
  const select = document.createElement("select");
  select.disabled = !keys.length;
  const empty = document.createElement("option");
  empty.value = "";
  empty.textContent = keys.length ? "未配置" : "先上传动画";
  select.append(empty);
  for (const key of keys) {
    const option = document.createElement("option");
    option.value = key;
    option.textContent = key;
    select.append(option);
  }
  select.value = currentAnimation;
  field.append(select);

  const actions = document.createElement("div");
  actions.className = "scene-app-popover-actions";
  const preview = document.createElement("button");
  preview.type = "button";
  preview.className = "scene-app-popover-button secondary";
  preview.textContent = "预览";
  preview.disabled = !select.value;
  const confirm = document.createElement("button");
  confirm.type = "button";
  confirm.className = "scene-app-popover-button primary";
  confirm.textContent = "确认";
  confirm.disabled = !keys.length;
  const cancel = document.createElement("button");
  cancel.type = "button";
  cancel.className = "scene-app-popover-button secondary";
  cancel.textContent = "取消";
  const remove = document.createElement("button");
  remove.type = "button";
  remove.className = "scene-app-popover-button danger";
  remove.textContent = "删除配置";
  remove.disabled = !currentAnimation;

  select.addEventListener("change", () => {
    preview.disabled = !select.value;
  });
  preview.addEventListener("click", () => {
    if (!select.value) return;
    syncBehaviorPreview("application_interaction", card.app || card.key, select.value, 0);
    updateMainPreview();
  });
  confirm.addEventListener("click", () => {
    setSceneAppEventAnimation(card.key, eventConfig.key, select.value);
    closeSceneApplicationPopover();
  });
  cancel.addEventListener("click", closeSceneApplicationPopover);
  remove.addEventListener("click", () => {
    setSceneAppEventAnimation(card.key, eventConfig.key, "");
    closeSceneApplicationPopover();
  });

  actions.append(preview, confirm, cancel, remove);
  popover.append(head, field, actions);
  document.body.append(popover);
  sceneApplicationPopover = popover;
  positionSceneApplicationPopover(popover, anchor);
  window.setTimeout(() => {
    document.addEventListener("pointerdown", handleSceneApplicationPopoverOutside);
    document.addEventListener("keydown", handleSceneApplicationPopoverKeydown);
  }, 0);
}

function sceneAppEnabled(appKey) {
  return (core.SCENE_APP_EVENTS || []).some((event) => Boolean(sceneAppEventAnimation(appKey, event.key)));
}

function countConfiguredSceneAppCards() {
  return (core.SCENE_APP_CARDS || []).filter((card) => sceneAppEnabled(card.key)).length;
}

function setSceneAppEnabled(appKey, enabled, keys, card, anchor, input) {
  if (enabled) {
    if (input) input.checked = sceneAppEnabled(appKey);
    const event = (core.SCENE_APP_EVENTS || [])[0];
    if (card && anchor && event) {
      openSceneApplicationPopover(card, event, anchor, keys);
    } else {
      renderBehaviorEditor({ preserveScroll: true });
    }
    return;
  }
  for (const event of core.SCENE_APP_EVENTS || []) setSceneAppEventAnimation(appKey, event.key, "");
}

function renderSceneInteractionPanel(keys) {
  const panel = document.createElement("section");
  panel.className = "scene-interaction-panel";

  const groups = [
    ["application", "应用"],
    ["workload", "工作"],
    ["attention", "专注"],
    ["relationship", "陪伴"],
  ];
  groups.sort((a, b) => SCENE_GROUP_ORDER.indexOf(a[0]) - SCENE_GROUP_ORDER.indexOf(b[0]));
  const tabs = document.createElement("nav");
  tabs.className = "scene-interaction-tabs";
  tabs.setAttribute("aria-label", "场景互动分类");
  for (const [groupKey, label] of groups) {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "scene-interaction-tab";
    if (state.activeSceneInteractionGroup === groupKey) button.classList.add("active");
    button.textContent = label;
    button.addEventListener("click", () => {
      state.activeSceneInteractionGroup = groupKey;
      if (groupKey !== "application") state.activeOfficialSceneGroup = groupKey;
      renderBehaviorEditor({ preserveScroll: true });
    });
    tabs.append(button);
  }

  const body = document.createElement("div");
  body.className = "scene-interaction-body";
  if (state.activeSceneInteractionGroup === "application") {
    body.append(renderSceneApplicationCards(keys));
  } else {
    state.activeOfficialSceneGroup = state.activeSceneInteractionGroup;
    body.append(renderOfficialSceneLibraryPanel(keys, { showTabs: false }));
  }

  panel.append(tabs, body);
  return panel;
}

function renderSceneApplicationCards(keys) {
  const section = document.createElement("section");
  section.className = "scene-application-section";
  const head = document.createElement("div");
  head.className = "scene-application-head";
  head.innerHTML = `<h3>应用</h3><p>每张卡是一个应用名字。开关控制整张卡；下方启动、使用时长、关闭胶囊可分别配置动画，已有胶囊配置不会被删除。</p>`;
  const grid = document.createElement("div");
  grid.className = "scene-app-grid";
  for (const card of core.SCENE_APP_CARDS || []) {
    grid.append(renderSceneApplicationCard(card, keys));
  }
  section.append(head, grid);
  return section;
}

function renderSceneApplicationCard(card, keys) {
  const enabled = sceneAppEnabled(card.key);
  const article = document.createElement("article");
  article.className = `scene-app-card${enabled ? " enabled" : ""}`;

  const top = document.createElement("div");
  top.className = "scene-app-card-top";
  const main = document.createElement("div");
  main.className = "scene-app-main";
  const icon = document.createElement("span");
  icon.className = `scene-app-icon scene-app-icon-${card.key.replace(/_/g, "-")}`;
  icon.setAttribute("aria-hidden", "true");
  icon.textContent = card.iconLabel || card.label.slice(0, 2);
  const copy = document.createElement("div");
  copy.className = "scene-app-copy";
  const title = document.createElement("strong");
  title.textContent = card.label;
  const meta = document.createElement("span");
  meta.textContent = core.APP_REACTION_KEYS.includes(card.key) ? "独立应用" : "特色应用";
  copy.append(title, meta);
  main.append(icon, copy);

  const switchLabel = document.createElement("label");
  switchLabel.className = "official-scene-switch scene-app-switch";
  const input = document.createElement("input");
  input.type = "checkbox";
  input.checked = enabled;
  input.disabled = !keys.length;
  input.addEventListener("change", () => {
    const firstCapsule = article.querySelector?.(".scene-app-capsule");
    setSceneAppEnabled(card.key, input.checked, keys, card, firstCapsule, input);
  });
  const slider = document.createElement("span");
  switchLabel.append(input, slider);
  top.append(main, switchLabel);

  const capsules = document.createElement("div");
  capsules.className = "scene-app-capsules";
  for (const event of core.SCENE_APP_EVENTS || []) {
    capsules.append(renderSceneApplicationCapsule(card, event, keys));
  }

  article.append(top, capsules);
  return article;
}

function renderSceneApplicationCapsule(card, event, keys) {
  const animationKey = sceneAppEventAnimation(card.key, event.key);
  const capsule = document.createElement("button");
  capsule.type = "button";
  capsule.className = `scene-app-capsule${animationKey ? " configured" : ""}`;
  capsule.setAttribute("aria-label", `${card.label}${event.label}动画配置`);
  const symbol = document.createElement("span");
  symbol.className = `scene-app-event-symbol scene-app-event-symbol-${event.key}`;
  symbol.setAttribute("aria-hidden", "true");
  const content = document.createElement("span");
  content.className = "scene-app-capsule-copy";
  const label = document.createElement("span");
  label.className = "scene-app-capsule-label";
  label.textContent = event.label;
  const stateText = document.createElement("strong");
  stateText.textContent = animationKey || "未配置";
  content.append(label, stateText);
  const arrow = document.createElement("span");
  arrow.className = "scene-app-capsule-arrow";
  arrow.textContent = "›";
  capsule.addEventListener("click", () => openSceneApplicationPopover(card, event, capsule, keys));
  capsule.append(symbol, content, arrow);
  return capsule;
}

function officialSceneGroupLabel(groupKey) {
  return OFFICIAL_SCENE_GROUPS.find(([key]) => key === groupKey)?.[1] || groupKey;
}

function officialConditionLabel(key) {
  return core.OFFICIAL_TRIGGER_CONDITION_TYPES.find((item) => item.key === key)?.label || key;
}

function officialSceneRule(sceneId) {
  return sanitizeOfficialSceneRules(state.officialSceneRules).find((rule) => rule.scene_id === sceneId) || null;
}

function setOfficialSceneRule(sceneId, enabled, animationKey) {
  const clean = sanitizeOfficialSceneRules(state.officialSceneRules).filter((rule) => rule.scene_id !== sceneId);
  const animation = String(animationKey || "").trim();
  if (enabled && animation) {
    clean.push({
      scene_id: sceneId,
      pet_behaviors: [{ slot: 1, animation, weight: 100 }],
    });
  }
  state.officialSceneRules = clean;
  persistDraft();
  refreshReview();
  renderBehaviorEditor({ preserveScroll: true });
}

function renderOfficialSceneLibraryPanel(keys, options = {}) {
  const showTabs = options.showTabs !== false;
  const panel = document.createElement("section");
  panel.className = "official-scene-library";

  const head = document.createElement("div");
  head.className = "official-scene-library-head";
  const title = document.createElement("div");
  title.innerHTML = `<h3>官方场景库</h3><p>选择预设场景，并为启用的场景指定播放动画。导出宠物包时会写入 interaction_rules。</p>`;
  head.append(title);

  const tabs = document.createElement("nav");
  tabs.className = "official-scene-tabs";
  tabs.setAttribute("aria-label", "官方场景分类");
  for (const [groupKey, label] of OFFICIAL_SCENE_GROUPS) {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "official-scene-tab";
    if (groupKey === state.activeOfficialSceneGroup) button.classList.add("active");
    button.textContent = label;
    button.addEventListener("click", () => {
      state.activeOfficialSceneGroup = groupKey;
      renderBehaviorEditor({ preserveScroll: true });
    });
    tabs.append(button);
  }

  const grid = document.createElement("div");
  grid.className = "official-scene-grid";
  const scenes = core.OFFICIAL_SCENE_REGISTRY.filter((scene) => scene.group === state.activeOfficialSceneGroup);
  for (const scene of scenes) {
    grid.append(renderOfficialSceneCard(scene, keys));
  }

  if (showTabs) panel.append(head, tabs, grid);
  else {
    const compactHint = document.createElement("p");
    compactHint.className = "official-scene-compact-hint";
    compactHint.textContent = state.activeOfficialSceneGroup === "workload" ? "工作分组包含凌晨工作等场景。" : "";
    panel.append(head);
    if (compactHint.textContent) panel.append(compactHint);
    panel.append(grid);
  }
  return panel;
}

function renderOfficialSceneCard(scene, keys) {
  const rule = officialSceneRule(scene.id);
  const enabled = Boolean(rule);
  const selectedAnimation = rule?.pet_behaviors?.[0]?.animation || "";
  const card = document.createElement("article");
  card.className = `official-scene-card${enabled ? " enabled" : ""}`;

  const top = document.createElement("div");
  top.className = "official-scene-card-top";
  const copy = document.createElement("div");
  copy.className = "official-scene-copy";
  const title = document.createElement("strong");
  title.textContent = scene.label;
  const meta = document.createElement("span");
  meta.textContent = (scene.conditionTypes || []).map(officialConditionLabel).join(" + ");
  copy.append(title, meta);

  const switchLabel = document.createElement("label");
  switchLabel.className = "official-scene-switch";
  const input = document.createElement("input");
  input.type = "checkbox";
  input.checked = enabled;
  input.disabled = !keys.length;
  input.addEventListener("change", () => {
    if (!input.checked) {
      setOfficialSceneRule(scene.id, false, "");
      return;
    }
    if (!selectedAnimation) {
      renderBehaviorEditor({ preserveScroll: true });
      return;
    }
    setOfficialSceneRule(scene.id, true, selectedAnimation);
  });
  const slider = document.createElement("span");
  switchLabel.append(input, slider);
  top.append(copy, switchLabel);

  const controls = document.createElement("div");
  controls.className = "official-scene-controls";
  const label = document.createElement("label");
  label.textContent = "播放动画";
  const select = document.createElement("select");
  select.disabled = !keys.length;
  const empty = document.createElement("option");
  empty.value = "";
  empty.textContent = keys.length ? "未配置" : "先上传动画";
  select.append(empty);
  for (const key of keys) {
    const option = document.createElement("option");
    option.value = key;
    option.textContent = key;
    select.append(option);
  }
  select.value = selectedAnimation;
  select.addEventListener("change", () => setOfficialSceneRule(scene.id, Boolean(select.value), select.value));
  controls.append(label, select);

  card.append(top, controls);
  return card;
}

function renderPetBehaviorPanel(type, judgementKey, keys, maxBehaviors) {
  const judgementConfig = ensureInteractionConfig(type.key, judgementKey);
  const selected = selectedAnimationsForJudgement(type.key, judgementKey);
  judgementConfig.weights = normalizeBehaviorProbabilities(selected.slice(0, maxBehaviors), judgementConfig.weights || {});
  const developerMaxJudgement = core.isDeveloperMaxJudgement(type.key, judgementKey);
  const rows = document.createElement("div");
  rows.className = "pet-behavior-rows";
  const activeSelected = selected.slice(0, maxBehaviors);
  if (activeSelected.length) {
    rows.append(renderBehaviorProbabilitySummary(type.key, judgementKey, activeSelected, judgementConfig));
  }
  activeSelected.forEach((animationKey, rowIndex) => {
    rows.append(renderPetBehaviorRow(type.key, judgementKey, animationKey, rowIndex, keys, judgementConfig, activeSelected));
  });
  if (selected.length < maxBehaviors) {
    rows.append(renderAddBehaviorRow(type.key, judgementKey, keys, selected, developerMaxJudgement));
  }
  return rows;
}

function probabilityColor(index) {
  const colors = ["#2563eb", "#14b8a6", "#f59e0b", "#ec4899", "#8b5cf6", "#22c55e", "#f97316", "#06b6d4", "#64748b", "#84cc16"];
  return colors[index % colors.length];
}

function selectorEscape(value) {
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
  return String(value).replace(/["\\]/g, "\\$&");
}

function renderBehaviorProbabilitySummary(typeKey, judgementKey, selected, judgementConfig) {
  const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
  judgementConfig.weights = weights;
  const summary = document.createElement("section");
  summary.className = "behavior-probability-summary";
  summary.setAttribute("aria-label", "宠物行为概率分布");

  const header = document.createElement("div");
  header.className = "behavior-probability-header";
  const title = document.createElement("strong");
  title.textContent = "行为概率分布";
  const total = Object.values(weights).reduce((sum, value) => sum + value, 0);
  const badge = document.createElement("span");
  badge.className = total === 100 ? "probability-total ok" : "probability-total warn";
  badge.textContent = `合计 ${total}%`;
  header.append(title, badge);

  const bar = document.createElement("div");
  bar.className = "behavior-probability-bar";
  const fill = document.createElement("div");
  fill.className = "behavior-probability-fill";
  bar.append(fill);
  let cumulative = 0;
  selected.forEach((animationKey, index) => {
    const segment = document.createElement("span");
    segment.className = "behavior-probability-segment";
    segment.setAttribute("data-animation-key", animationKey);
    segment.setAttribute("style", `width: ${weights[animationKey] || 0}%; background: ${probabilityColor(index)};`);
    segment.title = `${labelForAnimation(animationKey)} ${weights[animationKey] || 0}%`;
    segment.classList.toggle("is-tiny", (weights[animationKey] || 0) < 8);
    const percent = document.createElement("span");
    percent.className = "behavior-probability-percent";
    percent.textContent = `${weights[animationKey] || 0}%`;
    segment.append(percent);
    fill.append(segment);
    cumulative += weights[animationKey] || 0;
    if (index < selected.length - 1) {
      const handle = document.createElement("button");
      handle.type = "button";
      handle.className = "probability-boundary";
      handle.setAttribute("data-boundary-index", String(index));
      handle.title = `${labelForAnimation(animationKey)} / ${labelForAnimation(selected[index + 1])}`;
      handle.setAttribute("aria-label", `调整宠物行为${index + 1}和宠物行为${index + 2}的概率边界`);
      handle.setAttribute(
        "style",
        `left: ${cumulative}%; --boundary-left: ${probabilityColor(index)}; --boundary-right: ${probabilityColor(index + 1)};`,
      );
      handle.addEventListener("pointerdown", (event) => {
        beginBehaviorBoundaryDrag(event, bar, typeKey, judgementKey, index);
      });
      handle.addEventListener("mousedown", (event) => {
        if (bar._boundaryPointerDrag) return;
        beginBehaviorBoundaryMouseDrag(event, bar, typeKey, judgementKey, index);
      });
      handle.addEventListener("keydown", (event) => {
        handleBehaviorBoundaryKeydown(event, bar, typeKey, judgementKey, index);
      });
      bar.append(handle);
    }
  });

  const hint = document.createElement("p");
  hint.textContent = typeKey === "teleport_interaction"
    ? "消失/出现过场内按此概率选择宠物行为。"
    : "当前判定触发后，按此 100% 分布选择一个宠物行为。";

  summary.append(header, bar, hint);
  return summary;
}

function behaviorBoundaryMetrics(selected, weights, boundaryIndex) {
  const leftKey = selected[boundaryIndex];
  const rightKey = selected[boundaryIndex + 1];
  let pairStart = 0;
  for (let index = 0; index < boundaryIndex; index += 1) pairStart += weights[selected[index]] || 0;
  const pairTotal = Math.max(2, (weights[leftKey] || 0) + (weights[rightKey] || 0));
  return { leftKey, rightKey, pairStart, pairTotal };
}

function updateBehaviorProbabilityBarView(bar, selected, weights) {
  let cumulative = 0;
  selected.forEach((animationKey, index) => {
    const value = weights[animationKey] || 0;
    const segment = bar.querySelector(`.behavior-probability-segment[data-animation-key="${selectorEscape(animationKey)}"]`);
    if (segment) {
      segment.style.width = `${value}%`;
      segment.style.background = probabilityColor(index);
      segment.title = `${labelForAnimation(animationKey)} ${value}%`;
      segment.classList.toggle("is-tiny", value < 8);
      const percent = segment.querySelector(".behavior-probability-percent");
      if (percent) percent.textContent = `${value}%`;
    }
    cumulative += value;
    const handle = bar.querySelector(`.probability-boundary[data-boundary-index="${index}"]`);
    if (handle) {
      handle.style.left = `${cumulative}%`;
      handle.style.setProperty("--boundary-left", probabilityColor(index));
      handle.style.setProperty("--boundary-right", probabilityColor(index + 1));
      handle.title = `${labelForAnimation(animationKey)} / ${labelForAnimation(selected[index + 1])}`;
    }
  });
}

function applyBehaviorBoundaryChange(typeKey, judgementKey, boundaryIndex, nextLeftValue, options = null) {
  options = options || {};
  const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
  const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
  const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
  const { leftKey, rightKey, pairTotal } = behaviorBoundaryMetrics(selected, weights, boundaryIndex);
  if (!leftKey || !rightKey) return;
  const left = Math.min(pairTotal - 1, Math.max(1, Math.round(Number(nextLeftValue) || 1)));
  judgementConfig.weights = {
    ...weights,
    [leftKey]: left,
    [rightKey]: pairTotal - left,
  };
  if (options.bar) updateBehaviorProbabilityBarView(options.bar, selected, judgementConfig.weights);
  if (options.render !== false) renderBehaviorEditor({ preserveScroll: true });
  if (options.review !== false) refreshReview();
}

function beginBehaviorBoundaryDrag(event, bar, typeKey, judgementKey, boundaryIndex) {
  event.preventDefault();
  const handle = event.currentTarget;
  bar._boundaryPointerDrag = true;
  handle.setPointerCapture?.(event.pointerId);
  const move = (moveEvent) => {
    const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
    const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
    const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
    const { pairStart, pairTotal } = behaviorBoundaryMetrics(selected, weights, boundaryIndex);
    const rect = bar.getBoundingClientRect();
    const absolute = Math.min(100, Math.max(0, ((moveEvent.clientX - rect.left) / rect.width) * 100));
    const nextLeft = Math.min(pairTotal - 1, Math.max(1, absolute - pairStart));
    applyBehaviorBoundaryChange(typeKey, judgementKey, boundaryIndex, nextLeft, { bar, render: false, review: false });
  };
  const up = (upEvent) => {
    handle.releasePointerCapture?.(upEvent.pointerId);
    handle.removeEventListener("pointermove", move);
    handle.removeEventListener("pointerup", up);
    handle.removeEventListener("pointercancel", up);
    bar._boundaryPointerDrag = false;
    refreshReview();
  };
  handle.addEventListener("pointermove", move);
  handle.addEventListener("pointerup", up);
  handle.addEventListener("pointercancel", up);
}

function beginBehaviorBoundaryMouseDrag(event, bar, typeKey, judgementKey, boundaryIndex) {
  event.preventDefault();
  const move = (moveEvent) => {
    const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
    const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
    const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
    const { pairStart, pairTotal } = behaviorBoundaryMetrics(selected, weights, boundaryIndex);
    const rect = bar.getBoundingClientRect();
    const absolute = Math.min(100, Math.max(0, ((moveEvent.clientX - rect.left) / rect.width) * 100));
    const nextLeft = Math.min(pairTotal - 1, Math.max(1, absolute - pairStart));
    applyBehaviorBoundaryChange(typeKey, judgementKey, boundaryIndex, nextLeft, { bar, render: false, review: false });
  };
  const up = () => {
    document.removeEventListener("mousemove", move);
    document.removeEventListener("mouseup", up);
    refreshReview();
  };
  document.addEventListener("mousemove", move);
  document.addEventListener("mouseup", up);
}

function handleBehaviorBoundaryKeydown(event, bar, typeKey, judgementKey, boundaryIndex) {
  const step = event.shiftKey ? 5 : 1;
  const direction = event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0;
  if (!direction) return;
  event.preventDefault();
  const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
  const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
  const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
  const leftKey = selected[boundaryIndex];
  applyBehaviorBoundaryChange(typeKey, judgementKey, boundaryIndex, (weights[leftKey] || 1) + direction, { bar });
}

function applyBehaviorProbabilityChange(typeKey, judgementKey, animationKey, nextValue) {
  const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
  const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
  judgementConfig.weights = rebalanceBehaviorProbability(selected, judgementConfig.weights || {}, animationKey, nextValue);
  renderBehaviorEditor({ preserveScroll: true });
  refreshReview();
}

function renderProbabilityControl(typeKey, judgementKey, animationKey, rowIndex, judgementConfig, selectedKeys = []) {
  const selected = selectedKeys.length ? selectedKeys : selectedAnimationsForJudgement(typeKey, judgementKey);
  const weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
  judgementConfig.weights = weights;
  const value = weights[animationKey] || 100;
  const locked = selected.length <= 1;

  const wrapper = document.createElement("label");
  wrapper.className = "behavior-control-field probability-control";
  wrapper.setAttribute("style", `--probability-color: ${probabilityColor(rowIndex)};`);

  const label = document.createElement("span");
  label.className = "slot-note probability-label";
  label.textContent = typeKey === "teleport_interaction" ? "独立概率" : "概率";

  const slider = document.createElement("input");
  slider.type = "range";
  slider.className = "probability-slider";
  slider.min = "1";
  slider.max = String(Math.max(1, 100 - Math.max(0, selected.length - 1)));
  slider.value = String(value);
  slider.disabled = locked;
  slider.setAttribute("aria-label", `宠物行为${rowIndex + 1}概率`);

  const inputWrap = document.createElement("span");
  inputWrap.className = "probability-input-wrap";
  const number = document.createElement("input");
  number.type = "number";
  number.className = "probability-number";
  number.min = slider.min;
  number.max = slider.max;
  number.inputMode = "numeric";
  number.value = String(value);
  number.disabled = locked;
  const suffix = document.createElement("span");
  suffix.textContent = "%";
  inputWrap.append(number, suffix);

  const onInput = (event) => {
    applyBehaviorProbabilityChange(typeKey, judgementKey, animationKey, event.target.value);
  };
  slider.addEventListener("input", onInput);
  number.addEventListener("change", onInput);

  wrapper.append(label, slider, inputWrap);
  return wrapper;
}

function renderPetBehaviorRow(typeKey, judgementKey, animationKey, rowIndex, keys, judgementConfig, selectedKeys = []) {
  const row = document.createElement("div");
  row.className = "pet-behavior-row pet-behavior-card";
  row.setAttribute("style", `--probability-color: ${probabilityColor(rowIndex)};`);
  const slot = document.createElement("span");
  slot.className = "slot";
  slot.textContent = rowIndex + 1;

  const remove = document.createElement("button");
  remove.className = "pet-behavior-remove";
  remove.type = "button";
  remove.textContent = "×";
  remove.title = `删除宠物行为${rowIndex + 1}`;
  remove.setAttribute("aria-label", `删除宠物行为${rowIndex + 1}`);
  remove.addEventListener("click", () => updateJudgementAnimation(typeKey, judgementKey, animationKey, false));

  const name = document.createElement("div");
  name.className = "pet-behavior-name";
  const titleLine = document.createElement("div");
  titleLine.className = "pet-behavior-title-line";
  const title = document.createElement("strong");
  title.setAttribute("style", `color: ${probabilityColor(rowIndex)};`);
  title.textContent = `宠物行为${rowIndex + 1}`;
  const previewButton = document.createElement("button");
  previewButton.className = "pet-behavior-capsule test-capsule pet-behavior-preview";
  previewButton.type = "button";
  previewButton.textContent = "预览";
  previewButton.title = `预览宠物行为${rowIndex + 1}`;
  previewButton.addEventListener("click", (event) => {
    event.stopPropagation();
    syncBehaviorPreview(typeKey, judgementKey, animationKey, rowIndex);
    updateMainPreview();
  });
  const desktopTestButton = document.createElement("button");
  desktopTestButton.className = "pet-behavior-capsule test-capsule pet-behavior-desktop-test";
  desktopTestButton.type = "button";
  desktopTestButton.textContent = "桌面测试";
  desktopTestButton.title = `在桌面上测试宠物行为${rowIndex + 1}`;
  desktopTestButton.addEventListener("click", async (event) => {
    event.stopPropagation();
    const previousText = desktopTestButton.textContent;
    desktopTestButton.disabled = true;
    desktopTestButton.textContent = "发送中";
    try {
      await testBehaviorOnDesktop(typeKey, judgementKey, animationKey, rowIndex);
      desktopTestButton.textContent = "已发送";
      window.setTimeout(() => {
        desktopTestButton.textContent = previousText;
        desktopTestButton.disabled = false;
      }, 1200);
    } catch (_error) {
      desktopTestButton.textContent = previousText;
      desktopTestButton.disabled = false;
    }
  });
  const mouseToggle = document.createElement("button");
  mouseToggle.className = "pet-behavior-capsule config-capsule pet-behavior-mouse-toggle";
  mouseToggle.type = "button";
  const syncMouseToggle = () => {
    const enabled = mouseInteractionEnabled(judgementConfig, animationKey, typeKey);
    mouseToggle.textContent = enabled ? "响应鼠标" : "忽略鼠标";
    mouseToggle.classList.toggle("is-muted", !enabled);
    mouseToggle.title = enabled
      ? "点击后此行为播放时会忽略鼠标互动"
      : "点击后此行为播放时会响应鼠标互动";
  };
  syncMouseToggle();
  mouseToggle.addEventListener("click", (event) => {
    event.stopPropagation();
    setMouseInteractionEnabled(judgementConfig, animationKey, !mouseInteractionEnabled(judgementConfig, animationKey, typeKey));
    syncMouseToggle();
    refreshReview();
  });

  const linkControl = renderBehaviorLinkMenu(typeKey, judgementKey, animationKey, rowIndex);
  remove.classList.add("pet-behavior-capsule", "edit-capsule");
  titleLine.append(title, mouseToggle, linkControl, previewButton, desktopTestButton, remove);
  name.append(titleLine);

  const picker = renderAnimationPicker(typeKey, judgementKey, animationKey, rowIndex, keys);
  const animationField = document.createElement("label");
  animationField.className = "behavior-control-field behavior-animation-field";
  const animationLabel = document.createElement("span");
  animationLabel.className = "slot-note";
  animationLabel.textContent = "选择动画";
  animationField.append(animationLabel, picker);

  const defaultCooldown = core.defaultJudgementCooldown(typeKey, judgementKey);
  const idleDefaultJudgement = typeKey === "idle_interaction" && judgementKey === "idle";
  const cooldownLimits = core.COOLDOWN_LIMITS || { min: 0, max: 86400 };
  judgementConfig.cooldowns = judgementConfig.cooldowns || {};
  if (idleDefaultJudgement) judgementConfig.cooldowns[animationKey] = 0;
  const cooldown = document.createElement("label");
  cooldown.className = "behavior-control-field cooldown behavior-cooldown-field";
  cooldown.title = idleDefaultJudgement
    ? "默认待机，不可设置CD"
    : `宠物行为触发后冷却 ${defaultCooldown} 秒（默认值）`;
  cooldown.innerHTML = `<span class="slot-note">冷却时间(秒)</span><input type="number" min="${cooldownLimits.min}" max="${cooldownLimits.max}" inputmode="numeric"${idleDefaultJudgement ? " disabled" : ""}>`;
  const cooldownInput = cooldown.querySelector("input");
  const currentCooldown = idleDefaultJudgement
    ? 0
    : (judgementConfig.cooldowns?.[animationKey] ?? defaultCooldown);
  cooldownInput.value = currentCooldown;
  cooldownInput.placeholder = String(idleDefaultJudgement ? 0 : defaultCooldown);
  if (!idleDefaultJudgement) {
    cooldownInput.addEventListener("input", (event) => {
      judgementConfig.cooldowns = judgementConfig.cooldowns || {};
      judgementConfig.cooldowns[animationKey] = clampCooldown(event.target.value, defaultCooldown);
      refreshReview();
    });
    cooldownInput.addEventListener("change", () => {
      cooldownInput.value = judgementConfig.cooldowns?.[animationKey] ?? defaultCooldown;
    });
  }

  const leaveWake = document.createElement("label");
  leaveWake.className = "behavior-control-field leave-wake-control";
  const leaveWakeInput = document.createElement("input");
  leaveWakeInput.type = "checkbox";
  const canLeaveWake = typeKey === "application_interaction" && core.isLoopAnimation?.(state.animations[animationKey]);
  judgementConfig.leaveWake = judgementConfig.leaveWake || {};
  if (!canLeaveWake) judgementConfig.leaveWake[animationKey] = false;
  leaveWakeInput.checked = Boolean(canLeaveWake && judgementConfig.leaveWake?.[animationKey]);
  leaveWakeInput.disabled = !canLeaveWake;
  leaveWake.title = canLeaveWake
    ? "对于循环动画，开启唤醒后退出此应用时，会退出循环动画。"
    : "离开唤醒仅适用于循环动画。";
  leaveWake.append(leaveWakeInput, Object.assign(document.createElement("span"), { textContent: "离开唤醒" }));
  leaveWakeInput.addEventListener("change", () => {
    judgementConfig.leaveWake = judgementConfig.leaveWake || {};
    judgementConfig.leaveWake[animationKey] = leaveWakeInput.checked;
    refreshReview();
  });

  const controls = document.createElement("div");
  controls.className = "pet-behavior-controls";
  controls.append(animationField);
  if (typeKey !== "teleport_interaction") controls.append(cooldown);
  if (typeKey === "application_interaction") {
    controls.classList.add("app-controls");
    controls.append(leaveWake);
  }
  if (typeKey === "teleport_interaction") {
    const distance = document.createElement("label");
    distance.className = "behavior-control-field distance";
    judgementConfig.distances = judgementConfig.distances || {};
    distance.append(
      Object.assign(document.createElement("span"), { className: "slot-note", textContent: "距离" }),
      renderDistancePicker(judgementConfig, animationKey),
    );
    controls.classList.add("teleport-controls");
    controls.append(distance);
  }
  if (core.supportsBehaviorMovement?.(typeKey, judgementKey)) {
    controls.classList.add("window-controls");
    controls.append(renderWindowMovementControls(judgementConfig, animationKey, judgementKey));
  }
  row.addEventListener("click", (event) => {
    if (event.target.closest?.("button, input, label")) return;
    syncBehaviorPreview(typeKey, judgementKey, animationKey, rowIndex);
    updateMainPreview();
  });
  row.append(slot, name, controls);
  return row;
}

function mouseInteractionEnabled(judgementConfig, animationKey, typeKey = state.activeInteractionType) {
  judgementConfig.mouseInteractions = judgementConfig.mouseInteractions || {};
  const configured = judgementConfig.mouseInteractions[animationKey];
  if (typeof configured === "boolean") return configured;
  return core.defaultMouseInteractionEnabled(typeKey);
}

function setMouseInteractionEnabled(judgementConfig, animationKey, enabled) {
  judgementConfig.mouseInteractions = judgementConfig.mouseInteractions || {};
  judgementConfig.mouseInteractions[animationKey] = Boolean(enabled);
}

function renderBehaviorLinkMenu(typeKey, judgementKey, animationKey, rowIndex) {
  const wrapper = document.createElement("div");
  wrapper.className = "pet-behavior-link-control";

  const button = document.createElement("button");
  button.className = "pet-behavior-capsule config-capsule pet-behavior-link-button";
  button.type = "button";
  button.textContent = "链接过程行为";
  button.title = "编辑情形和动画的对应关系";

  const menu = document.createElement("div");
  menu.className = "behavior-link-menu";
  menu.hidden = true;

  const heading = document.createElement("div");
  heading.className = "behavior-link-menu-heading";
  heading.textContent = `宠物行为${rowIndex + 1} · 当前动画：${animationKey}`;
  menu.append(heading);

  const owner = behaviorRuleOwner(typeKey, judgementKey, rowIndex);
  const list = document.createElement("div");
  list.className = "process-rule-list";
  renderProcessBehaviorRuleList(owner, list);
  const addButton = document.createElement("button");
  addButton.className = "process-rule-add";
  addButton.type = "button";
  addButton.textContent = "+ 添加过程行为";
  addButton.addEventListener("click", () => {
    addProcessBehaviorRule(owner, animationKey);
    renderProcessBehaviorRuleList(owner, list);
    positionBehaviorLinkMenu(button, menu);
  });
  menu.append(list, addButton);

  button.addEventListener("click", (event) => {
    event.stopPropagation();
    menu.hidden = !menu.hidden;
    wrapper.classList.toggle("open", !menu.hidden);
    if (!menu.hidden) positionBehaviorLinkMenu(button, menu);
  });
  wrapper.append(button, menu);
  return wrapper;
}

function behaviorRuleOwner(typeKey, judgementKey, rowIndex) {
  return {
    type: "pet_behavior",
    interaction_type: typeKey,
    judgement: judgementKey,
    slot: rowIndex + 1,
  };
}

function ownerKey(owner) {
  return `${owner.interaction_type}.${owner.judgement}.${owner.slot}`;
}

function processRulesForOwner(owner) {
  const key = ownerKey(owner);
  return sanitizeProcessBehaviorRules(state.processBehaviorRules)
    .filter((rule) => ownerKey(rule.owner) === key);
}

function renderProcessBehaviorRuleList(owner, list) {
  const rules = processRulesForOwner(owner);
  list.replaceChildren();
  if (rules.length) {
    for (const rule of rules) list.append(renderProcessBehaviorRuleRow(rule, owner, list));
  } else {
    const empty = document.createElement("div");
    empty.className = "process-rule-empty";
    empty.textContent = "还没有链接过程行为";
    list.append(empty);
  }
}

function addProcessBehaviorRule(owner, animationKey) {
  const rule = {
    id: `process_rule_${Date.now()}_${state.processBehaviorRules.length + 1}`,
    owner: { ...owner },
    trigger: { type: "pomodoro", event: "running" },
    animation: animationKey || availableAnimationKeys()[0] || "",
  };
  state.processBehaviorRules = sanitizeProcessBehaviorRules([...state.processBehaviorRules, rule]);
  refreshReview();
  return state.processBehaviorRules[state.processBehaviorRules.length - 1];
}

function updateProcessBehaviorRule(ruleId, patch) {
  state.processBehaviorRules = sanitizeProcessBehaviorRules(state.processBehaviorRules.map((rule) => (
    rule.id === ruleId ? { ...rule, ...patch } : rule
  )));
  refreshReview();
}

function removeProcessBehaviorRule(ruleId) {
  state.processBehaviorRules = state.processBehaviorRules.filter((rule) => rule.id !== ruleId);
  refreshReview();
}

function renderProcessBehaviorRuleRow(rule, owner, list) {
  const row = document.createElement("div");
  row.className = "process-rule-row";
  const trigger = renderProcessTriggerSelect(rule);
  const params = renderProcessTriggerParams(rule);
  const animation = renderProcessAnimationSelect(rule);
  const remove = document.createElement("button");
  remove.className = "process-rule-remove";
  remove.type = "button";
  remove.textContent = "×";
  remove.title = "删除过程行为";
  remove.addEventListener("click", () => {
    removeProcessBehaviorRule(rule.id);
    renderProcessBehaviorRuleList(owner, list);
  });
  row.append(trigger, params, animation, remove);
  return row;
}

function renderProcessTriggerSelect(rule) {
  const select = document.createElement("select");
  select.className = "process-rule-trigger";
  for (const [value, label] of [
    ["pomodoro", "番茄钟"],
    ["app_open", "应用打开"],
    ["app_foreground", "前台应用"],
    ["app_leave", "离开应用"],
    ["manual_test", "手动测试"],
  ]) {
    const option = document.createElement("option");
    option.value = value;
    option.textContent = label;
    select.append(option);
  }
  select.value = rule.trigger.type;
  select.addEventListener("change", () => {
    updateProcessBehaviorRule(rule.id, { trigger: sanitizeProcessTrigger({ type: select.value }) });
  });
  return select;
}

function renderProcessTriggerParams(rule) {
  const select = document.createElement("select");
  select.className = "process-rule-param";
  const trigger = sanitizeProcessTrigger(rule.trigger);
  if (trigger.type === "pomodoro") {
    for (const [value, label] of [["start", "开始"], ["running", "进行中"], ["finish", "完成"]]) {
      const option = document.createElement("option");
      option.value = value;
      option.textContent = label;
      select.append(option);
    }
    select.value = trigger.event;
    select.addEventListener("change", () => updateProcessBehaviorRule(rule.id, { trigger: { type: "pomodoro", event: select.value } }));
  } else if (trigger.type === "manual_test") {
    const option = document.createElement("option");
    option.value = "manual_test";
    option.textContent = "桌面测试";
    select.append(option);
    select.disabled = true;
  } else {
    for (const app of core.APP_REACTION_KEYS) {
      const option = document.createElement("option");
      option.value = app;
      option.textContent = judgementLabel("application_interaction", app);
      select.append(option);
    }
    select.value = trigger.app;
    select.addEventListener("change", () => updateProcessBehaviorRule(rule.id, { trigger: { type: trigger.type, app: select.value } }));
  }
  return select;
}

function renderProcessAnimationSelect(rule) {
  const select = document.createElement("select");
  select.className = "process-rule-animation";
  for (const key of availableAnimationKeys()) {
    const option = document.createElement("option");
    option.value = key;
    option.textContent = key;
    select.append(option);
  }
  select.value = rule.animation;
  select.addEventListener("change", () => updateProcessBehaviorRule(rule.id, { animation: select.value }));
  return select;
}

function positionBehaviorLinkMenu(button, menu) {
  const rect = button.getBoundingClientRect();
  const menuWidth = Math.min(320, Math.max(220, window.innerWidth - 32));
  const left = Math.max(16, Math.min(window.innerWidth - menuWidth - 16, rect.left));
  const estimatedHeight = Math.min(360, Math.max(240, window.innerHeight - 32));
  const belowTop = rect.bottom + 6;
  const aboveTop = rect.top - estimatedHeight - 6;
  const top = belowTop + estimatedHeight <= window.innerHeight - 16
    ? belowTop
    : Math.max(16, aboveTop);
  menu.style.width = `${menuWidth}px`;
  menu.style.left = `${left}px`;
  menu.style.top = `${top}px`;
}

function ensureWindowMovementConfig(judgementConfig, animationKey, judgementKey = "") {
  judgementConfig.movement = judgementConfig.movement || {};
  const current = judgementConfig.movement[animationKey];
  const defaultMode = core.defaultMovementModeForJudgement?.("idle_interaction", judgementKey)
    || (judgementKey === "wander" ? "random_wander" : "directed");
  if (!current || typeof current !== "object") {
    judgementConfig.movement[animationKey] = {
      enabled: false,
      mode: defaultMode,
      direction: "random",
      speed: 5,
    };
  } else {
    current.enabled = Boolean(current.enabled);
    if (current.mode === "auto" || current.mode === "manual") {
      current.mode = "directed";
    } else if (!core.MOVEMENT_MODES?.includes(current.mode)) {
      current.mode = defaultMode;
    }
    if (!core.MOVEMENT_DIRECTIONS?.includes(current.direction)) current.direction = "random";
    current.speed = clampMovementSpeed(
      current.speed !== undefined
        ? current.speed
        : (current.speed_px_per_sec !== undefined ? Number(current.speed_px_per_sec) / 20 : 5),
    );
    delete current.speed_px_per_sec;
  }
  return judgementConfig.movement[animationKey];
}

function clampMovementSpeed(value, fallback = 5) {
  const limits = core.MOVEMENT_SPEED_LIMITS || { min: 1, max: 2000 };
  const number = Number(value);
  const safe = Number.isFinite(number) ? number : fallback;
  return Math.max(limits.min, Math.min(limits.max, Math.round(safe)));
}

function renderWindowMovementControls(judgementConfig, animationKey, judgementKey = "") {
  const movementConfig = ensureWindowMovementConfig(judgementConfig, animationKey, judgementKey);
  const wrapper = document.createElement("div");
  wrapper.className = "behavior-control-field window-movement-control";

  const enableLabel = document.createElement("label");
  enableLabel.className = "window-movement-toggle";
  const enabledInput = document.createElement("input");
  enabledInput.type = "checkbox";
  enabledInput.checked = Boolean(movementConfig.enabled);
  enableLabel.append(enabledInput, Object.assign(document.createElement("span"), { textContent: "位移" }));

  const renderPicker = (className, options, current, onPick) => {
    const picker = document.createElement("div");
    picker.className = `animation-picker ${className}`;
    const button = document.createElement("button");
    button.className = "animation-picker-button";
    button.type = "button";
    button.setAttribute("aria-haspopup", "listbox");
    button.setAttribute("aria-expanded", "false");
    const menu = document.createElement("div");
    menu.className = "animation-picker-menu";
    menu.setAttribute("role", "listbox");
    menu.hidden = true;
    const labelFor = () => options.find(([value]) => value === current())?.[1] || options[0]?.[1] || "";
    const close = () => {
      picker.classList.remove("open");
      menu.hidden = true;
      button.setAttribute("aria-expanded", "false");
    };
    button.textContent = labelFor();
    for (const [value, label] of options) {
      const option = document.createElement("button");
      option.className = "animation-picker-option";
      option.type = "button";
      option.setAttribute("role", "option");
      option.setAttribute("aria-selected", String(value === current()));
      option.textContent = label;
      option.addEventListener("click", () => {
        onPick(value);
        button.textContent = labelFor();
        for (const item of menu.querySelectorAll(".animation-picker-option")) {
          item.setAttribute("aria-selected", String(item.textContent === labelFor()));
        }
        close();
        syncDisabled();
        refreshReview();
      });
      menu.append(option);
    }
    button.addEventListener("click", () => {
      const open = picker.classList.toggle("open");
      menu.hidden = !open;
      button.setAttribute("aria-expanded", String(open));
    });
    button.addEventListener("blur", () => {
      window.setTimeout(() => {
        if (!picker.contains(document.activeElement)) close();
      }, 80);
    });
    picker.append(button, menu);
    return { picker, button };
  };

  const modeOptions = judgementKey === "wander"
    ? [["random_wander", "随机闲逛"], ["straight_wander", "直线闲逛"]]
    : [["directed", "指定方向"]];
  if (!modeOptions.some(([value]) => value === movementConfig.mode)) {
    movementConfig.mode = modeOptions[0][0];
  }

  const modeField = document.createElement("label");
  modeField.className = "window-movement-mode";
  modeField.append(Object.assign(document.createElement("span"), { className: "slot-note", textContent: "模式" }));
  const modePicker = renderPicker(
    "movement-mode-picker",
    modeOptions,
    () => movementConfig.mode,
    (value) => { movementConfig.mode = value; },
  );
  modeField.append(modePicker.picker);

  const directionField = document.createElement("label");
  directionField.className = "window-movement-mode movement-direction";
  directionField.append(Object.assign(document.createElement("span"), { className: "slot-note", textContent: "方向" }));
  const directionPicker = renderPicker(
    "movement-direction-picker",
    [["random", "随机"], ["left", "向左"], ["right", "向右"], ["up", "向上"], ["down", "向下"]],
    () => movementConfig.direction,
    (value) => { movementConfig.direction = value; },
  );
  directionField.append(directionPicker.picker);

  const speedField = document.createElement("label");
  speedField.className = "window-movement-speed";
  speedField.append(Object.assign(document.createElement("span"), { className: "slot-note", textContent: "速度" }));
  const speedInput = document.createElement("input");
  speedInput.type = "number";
  speedInput.min = String((core.MOVEMENT_SPEED_LIMITS || { min: 1 }).min);
  speedInput.max = String((core.MOVEMENT_SPEED_LIMITS || { max: 2000 }).max);
  speedInput.inputMode = "numeric";
  speedInput.value = movementConfig.speed ?? 5;
  speedInput.dataset.field = "speed";
  speedField.append(speedInput);

  const syncDisabled = () => {
    const enabled = Boolean(movementConfig.enabled);
    modePicker.button.disabled = !enabled || modeOptions.length <= 1;
    directionPicker.button.disabled = !enabled;
    speedInput.disabled = !enabled;
    speedField.classList.toggle("is-disabled", !enabled);
    wrapper.classList.toggle("is-disabled", !enabled);
  };

  enabledInput.addEventListener("change", () => {
    movementConfig.enabled = enabledInput.checked;
    syncDisabled();
    refreshReview();
  });
  speedInput.addEventListener("input", (event) => {
    movementConfig.speed = clampMovementSpeed(event.target.value);
    refreshReview();
  });
  speedInput.addEventListener("change", () => {
    speedInput.value = movementConfig.speed ?? 5;
  });

  syncDisabled();
  wrapper.append(enableLabel, modeField, directionField, speedField);
  return wrapper;
}

function renderDistancePicker(judgementConfig, animationKey) {
  const options = [
    ["near", "近距离"],
    ["far", "远距离"],
  ];
  const current = judgementConfig.distances?.[animationKey] === "far" ? "far" : "near";
  const picker = document.createElement("div");
  picker.className = "animation-picker distance-picker";
  const button = document.createElement("button");
  button.className = "animation-picker-button";
  button.type = "button";
  button.setAttribute("aria-label", "距离");
  button.setAttribute("aria-haspopup", "listbox");
  button.setAttribute("aria-expanded", "false");
  button.textContent = options.find(([value]) => value === current)?.[1] || "近距离";

  const menu = document.createElement("div");
  menu.className = "animation-picker-menu";
  menu.setAttribute("role", "listbox");
  menu.hidden = true;

  const closeMenu = () => {
    picker.classList.remove("open");
    menu.hidden = true;
    button.setAttribute("aria-expanded", "false");
  };

  for (const [value, label] of options) {
    const option = document.createElement("button");
    option.className = "animation-picker-option";
    option.type = "button";
    option.setAttribute("role", "option");
    option.setAttribute("aria-selected", String(value === current));
    option.textContent = label;
    option.addEventListener("click", () => {
      judgementConfig.distances = judgementConfig.distances || {};
      judgementConfig.distances[animationKey] = value;
      closeMenu();
      renderBehaviorEditor({ preserveScroll: true });
      refreshReview();
    });
    menu.append(option);
  }

  button.addEventListener("click", () => {
    const open = picker.classList.toggle("open");
    menu.hidden = !open;
    button.setAttribute("aria-expanded", String(open));
  });
  button.addEventListener("blur", () => {
    window.setTimeout(() => {
      if (!picker.contains(document.activeElement)) closeMenu();
    }, 80);
  });

  picker.append(button, menu);
  return picker;
}

function renderAnimationPicker(typeKey, judgementKey, animationKey, rowIndex, keys) {
  const picker = document.createElement("div");
  picker.className = "animation-picker";

  const button = document.createElement("button");
  button.className = "animation-picker-button";
  button.type = "button";
  button.setAttribute("aria-label", `宠物行为${rowIndex + 1}动画`);
  button.setAttribute("aria-haspopup", "listbox");
  button.setAttribute("aria-expanded", "false");
  button.textContent = animationKey;

  const menu = document.createElement("div");
  menu.className = "animation-picker-menu";
  menu.setAttribute("role", "listbox");
  menu.hidden = true;

  const closeMenu = () => {
    picker.classList.remove("open");
    menu.hidden = true;
    button.setAttribute("aria-expanded", "false");
  };

  keys.forEach((key) => {
    const option = document.createElement("button");
    option.className = "animation-picker-option";
    option.type = "button";
    option.setAttribute("role", "option");
    option.setAttribute("aria-selected", String(key === animationKey));
    option.textContent = key;
    option.addEventListener("click", () => {
      closeMenu();
      replaceJudgementAnimation(typeKey, judgementKey, animationKey, key);
    });
    menu.append(option);
  });

  button.addEventListener("click", () => {
    const open = picker.classList.toggle("open");
    menu.hidden = !open;
    button.setAttribute("aria-expanded", String(open));
  });

  button.addEventListener("blur", () => {
    window.setTimeout(() => {
      if (!picker.contains(document.activeElement)) closeMenu();
    }, 80);
  });

  picker.append(button, menu);
  return picker;
}

function renderAddBehaviorRow(typeKey, judgementKey, keys, selected, developerMaxJudgement = false) {
  const row = document.createElement("div");
  row.className = "pet-behavior-row add-row";
  const availableKeys = keys.filter((key) => !selected.includes(key));
  const nextKey = availableKeys[0];
  const lockedForMax = developerMaxJudgement && !state.account.developerMax;
  const button = document.createElement("button");
  button.type = "button";
  button.className = `button secondary-button add-behavior-action${!nextKey && !lockedForMax ? " add-behavior-empty-label" : ""}`;
  button.textContent = lockedForMax ? "升级 MAX 后可配置" : (nextKey ? "+ 从动画库添加" : "暂无可用动画");
  button.disabled = lockedForMax || !nextKey;
  button.title = lockedForMax
    ? "该判定为 MAX 专享"
    : (nextKey ? `添加 ${nextKey}` : "请先在动画库创建可用动画");
  button.addEventListener("click", () => {
    if (nextKey && !lockedForMax) updateJudgementAnimation(typeKey, judgementKey, nextKey, true);
  });
  const createButton = document.createElement("button");
  createButton.type = "button";
  createButton.className = "button primary-button add-behavior-action create-and-bind-action create-and-bind-icon-action";
  createButton.textContent = "+";
  createButton.disabled = lockedForMax;
  createButton.setAttribute("aria-label", lockedForMax ? "该判定为 MAX 专享，无法新建动画" : "新建动画并绑定到这里");
  createButton.title = lockedForMax ? "该判定为 MAX 专享" : "跳到动画上传，保存后自动绑定到当前判定";
  createButton.addEventListener("click", () => {
    if (!lockedForMax) startAnimationDraftForJudgement(typeKey, judgementKey, selected.length);
  });
  row.append(button, createButton);
  return row;
}

function bindAnimationToJudgement(typeKey, judgementKey, animationKey) {
  const target = typeKey === "application_interaction" ? state.appReactions : state.behaviorBindings;
  const list = normalizeList(target[judgementKey]);
  target[judgementKey] = [...new Set([...list, animationKey])];
}

function startAnimationDraftForJudgement(typeKey, judgementKey, rowIndex = 0) {
  state.pendingBehaviorTarget = { typeKey, judgementKey, rowIndex };
  if (!state.draftAnimation) {
    state.draftAnimation = defaultAnimation("", false, Number(els.currentAnimationFps?.value) || 8);
  }
  state.selectedAnimationKey = "";
  state.selectedFrames[state.draftAnimation.key || "__new__"] = null;
  syncMainPreviewToAnimation(DRAFT_PREVIEW_KEY, { kind: "draft" });
  renderAllAnimationCards();
  refreshReview();
  scrollToStepHash("#bodyAnimations");
}

function updateJudgementAnimation(typeKey, judgementKey, animationKey, checked) {
  const target = typeKey === "application_interaction" ? state.appReactions : state.behaviorBindings;
  if (checked) {
    bindAnimationToJudgement(typeKey, judgementKey, animationKey);
  } else {
    const list = normalizeList(target[judgementKey]);
    target[judgementKey] = list.filter((name) => name !== animationKey);
  }
  syncJudgementProbabilities(typeKey, judgementKey);
  renderBehaviorEditor({ preserveScroll: true });
  refreshReview();
}

function selectedAnimationsForJudgement(typeKey, judgementKey) {
  const target = typeKey === "application_interaction" ? state.appReactions : state.behaviorBindings;
  return normalizeList(target[judgementKey]);
}

function syncJudgementProbabilities(typeKey, judgementKey) {
  const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
  const selected = selectedAnimationsForJudgement(typeKey, judgementKey);
  judgementConfig.weights = normalizeBehaviorProbabilities(selected, judgementConfig.weights || {});
  return judgementConfig.weights;
}

function replaceJudgementAnimation(typeKey, judgementKey, oldKey, nextKey) {
  if (!nextKey || oldKey === nextKey) return;
  const target = typeKey === "application_interaction" ? state.appReactions : state.behaviorBindings;
  const list = normalizeList(target[judgementKey]);
  target[judgementKey] = [...new Set(list.map((name) => (name === oldKey ? nextKey : name)))];
  const judgementConfig = ensureInteractionConfig(typeKey, judgementKey);
  if (judgementConfig.weights?.[oldKey] !== undefined) {
    if (judgementConfig.weights[nextKey] === undefined) judgementConfig.weights[nextKey] = judgementConfig.weights[oldKey];
    delete judgementConfig.weights[oldKey];
  }
  if (judgementConfig.cooldowns?.[oldKey] !== undefined) {
    if (judgementConfig.cooldowns[nextKey] === undefined) judgementConfig.cooldowns[nextKey] = judgementConfig.cooldowns[oldKey];
    delete judgementConfig.cooldowns[oldKey];
  }
  if (judgementConfig.distances?.[oldKey] !== undefined) {
    if (judgementConfig.distances[nextKey] === undefined) judgementConfig.distances[nextKey] = judgementConfig.distances[oldKey];
    delete judgementConfig.distances[oldKey];
  }
  if (judgementConfig.leaveWake?.[oldKey] !== undefined) {
    if (judgementConfig.leaveWake[nextKey] === undefined) judgementConfig.leaveWake[nextKey] = judgementConfig.leaveWake[oldKey];
    delete judgementConfig.leaveWake[oldKey];
  }
  if (judgementConfig.movement?.[oldKey] !== undefined) {
    if (judgementConfig.movement[nextKey] === undefined) judgementConfig.movement[nextKey] = judgementConfig.movement[oldKey];
    delete judgementConfig.movement[oldKey];
  }
  if (judgementConfig.mouseInteractions?.[oldKey] !== undefined) {
    if (judgementConfig.mouseInteractions[nextKey] === undefined) judgementConfig.mouseInteractions[nextKey] = judgementConfig.mouseInteractions[oldKey];
    delete judgementConfig.mouseInteractions[oldKey];
  }
  syncJudgementProbabilities(typeKey, judgementKey);
  renderBehaviorEditor({ preserveScroll: true });
  refreshReview();
}

function normalizeList(value) {
  if (Array.isArray(value)) return value.filter(Boolean);
  return value ? [value] : [];
}

function clampPercent(value, min = 0) {
  const number = Number(value);
  if (!Number.isFinite(number)) return min;
  return Math.min(100, Math.max(min, Math.round(number)));
}

function uniqueBehaviorKeys(animationKeys) {
  return [...new Set((animationKeys || []).filter((key) => typeof key === "string" && key))];
}

function distributeBehaviorProbabilities(animationKeys, weights = {}, totalPercent = 100) {
  const keys = uniqueBehaviorKeys(animationKeys);
  if (!keys.length) return {};
  const total = Math.max(keys.length, Math.round(Number(totalPercent) || 0));
  if (keys.length === 1) return { [keys[0]]: total };

  const raw = keys.map((key) => Math.max(0, Number(weights?.[key]) || 0));
  const rawTotal = raw.reduce((sum, value) => sum + value, 0);
  const base = rawTotal > 0 ? raw : keys.map(() => 1);
  const baseTotal = base.reduce((sum, value) => sum + value, 0);
  const exact = base.map((value) => (value / baseTotal) * total);
  const out = Object.fromEntries(keys.map((key, index) => [key, Math.max(1, Math.floor(exact[index]))]));
  let remaining = total - Object.values(out).reduce((sum, value) => sum + value, 0);
  const fractionalOrder = exact
    .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
    .sort((a, b) => b.fraction - a.fraction);

  for (let index = 0; remaining > 0 && fractionalOrder.length; index = (index + 1) % fractionalOrder.length) {
    out[keys[fractionalOrder[index].index]] += 1;
    remaining -= 1;
  }
  while (remaining < 0) {
    const key = keys.find((candidate) => out[candidate] > 1);
    if (!key) break;
    out[key] -= 1;
    remaining += 1;
  }
  return out;
}

function normalizeBehaviorProbabilities(animationKeys, weights = {}) {
  return distributeBehaviorProbabilities(animationKeys, weights, 100);
}

function rebalanceBehaviorProbability(animationKeys, weights, changedKey, nextValue) {
  const keys = uniqueBehaviorKeys(animationKeys);
  if (!keys.includes(changedKey)) return normalizeBehaviorProbabilities(keys, weights);
  if (keys.length <= 1) return normalizeBehaviorProbabilities(keys, weights);

  const siblingCount = keys.length - 1;
  const changedValue = Math.max(1, Math.min(100 - siblingCount, Math.round(Number(nextValue) || 1)));
  const siblings = keys.filter((key) => key !== changedKey);
  const siblingWeights = Object.fromEntries(siblings.map((key) => [key, Math.max(0, Number(weights?.[key]) || 0)]));
  const remaining = 100 - changedValue;

  return {
    [changedKey]: changedValue,
    ...distributeBehaviorProbabilities(siblings, siblingWeights, remaining),
  };
}

function clampCooldown(value, fallback = 0) {
  const limits = core.COOLDOWN_LIMITS || { min: 0, max: 86400 };
  const number = Number(value);
  if (!Number.isFinite(number)) return fallback;
  return Math.min(limits.max, Math.max(limits.min, Math.round(number)));
}

function renderBehaviorBindingChoices(container, animationKey) {
  for (const type of core.INTERACTION_TYPES.filter((item) => item.key !== "application_interaction")) {
    const group = document.createElement("section");
    group.className = "binding-group";
    const heading = document.createElement("strong");
    heading.textContent = type.label;
    group.append(heading);

    const options = document.createElement("div");
    options.className = "binding-options";
    for (const [key, labelText] of type.judgements) {
      const label = document.createElement("label");
      const input = document.createElement("input");
      input.type = "checkbox";
      input.value = key;
      input.checked = (state.behaviorBindings[key] || []).includes(animationKey);
      input.addEventListener("change", () => updateBehaviorBinding(animationKey, key, input.checked));
      label.append(input, labelText);
      options.append(label);
    }
    group.append(options);
    container.append(group);
  }
}

async function readImageFile(file) {
  const data = new Uint8Array(await file.arrayBuffer());
  const url = URL.createObjectURL(new Blob([data], { type: file.type || "image/png" }));
  const image = await loadImage(url);
  return { name: file.name, width: image.naturalWidth, height: image.naturalHeight, bytes: data, url, image };
}

async function readVideoFile(file) {
  if (!file) return null;
  if (!String(file.name || "").toLowerCase().endsWith(".webm")) {
    throw new Error("只能上传 .webm 动画文件。");
  }
  const data = new Uint8Array(await file.arrayBuffer());
  const url = URL.createObjectURL(new Blob([data], { type: file.type || "video/webm" }));
  const metadata = await loadVideoMetadata(url);
  return {
    name: file.name,
    width: metadata.width,
    height: metadata.height,
    durationSeconds: metadata.durationSeconds,
    bytes: data,
    url,
  };
}

function loadVideoMetadata(url) {
  return new Promise((resolve, reject) => {
    const video = document.createElement("video");
    video.preload = "metadata";
    video.muted = true;
    video.onloadedmetadata = () => {
      resolve({
        width: video.videoWidth || 0,
        height: video.videoHeight || 0,
        durationSeconds: Number.isFinite(video.duration) ? video.duration : 0,
      });
    };
    video.onerror = () => reject(new Error("WebM 元数据读取失败。"));
    video.src = url;
  });
}

function loadImage(url) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = reject;
    image.src = url;
  });
}

function renderFrames(card, animation) {
  const strip = card.querySelector(".frame-strip");
  const note = card.querySelector(".card-note");
  const preview = card.querySelector(".anim-preview");
  const pill = card.querySelector(".state-pill");
  strip.replaceChildren();

  if (core.isVideoAnimationAsset?.(animation)) {
    appendVideoThumb(strip, animation.key, animation.video);
    note.textContent = videoSummary(animation.video);
    playPreview(preview, animation);
    updateStatePill(pill, animation);
    updateCardUploadState(card, animation);
    updateEntitlementControls();
    return;
  }

  if (animation.sheet) {
    const img = document.createElement("img");
    img.className = "frame-thumb selected";
    img.src = animation.sheet.url;
    img.alt = `${animation.key} sprite sheet`;
    strip.append(img);
    const sheet = core.inferSheet(animation.sheet);
    note.textContent = sheet.errors.length
      ? sheet.errors.join(" ")
      : `sheet ${animation.sheet.width} x ${animation.sheet.height}, ${sheet.frames} 帧。`;
    drawAnimationFrame(preview, animation, 0);
    updateStatePill(pill, animation);
    updateCardUploadState(card, animation);
    return;
  }

  animation.frames.forEach((frame, index) => {
    const img = document.createElement("img");
    img.className = "frame-thumb";
    if (state.selectedFrames[animation.key] === index) img.classList.add("selected");
    img.src = frame.url;
    img.alt = `${animation.key} 第 ${index + 1} 帧`;
    img.title = `${index + 1}. ${frame.name}`;
    img.addEventListener("click", () => {
      state.selectedFrames[animation.key] = index;
      renderFrames(card, animation);
    });
    strip.append(img);
  });
  note.textContent = animation.frames.length
    ? `${animation.frames.length} 帧，${frameDimensionText(animation)}，${state.account.loggedIn ? "点击缩略图后可调整顺序。" : "登录后可在线调整帧顺序。"}`
    : "等待上传素材。";
  playPreview(preview, animation);
  updateStatePill(pill, animation);
  updateCardUploadState(card, animation);
  updateEntitlementControls();
}

function updateCardUploadState(card, animation) {
  const count = animationFrameCount(animation);
  card.classList.toggle("has-frames", count > 0);
  card.classList.toggle("missing-frames", count === 0);
}

function frameDimensionText(animation) {
  if (core.isLoopAnimation?.(animation)) {
    const loopSegment = ensureLoopSegments(animation).loop;
    if (core.isVideoAnimationAsset?.(loopSegment)) return videoSummary(loopSegment.video);
    if (loopSegment.frames?.length) {
      const size = core.inferFrameSize(loopSegment.frames);
      if (size.frameWidth && size.frameHeight) return `${size.frameWidth} x ${size.frameHeight}`;
    }
    if (loopSegment.sheet) return `${loopSegment.sheet.width} x ${loopSegment.sheet.height} sheet`;
  }
  if (core.isVideoAnimationAsset?.(animation)) return videoSummary(animation.video);
  if (animation.frames?.length) {
    const size = core.inferFrameSize(animation.frames);
    if (size.frameWidth && size.frameHeight) return `${size.frameWidth} x ${size.frameHeight}`;
  }
  if (animation.sheet) return `${animation.sheet.width} x ${animation.sheet.height} sheet`;
  return "等待尺寸";
}

function currentFrameActionTarget(animation) {
  if (!core.isLoopAnimation?.(animation)) return animation;
  const selected = state.selectedFrames[animation.key];
  const segment = selected?.segment || "loop";
  return ensureLoopSegments(animation)[segment];
}

function clearSelectedFrameForTarget(animation, target) {
  if (!core.isLoopAnimation?.(animation)) {
    state.selectedFrames[animation.key] = null;
    return;
  }
  for (const segment of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
    if (ensureLoopSegments(animation)[segment] === target) {
      state.selectedFrames[selectedFrameBucket(animation, segment)] = null;
      state.selectedFrames[animation.key] = null;
      return;
    }
  }
}

function moveSelectedFrameTarget(animation, action) {
  const target = currentFrameActionTarget(animation);
  if (!target) return;
  if (!core.isLoopAnimation?.(animation)) {
    moveSelectedFrame(animation, action);
    return;
  }
  const selected = state.selectedFrames[animation.key];
  const segment = selected?.segment || "loop";
  const index = state.selectedFrames[selectedFrameBucket(animation, segment)];
  moveSelectedFrame(target, action, index, (next) => {
    state.selectedFrames[selectedFrameBucket(animation, segment)] = next;
    state.selectedFrames[animation.key] = next === null ? null : { segment, index: next };
  });
}

function moveSelectedFrame(animation, action, forcedIndex = undefined, setSelected = null) {
  const index = forcedIndex === undefined ? state.selectedFrames[animation.key] : forcedIndex;
  const updateSelected = setSelected || ((next) => { state.selectedFrames[animation.key] = next; });
  if ((action === "left" || action === "right") && !state.account.loggedIn) {
    alert("登录后可在线调整帧顺序。");
    return;
  }
  if (action === "remove") {
    if (index === null || index === undefined || animation.sheet) return;
    animation.frames.splice(index, 1);
    updateSelected(null);
    return;
  }
  if (index === null || index === undefined || animation.sheet) return;
  const next = action === "left" ? index - 1 : index + 1;
  if (next < 0 || next >= animation.frames.length) return;
  const [frame] = animation.frames.splice(index, 1);
  animation.frames.splice(next, 0, frame);
  updateSelected(next);
}

function playPreview(canvas, animation) {
  clearInterval(state.timers[animation.key]);
  clearCanvas(canvas);
  const count = animationFrameCount(animation);
  if (!count) return;
  if (core.isVideoAnimationAsset?.(animation)) {
    playVideoPreview(canvas, animation);
    return;
  }
  let index = 0;
  const draw = () => {
    drawAnimationFrame(canvas, animation, index);
    index = (index + 1) % count;
  };
  draw();
  state.timers[animation.key] = setInterval(draw, Math.max(40, 1000 / (animation.fps || 8)));
}

function playVideoPreview(canvas, animation) {
  const videoMeta = animation.video;
  if (!videoMeta?.url) return;
  const video = document.createElement("video");
  video.src = videoMeta.url;
  video.muted = true;
  video.loop = true;
  video.playsInline = true;
  video.play?.().catch(() => {});
  const draw = () => {
    if (video.readyState >= 2) drawVideoFrame(canvas, video, videoMeta.width, videoMeta.height);
  };
  draw();
  state.timers[animation.key] = setInterval(draw, 33);
}

function drawStill(canvas, image) {
  const { ctx, width, height } = clearCanvas(canvas);
  drawImageContained(ctx, image, image.naturalWidth || image.width, image.naturalHeight || image.height, width, height);
}

function drawVideoFrame(canvas, video, sourceWidth, sourceHeight) {
  const { ctx, width, height } = clearCanvas(canvas);
  drawImageContained(ctx, video, sourceWidth || video.videoWidth || 1, sourceHeight || video.videoHeight || 1, width, height);
}

function drawWebmBadge(canvas) {
  const { ctx, width, height } = clearCanvas(canvas);
  ctx.fillStyle = "#f3f5ff";
  ctx.fillRect(0, 0, width, height);
  ctx.fillStyle = "#3f335f";
  ctx.font = "700 13px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("WebM", width / 2, height / 2);
}

function clearCanvas(canvas) {
  const prepared = prepareCanvas(canvas);
  prepared.ctx.clearRect(0, 0, prepared.width, prepared.height);
  return prepared;
}

function drawAnimationFrame(canvas, animation, frameIndex) {
  const { ctx, width, height } = clearCanvas(canvas);

  if (core.isLoopAnimation?.(animation)) {
    const segments = ensureLoopSegments(animation);
    const loopSegment = segments.loop;
    drawAnimationFrame(canvas, { ...loopSegment, key: animation.key, fps: animation.fps }, frameIndex);
    return;
  }

  if (core.isVideoAnimationAsset?.(animation)) {
    if (animation.video?.previewElement) {
      drawVideoFrame(canvas, animation.video.previewElement, animation.video.width, animation.video.height);
    } else {
      drawWebmBadge(canvas);
    }
    return;
  }

  if (animation.frames.length) {
    const frame = animation.frames[frameIndex % animation.frames.length];
    drawImageContained(ctx, frame.image, frame.width, frame.height, width, height);
    return;
  }

  if (animation.sheet) {
    const sheet = core.inferSheet(animation.sheet);
    if (sheet.errors.length || !sheet.frameSize) return;
    const sourceWidth = sheet.frameWidth;
    const sourceHeight = sheet.frameHeight;
    const sourceX = (frameIndex % sheet.frames) * sourceWidth;
    const rect = containedRect(sourceWidth, sourceHeight, width, height);
    configureSmoothing(ctx, sourceWidth, sourceHeight, rect.width, rect.height);
    ctx.drawImage(animation.sheet.image, sourceX, 0, sourceWidth, sourceHeight, rect.left, rect.top, rect.width, rect.height);
  }
}

function prepareCanvas(canvas) {
  const storedWidth = Number(canvas.dataset.cssWidth) || Number(canvas.getAttribute("width")) || canvas.width || 1;
  const storedHeight = Number(canvas.dataset.cssHeight) || Number(canvas.getAttribute("height")) || canvas.height || 1;
  const rect = canvas.isConnected ? canvas.getBoundingClientRect() : { width: 0, height: 0 };
  const cssWidth = Math.max(1, Math.round(rect.width || storedWidth));
  const cssHeight = Math.max(1, Math.round(rect.height || storedHeight));
  const ratio = canvas.isConnected ? Math.max(1, Math.min(3, window.devicePixelRatio || 1)) : 1;
  const pixelWidth = Math.round(cssWidth * ratio);
  const pixelHeight = Math.round(cssHeight * ratio);

  canvas.dataset.cssWidth = String(cssWidth);
  canvas.dataset.cssHeight = String(cssHeight);
  if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
    canvas.width = pixelWidth;
    canvas.height = pixelHeight;
  }

  const ctx = canvas.getContext("2d");
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
  return { ctx, width: cssWidth, height: cssHeight };
}

function containedRect(sourceWidth, sourceHeight, boxWidth, boxHeight, fill = 0.86) {
  const safeSourceWidth = Math.max(1, sourceWidth || 1);
  const safeSourceHeight = Math.max(1, sourceHeight || 1);
  const maxWidth = boxWidth * fill;
  const maxHeight = boxHeight * fill;
  const scale = Math.min(maxWidth / safeSourceWidth, maxHeight / safeSourceHeight);
  const width = Math.max(1, safeSourceWidth * scale);
  const height = Math.max(1, safeSourceHeight * scale);
  return {
    left: (boxWidth - width) / 2,
    top: (boxHeight - height) / 2,
    width,
    height,
  };
}

function configureSmoothing(ctx, sourceWidth, sourceHeight, targetWidth, targetHeight) {
  const upscaling = targetWidth > sourceWidth || targetHeight > sourceHeight;
  ctx.imageSmoothingEnabled = !upscaling;
  if (ctx.imageSmoothingEnabled) ctx.imageSmoothingQuality = "high";
}

function drawImageContained(ctx, image, sourceWidth, sourceHeight, boxWidth, boxHeight) {
  const rect = containedRect(sourceWidth, sourceHeight, boxWidth, boxHeight);
  configureSmoothing(ctx, sourceWidth, sourceHeight, rect.width, rect.height);
  ctx.drawImage(image, rect.left, rect.top, rect.width, rect.height);
}

// ---- Anchor stability (Step 3/6): measure each frame's alpha bounding box ----

// Returns the opaque-pixel bounding box of an image (or a sub-rect of a sprite
// sheet) in native source pixels, or null if the frame is fully transparent /
// unreadable. Analysis is downscaled to <=192px for speed.
function alphaBoundingBox(image, sourceWidth, sourceHeight, srcRect) {
  const w = Math.max(1, Math.round(srcRect ? srcRect.w : sourceWidth));
  const h = Math.max(1, Math.round(srcRect ? srcRect.h : sourceHeight));
  const scale = Math.min(1, 192 / Math.max(w, h));
  const cw = Math.max(1, Math.round(w * scale));
  const ch = Math.max(1, Math.round(h * scale));
  const cv = document.createElement("canvas");
  if (typeof cv.getContext !== "function") return null;
  cv.width = cw;
  cv.height = ch;
  const ctx = cv.getContext("2d", { willReadFrequently: true });
  if (!ctx) return null;
  try {
    if (srcRect) ctx.drawImage(image, srcRect.x, srcRect.y, srcRect.w, srcRect.h, 0, 0, cw, ch);
    else ctx.drawImage(image, 0, 0, cw, ch);
  } catch (_error) {
    return null;  // tainted canvas / not yet decoded
  }
  let data;
  try {
    data = ctx.getImageData(0, 0, cw, ch).data;
  } catch (_error) {
    return null;
  }
  let minX = cw;
  let minY = ch;
  let maxX = -1;
  let maxY = -1;
  for (let y = 0; y < ch; y += 1) {
    for (let x = 0; x < cw; x += 1) {
      if (data[(y * cw + x) * 4 + 3] > 12) {
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
      }
    }
  }
  if (maxX < minX || maxY < minY) return null;
  const inv = 1 / scale;
  return { x: minX * inv, y: minY * inv, w: (maxX - minX + 1) * inv, h: (maxY - minY + 1) * inv };
}

function sampleAnimationAlphaBoxes(animation) {
  if (!animation || core.isVideoAnimationAsset?.(animation)) return null;
  const target = core.isLoopAnimation?.(animation) ? ensureLoopSegments(animation).loop : animation;
  if (!target) return null;
  const boxes = [];
  let canvasWidth = 0;
  let canvasHeight = 0;
  const SAMPLE = 12;
  if (target.frames && target.frames.length) {
    const frames = target.frames.filter((frame) => frame && frame.image);
    const step = Math.max(1, Math.floor(frames.length / SAMPLE));
    for (let i = 0; i < frames.length; i += step) {
      const frame = frames[i];
      const box = alphaBoundingBox(frame.image, frame.width, frame.height);
      if (box) {
        boxes.push(box);
        canvasWidth = Math.max(canvasWidth, frame.width || 0);
        canvasHeight = Math.max(canvasHeight, frame.height || 0);
      }
    }
  } else if (target.sheet && target.sheet.image) {
    const sheet = core.inferSheet(target.sheet);
    if (sheet.errors.length || !sheet.frames) return null;
    const step = Math.max(1, Math.floor(sheet.frames / SAMPLE));
    for (let i = 0; i < sheet.frames; i += step) {
      const box = alphaBoundingBox(target.sheet.image, sheet.frameWidth, sheet.frameHeight, {
        x: i * sheet.frameWidth,
        y: 0,
        w: sheet.frameWidth,
        h: sheet.frameHeight,
      });
      if (box) {
        boxes.push(box);
        canvasWidth = Math.max(canvasWidth, sheet.frameWidth || 0);
        canvasHeight = Math.max(canvasHeight, sheet.frameHeight || 0);
      }
    }
  }
  if (!boxes.length) return null;
  return { boxes, canvasWidth, canvasHeight };
}

function measureAnimationAnchor(animation) {
  const sampled = sampleAnimationAlphaBoxes(animation);
  if (!sampled || sampled.boxes.length < 2) return null;
  return { analysis: core.analyzeAnchorStability(sampled.boxes), canvasWidth: sampled.canvasWidth };
}

function resizeHandleAnchorFromIdle(project) {
  const animations = project?.animations || {};
  const candidates = [];
  if (animations.idle) candidates.push("idle");
  for (const key of normalizeList(project?.behaviorBindings?.idle)) {
    if (animations[key] && !candidates.includes(key)) candidates.push(key);
  }
  if (!candidates.length) {
    for (const key of Object.keys(animations)) {
      if (/idle/i.test(key)) candidates.push(key);
    }
  }
  for (const key of candidates) {
    const sampled = sampleAnimationAlphaBoxes(animations[key]);
    if (!sampled || !sampled.boxes.length) continue;
    const sourceRight = Math.max(...sampled.boxes.map((box) => box.x + box.w));
    const sourceBottom = Math.max(...sampled.boxes.map((box) => box.y + box.h));
    return {
      animation: key,
      source_right: Math.max(0, Math.min(sampled.canvasWidth || sourceRight, Math.round(sourceRight))),
      source_bottom: Math.max(0, Math.min(sampled.canvasHeight || sourceBottom, Math.round(sourceBottom))),
    };
  }
  return null;
}

function resizeHandleAnchorFromAnchors(project) {
  const animations = project?.animations || {};
  const candidates = [];
  if (animations.idle) candidates.push("idle");
  for (const key of normalizeList(project?.behaviorBindings?.idle)) {
    if (animations[key] && !candidates.includes(key)) candidates.push(key);
  }
  for (const key of Object.keys(animations)) {
    if (!candidates.includes(key)) candidates.push(key);
  }
  for (const key of candidates) {
    const anchors = cloneAnimationAnchors(animations[key]?.anchors);
    if (!anchors?.points?.scale) continue;
    return {
      animation: key,
      source_right: anchors.points.scale.x,
      source_bottom: anchors.points.scale.y,
    };
  }
  return null;
}

function cloneAnimationWithAnchors(animation) {
  if (!animation) return animation;
  const cloned = {
    ...animation,
    frames: [...(animation.frames || [])],
    sheet: animation.sheet || null,
    video: animation.video || null,
  };
  if (core.isLoopAnimation?.(animation)) {
    ensureLoopSegments(animation);
    cloned.segments = Object.fromEntries(
      (core.LOOP_SEGMENTS || ["intro", "loop", "outro"]).map((segment) => [
        segment,
        cloneAnimationSegment(animation.segments[segment]),
      ]),
    );
  }
  const anchors = cloneAnimationAnchors(animation.anchors) || estimateAnimationAnchors(animation);
  if (anchors) cloned.anchors = anchors;
  return cloned;
}

function projectWithAnimationAnchors(project) {
  return {
    ...project,
    animations: Object.fromEntries(
      Object.entries(project?.animations || {}).map(([key, animation]) => [
        key,
        cloneAnimationWithAnchors(animation),
      ]),
    ),
  };
}

function animationIsMotionBound(key) {
  for (const [judgementKey, names] of Object.entries(state.behaviorBindings || {})) {
    if (core.isMotionJudgementKey?.(judgementKey) && normalizeList(names).includes(key)) return true;
  }
  return false;
}

// Build the "锚点漂移 / 尾帧未归位" hint chip for an animation library card,
// plus the reference-line overlay drawn on its thumbnail. Returns the measured
// analysis (so the thumbnail can draw the guide) or null when stable/unmeasurable.
function buildAnchorHint(animation) {
  let measured;
  try {
    measured = measureAnimationAnchor(animation);
  } catch (_error) {
    measured = null;
  }
  if (!measured || !measured.analysis.measured) return null;
  const isMotion = animationIsMotionBound(animation.key);
  const warnings = core.anchorStabilityWarnings(measured.analysis, {
    isMotion,
    canvasWidth: measured.canvasWidth,
    label: animation.key,
  });
  if (!warnings.length) return { measured, chip: null };
  const chip = document.createElement("span");
  chip.className = "library-anchor-hint";
  chip.textContent = isMotion
    ? `⚠ 尾帧未归位 ${Math.round(measured.analysis.tailDriftX)}px`
    : `⚠ 锚点漂移 ${Math.round(measured.analysis.maxDriftX)}px`;
  chip.title = warnings[0];
  return { measured, chip };
}

// Draw a vertical reference line at the first frame's anchor on a thumbnail so
// creators can see where the character is "planted" relative to later frames.
function drawAnchorGuide(canvas, animation, analysis) {
  if (!canvas) return;
  const frame = animation.frames && animation.frames[0];
  const sheet = !frame && animation.sheet ? core.inferSheet(animation.sheet) : null;
  const sourceWidth = frame ? frame.width : (sheet ? sheet.frameWidth : 0);
  const sourceHeight = frame ? frame.height : (sheet ? sheet.frameHeight : 0);
  if (!sourceWidth || !sourceHeight) return;
  const prepared = prepareCanvas(canvas);
  const { ctx, width, height } = prepared;
  const rect = containedRect(sourceWidth, sourceHeight, width, height);
  ctx.save();
  if (analysis?.measured) {
    const guideX = rect.left + (analysis.refAnchorX / sourceWidth) * rect.width;
    ctx.strokeStyle = "rgba(244, 161, 47, 0.85)";
    ctx.lineWidth = 1;
    ctx.setLineDash([3, 2]);
    ctx.beginPath();
    ctx.moveTo(guideX, 0);
    ctx.lineTo(guideX, height);
    ctx.stroke();
    ctx.setLineDash([]);
  }
  const anchors = cloneAnimationAnchors(animation.anchors);
  if (anchors?.points) {
    for (const key of anchorPointKeys()) {
      const point = anchors.points[key];
      if (!point) continue;
      const x = rect.left + (point.x / sourceWidth) * rect.width;
      const y = rect.top + (point.y / sourceHeight) * rect.height;
      ctx.fillStyle = ANIMATION_ANCHOR_COLORS[key] || "#151a27";
      ctx.strokeStyle = "#ffffff";
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(x, y, key === "scale" ? 3.5 : 3, 0, Math.PI * 2);
      ctx.fill();
      ctx.stroke();
    }
  }
  ctx.restore();
}

function saveCurrentAnimationName() {
  const selected = state.animations[state.selectedAnimationKey];
  const selectedKey = sanitizeAnimationKey(els.currentAnimationName.value || selected?.key);
  if (!state.draftAnimation && selected && selectedKey === selected.key) {
    selected.fps = Number(els.currentAnimationFps.value) || selected.fps || 8;
    state.selectedFrames[selected.key] = state.selectedFrames[selected.key] ?? null;
    state.expandedAnimations[selected.key] = true;
    syncMainPreviewToAnimation(selected.key, { kind: "library", animationKey: selected.key });
    renderAllAnimationCards();
    refreshReview();
    return;
  }

  const animation = ensureCurrentAnimationDraft({ warn: true });
  if (!animation) return;
  const saved = {
    ...animation,
    frames: [...(animation.frames || [])],
    sheet: animation.sheet || null,
    anchors: cloneAnimationAnchors(animation.anchors) || estimateAnimationAnchors(animation) || undefined,
  };
  if (core.isLoopAnimation?.(animation)) {
    ensureLoopSegments(animation);
    saved.kind = "loop";
    saved.loop = true;
    saved.segments = Object.fromEntries(
      (core.LOOP_SEGMENTS || ["intro", "loop", "outro"]).map((segment) => [
        segment,
        cloneAnimationSegment(animation.segments[segment]),
      ]),
    );
  }
  state.animations[saved.key] = saved;
  state.draftAnimation = null;
  state.selectedAnimationKey = saved.key;
  state.selectedFrames[saved.key] = null;
  state.expandedAnimations[saved.key] = true;
  if (state.pendingBehaviorTarget) {
    const target = state.pendingBehaviorTarget;
    bindAnimationToJudgement(target.typeKey, target.judgementKey, saved.key);
    syncBehaviorPreview(target.typeKey, target.judgementKey, saved.key, target.rowIndex || 0);
    state.pendingBehaviorTarget = null;
  } else {
    syncMainPreviewToAnimation(saved.key, { kind: "library", animationKey: saved.key });
  }
  renderAllAnimationCards();
  refreshReview();
}

function currentEditableAnimationKey() {
  return state.draftAnimation?.key || state.animations[state.selectedAnimationKey]?.key || "";
}

function sanitizeAnimationKey(value) {
  return String(value || "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_]+/g, "_")
    .replace(/^_+|_+$/g, "")
    .slice(0, 32);
}

function handleAnimationNameChange() {
  const currentKey = currentEditableAnimationKey();
  const nextKey = sanitizeAnimationKey(els.currentAnimationName.value);
  if (!nextKey) {
    if (els.currentAnimationName.value.trim()) alert("动画名称只能使用小写字母、数字和下划线。");
    els.currentAnimationName.value = currentKey;
    return;
  }
  if (nextKey === currentKey) {
    els.currentAnimationName.value = nextKey;
    return;
  }
  if (!currentKey) {
    if (state.draftAnimation) {
      state.draftAnimation.key = nextKey;
      applyActiveAnimationUploadMode(state.draftAnimation);
      state.selectedAnimationKey = nextKey;
      state.selectedFrames[nextKey] = state.selectedFrames[nextKey] || null;
      state.expandedAnimations[nextKey] = true;
      els.currentAnimationName.value = nextKey;
      renderAllAnimationCards();
      refreshReview();
      return;
    }
    els.currentAnimationName.value = nextKey;
    return;
  }
  if (state.animations[nextKey]) {
    alert(`动画 ${nextKey} 已存在，请从动画库选择它，或换一个新名字。`);
    els.currentAnimationName.value = currentKey;
    return;
  }

  const shouldCreate = confirm(`要新建动画 ${nextKey} 吗？当前帧顺序编辑区会清空，原动画仍保留在动画库。`);
  if (!shouldCreate) {
    els.currentAnimationName.value = currentKey;
    renderUnifiedAnimationEditor();
    return;
  }

  const source = state.draftAnimation || state.animations[state.selectedAnimationKey];
  const fps = Number(els.currentAnimationFps.value) || source?.fps || 8;
  state.draftAnimation = defaultAnimation(nextKey, false, fps);
  applyActiveAnimationUploadMode(state.draftAnimation);
  state.selectedAnimationKey = nextKey;
  state.selectedFrames[nextKey] = null;
  state.expandedAnimations[nextKey] = true;
  syncMainPreviewToAnimation(DRAFT_PREVIEW_KEY, { kind: "draft" });
  renderAllAnimationCards();
  refreshReview();
}

function ensureCurrentAnimationDraft(options = {}) {
  const selected = state.animations[state.selectedAnimationKey];
  const draft = state.draftAnimation;
  const nextKey = sanitizeAnimationKey(els.currentAnimationName.value || options.fallbackName || draft?.key || selected?.key);
  if (!nextKey) {
    if (options.warn) alert("动画名称只能使用小写字母、数字和下划线。");
    els.currentAnimationName.value = draft?.key || selected?.key || "";
    return null;
  }

  if (!state.draftAnimation) {
    const fps = Number(els.currentAnimationFps.value) || selected?.fps || 8;
    state.draftAnimation = defaultAnimation(nextKey, false, fps);
    applyActiveAnimationUploadMode(state.draftAnimation);
  } else {
    state.draftAnimation.key = nextKey;
    state.draftAnimation.fps = Number(els.currentAnimationFps.value) || state.draftAnimation.fps || 8;
    applyActiveAnimationUploadMode(state.draftAnimation);
  }

  state.selectedAnimationKey = nextKey;
  els.currentAnimationName.value = nextKey;
  state.selectedFrames[nextKey] = null;
  state.expandedAnimations[nextKey] = true;
  syncMainPreviewToAnimation(DRAFT_PREVIEW_KEY, { kind: "draft" });
  return state.draftAnimation;
}

async function addUnifiedFrames(files) {
  if (!files.length) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: files[0]?.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  const hadAsset = animationFrameCount(animation) > 0;
  const frames = await Promise.all([...files].map(readImageFile));
  if (!frames.every((frame) => enforceGuestImageLimit(frame))) return;
  animation.assetType = "png_frames";
  animation.frames.push(...frames);
  animation.sheet = null;
  animation.video = null;
  if (!hadAsset) clearAnimationAnchors(animation);
  state.selectedFrames[animation.key] = null;
  renderAllAnimationCards();
  refreshReview();
}

async function addLoopSegmentFrames(segmentName, files) {
  if (!files.length) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: files[0]?.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  animation.kind = "loop";
  animation.loop = true;
  animation.loopDurationSeconds = animation.loopDurationSeconds ?? 10;
  const segments = ensureLoopSegments(animation);
  const hadLoopAsset = core.animationSegmentFrameCount?.(segments.loop) || animationFrameCount(segments.loop);
  const frames = await Promise.all([...files].map(readImageFile));
  if (!frames.every((frame) => enforceGuestImageLimit(frame))) return;
  segments[segmentName].assetType = "png_frames";
  segments[segmentName].frames.push(...frames);
  segments[segmentName].sheet = null;
  segments[segmentName].video = null;
  if (segmentName === "loop" && !hadLoopAsset) clearAnimationAnchors(animation);
  state.selectedFrames[selectedFrameBucket(animation, segmentName)] = null;
  renderAllAnimationCards();
  refreshReview();
}

async function setUnifiedSheet(file) {
  if (!file) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: file.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  const sheet = await readImageFile(file);
  if (!enforceGuestImageLimit(sheet, { sheet: true })) return;
  animation.assetType = "sprite_sheet";
  animation.sheet = sheet;
  animation.frames = [];
  animation.video = null;
  clearAnimationAnchors(animation);
  state.selectedFrames[animation.key] = null;
  renderAllAnimationCards();
  refreshReview();
}

async function setLoopSegmentSheet(segmentName, file) {
  if (!file) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: file.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  animation.kind = "loop";
  animation.loop = true;
  animation.loopDurationSeconds = animation.loopDurationSeconds ?? 10;
  const segments = ensureLoopSegments(animation);
  const sheet = await readImageFile(file);
  if (!enforceGuestImageLimit(sheet, { sheet: true })) return;
  segments[segmentName].assetType = "sprite_sheet";
  segments[segmentName].sheet = sheet;
  segments[segmentName].frames = [];
  segments[segmentName].video = null;
  if (segmentName === "loop") clearAnimationAnchors(animation);
  state.selectedFrames[selectedFrameBucket(animation, segmentName)] = null;
  renderAllAnimationCards();
  refreshReview();
}

async function setUnifiedWebm(file) {
  if (!file) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: file.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  try {
    const video = await readVideoFile(file);
    animation.assetType = "webm";
    animation.video = video;
    animation.frames = [];
    animation.sheet = null;
    clearAnimationAnchors(animation);
    state.selectedFrames[animation.key] = null;
    renderAllAnimationCards();
    refreshReview();
  } catch (error) {
    alert(error?.message || "WebM 上传失败。");
  }
}

async function setLoopSegmentWebm(segmentName, file) {
  if (!file) return;
  const animation = ensureCurrentAnimationDraft({ warn: true, fallbackName: file.name?.replace(/\.[^.]+$/, "") });
  if (!animation) return;
  animation.kind = "loop";
  animation.loop = true;
  animation.loopDurationSeconds = animation.loopDurationSeconds ?? 10;
  const segments = ensureLoopSegments(animation);
  try {
    const video = await readVideoFile(file);
    segments[segmentName].assetType = "webm";
    segments[segmentName].video = video;
    segments[segmentName].frames = [];
    segments[segmentName].sheet = null;
    if (segmentName === "loop") clearAnimationAnchors(animation);
    state.selectedFrames[selectedFrameBucket(animation, segmentName)] = null;
    renderAllAnimationCards();
    refreshReview();
  } catch (error) {
    alert(error?.message || "WebM 上传失败。");
  }
}

function applyUnifiedFrameAction(action) {
  const animation = selectedAnimation();
  if (!animation) return;
  if ((action === "left" || action === "right" || action === "reverse") && !hasFullDiyAccess()) {
    showLoginRequired("在线调整帧顺序");
    return;
  }
  if (action === "reverse") {
    const target = currentFrameActionTarget(animation);
    if (target?.frames?.length) target.frames.reverse();
    clearSelectedFrameForTarget(animation, target);
  } else {
    moveSelectedFrameTarget(animation, action);
  }
  renderAllAnimationCards();
  refreshReview();
}

function moodGroupLabel(group) {
  if (group === "click") return "点击";
  if (group === "drag") return "拖拽";
  if (group === "idle") return "待机";
  if (group === "talk") return "说话";
  return group;
}

function behaviorGroupLabel(group) {
  for (const type of core.INTERACTION_TYPES) {
    const match = type.judgements.find(([key]) => key === group);
    if (match) return match[1];
  }
  return group;
}

function updateMoodBinding(key, group, checked) {
  const list = state.moodBindings[group] || [];
  state.moodBindings[group] = checked
    ? [...new Set([...list, key])]
    : list.filter((name) => name !== key);
  refreshReview();
}

function updateBehaviorBinding(key, group, checked) {
  const list = state.behaviorBindings[group] || [];
  state.behaviorBindings[group] = checked
    ? [...new Set([...list, key])]
    : list.filter((name) => name !== key);
  refreshReview();
}

function projectForValidation() {
  collectMetadata();
  return {
    metadata: state.metadata,
    animations: state.animations,
    moodBindings: state.moodBindings,
    behaviorBindings: state.behaviorBindings,
    behaviorTempo: state.behaviorTempo,
    appReactions: state.appReactions,
    interactionConfig: state.interactionConfig,
    officialSceneRules: sanitizeOfficialSceneRules(state.officialSceneRules),
    sceneAppEventBindings: sanitizeSceneAppEventBindings(state.sceneAppEventBindings),
    processBehaviorRules: sanitizeProcessBehaviorRules(state.processBehaviorRules),
    ownershipSignature: typeof state.importedPackSignature === "string" ? state.importedPackSignature : "",
    account: state.account,
    importedPackId: state.importedPackId,
    importedPackSignature: state.importedPackSignature,
  };
}

function projectForDesktopPreview() {
  const project = projectForValidation();
  const metadata = project.metadata || {};
  project.metadata = {
    name: metadata.name || "DIY Preview",
    author: metadata.author || "local",
    version: /^\d+\.\d+\.\d+$/.test(metadata.version || "") ? metadata.version : "1.0.0",
    description: metadata.description || "Temporary desktop preview from DIY.",
  };
  return project;
}

function refreshReview() {
  const result = core.validateProject(projectForValidation());
  els.generatedId.textContent = `包 ID: ${result.id || "尚未生成"}`;
  els.frameSize.textContent = `单帧尺寸: ${validationDimensionText(result)}`;
  renderList(els.errors, result.errors, "没有阻止导出的问题。");
  renderList(els.warnings, result.warnings, "没有警告。");
  els.exportBtn.disabled = result.errors.length > 0;
  if (els.topExportBtn) els.topExportBtn.disabled = result.errors.length > 0;
  updateExportSummary(result);
  updateCdkeyPublishControls(result);
  updatePermissionPanel();
  updateEntitlementControls();
  persistDraft();
  renderPreviewAnimationSelect();
  updateMainPreview();
}

function validationDimensionText(result) {
  const dimensions = result?.frameDimensions || [];
  if (!dimensions.length) return "等待有效素材";
  const unique = [...new Set(dimensions.map((entry) => `${entry.frameWidth} x ${entry.frameHeight}`))];
  if (unique.length === 1) return unique[0];
  return `多尺寸: ${dimensions.map((entry) => `${entry.key}=${entry.frameWidth} x ${entry.frameHeight}`).join("，")}`;
}

function renderList(list, items, emptyText) {
  list.replaceChildren();
  if (!items.length) {
    const li = document.createElement("li");
    li.textContent = emptyText;
    list.append(li);
    return;
  }
  for (const item of items) {
    const li = document.createElement("li");
    li.textContent = item;
    list.append(li);
  }
}

function renderPreviewAnimationSelect() {
  const current = state.previewAnimationKey;
  const keys = orderedAnimations().map(({ animation }) => animation.key);
  els.previewAnimationSelect.replaceChildren();
  if (state.draftAnimation) {
    const draftOption = document.createElement("option");
    draftOption.value = DRAFT_PREVIEW_KEY;
    draftOption.textContent = "未保存动画";
    els.previewAnimationSelect.append(draftOption);
  }
  for (const key of keys) {
    const option = document.createElement("option");
    option.value = key;
    option.textContent = labelForAnimation(key);
    els.previewAnimationSelect.append(option);
  }
  state.previewAnimationKey = current === DRAFT_PREVIEW_KEY && state.draftAnimation
    ? DRAFT_PREVIEW_KEY
    : keys.includes(current)
    ? current
    : (state.draftAnimation ? DRAFT_PREVIEW_KEY : (keys[0] || ""));
  els.previewAnimationSelect.value = state.previewAnimationKey;
}

function currentPreviewContextText() {
  const animation = previewAnimation();
  if (!animation) return "";
  if (state.previewAnimationKey === DRAFT_PREVIEW_KEY) {
    return "未保存动画，点击保存动画后会进入动画库。";
  }
  if (
    state.previewContext?.kind === "behavior"
    && state.previewContext.animationKey === state.previewAnimationKey
  ) {
    const typeText = `${interactionTypeLabel(state.previewContext.typeKey)}互动`;
    const judgementText = judgementLabel(state.previewContext.typeKey, state.previewContext.judgementKey);
    return `正在预览：${typeText} / ${judgementText} / 宠物行为${(state.previewContext.rowIndex || 0) + 1}。`;
  }
  if (state.previewContext?.kind === "library" && state.previewContext.animationKey === state.previewAnimationKey) {
    return `正在预览动画库：${state.previewAnimationKey}。`;
  }
  if (state.previewAnimationKey === state.selectedAnimationKey) {
    return `正在编辑已保存动画：${state.previewAnimationKey}。`;
  }
  return `正在预览动画库：${state.previewAnimationKey}。`;
}

function updateMainPreview() {
  clearInterval(state.mainPreviewTimer);
  updateEntitlementControls();
  const animation = previewAnimation();
  const count = animationFrameCount(animation);
  els.previewPlayBtn.textContent = state.previewPlaying ? "Ⅱ" : "▶";
  if (els.previewContext) {
    els.previewContext.textContent = currentPreviewContextText();
  }
  renderPreviewAnchorOverlay();

  if (!count) {
    const image = state.preview?.image;
    if (image) drawStill(els.previewCanvas, image);
    else clearCanvas(els.previewCanvas);
    return;
  }

  if (core.isVideoAnimationAsset?.(animation)) {
    playMainVideoPreview(animation);
    return;
  }

  const draw = () => {
    drawAnimationFrame(els.previewCanvas, animation, state.previewFrameIndex);
    state.previewFrameIndex = (state.previewFrameIndex + 1) % count;
  };
  draw();
  if (state.previewPlaying) {
    const fps = Number(els.previewFpsSelect.value) || animation.fps || 8;
    state.mainPreviewTimer = setInterval(draw, Math.max(40, 1000 / fps));
  }
}

function playMainVideoPreview(animation) {
  clearCanvas(els.previewCanvas);
  const videoMeta = animation?.video;
  if (!videoMeta?.url) return;
  const video = document.createElement("video");
  video.src = videoMeta.url;
  video.muted = true;
  video.loop = true;
  video.playsInline = true;
  if (state.previewPlaying) video.play?.().catch(() => {});
  const draw = () => {
    if (video.readyState >= 2) drawVideoFrame(els.previewCanvas, video, videoMeta.width, videoMeta.height);
  };
  draw();
  if (state.previewPlaying) state.mainPreviewTimer = setInterval(draw, 33);
}

function previewAnimation() {
  if (state.previewAnimationKey === DRAFT_PREVIEW_KEY) return state.draftAnimation;
  return state.animations[state.previewAnimationKey];
}

function animationFrameCount(animation) {
  if (!animation) return 0;
  if (core.animationFrameCount) return core.animationFrameCount(animation);
  if (animation.frames?.length) return animation.frames.length;
  if (animation.sheet) return core.inferSheet(animation.sheet).frames;
  return 0;
}

function updateExportSummary(validation) {
  const animations = Object.values(state.animations);
  const uploaded = animations.filter((animation) => animationFrameCount(animation));
  const estimatedBytes = uploaded.reduce((sum, animation) => {
    if (core.isLoopAnimation?.(animation)) {
      return sum + Object.values(ensureLoopSegments(animation)).reduce((segmentSum, segment) => {
        if (core.isVideoAnimationAsset?.(segment)) return segmentSum + (segment.video?.bytes?.length || 0);
        if (segment.sheet) return segmentSum + segment.sheet.bytes.length;
        return segmentSum + (segment.frames || []).reduce((total, frame) => total + frame.bytes.length, 0);
      }, 0);
    }
    if (core.isVideoAnimationAsset?.(animation)) return sum + (animation.video?.bytes?.length || 0);
    if (animation.sheet) return sum + animation.sheet.bytes.length;
    return sum + animation.frames.reduce((total, frame) => total + frame.bytes.length, 0);
  }, state.preview?.bytes.length || 0);

  els.exportName.textContent = state.metadata.name || "未填写";
  els.exportVersion.textContent = state.metadata.version || "1.0.0";
  els.exportEstimate.textContent = estimatedBytes ? `~${formatBytes(estimatedBytes)}` : "~0 KB";
  if (els.exportPermission) {
    els.exportPermission.replaceChildren();
    if (state.account.developerMax) {
      els.exportPermission.append("宠物包 ", maxTagNode("MAX创作者", "max-capsule export-max-capsule"), " 标签");
    } else if (state.account.loggedIn) {
      els.exportPermission.textContent = "已登录免费版宠物包";
    } else {
      els.exportPermission.textContent = "未登录免费基础版宠物包";
    }
  }
  if (validation?.id) els.generatedId.textContent = `包 ID: ${validation.id}`;
}

function maxTagNode(text = "MAX创作者", className = "max-capsule") {
  const tag = document.createElement("span");
  tag.className = className;
  if (text === "MAX创作者" && className === "max-capsule") tag.classList.add("account-max-capsule");
  tag.textContent = text;
  return tag;
}

function formatBytes(bytes) {
  if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function updatePermissionPanel() {
  const username = state.account.username || "豆豆桌宠用户";
  els.loginActions.forEach((button) => {
    const label = button.querySelector("span:last-child");
    if (state.account.loggedIn || state.account.developerMax) {
      if (label) label.textContent = username;
      else button.textContent = username;
      button.disabled = false;
      button.classList.add("is-account");
    } else {
      if (label) label.textContent = "登录";
      else button.textContent = "登录 / 注册";
      button.disabled = false;
      button.classList.remove("is-account");
    }
  });
  if (els.accountMenu) els.accountMenu.hidden = !(state.account.loggedIn || state.account.developerMax);
  els.topActions?.classList.toggle("account-active", state.account.loggedIn || state.account.developerMax);
  if (state.account.developerMax) {
    if (els.permissionState) els.permissionState.textContent = username;
    if (els.permissionDetail) els.permissionDetail.replaceChildren("10 个行为槽位 · ", maxTagNode("MAX创作者"), " 标签");
    els.upgradeActions.forEach((button) => {
      button.replaceChildren(maxTagNode("MAX创作者"));
      button.classList.add("max-capsule-button");
      button.disabled = true;
      button.hidden = false;
    });
    return;
  }
  if (els.permissionState) els.permissionState.textContent = state.account.loggedIn ? username : "未登录 · 免费基础版";
  if (els.permissionDetail) els.permissionDetail.textContent = state.account.loggedIn
    ? "已解锁实时预览、无限分辨率和帧顺序调整"
    : `免费基础版 · 最高 ${core.FREE_FRAME_SIZE_LIMIT}x${core.FREE_FRAME_SIZE_LIMIT} · 登录后解锁更多功能`;
  els.upgradeActions.forEach((button) => {
    const label = button.querySelector("span:last-child");
    if (label) label.textContent = "升级为 MAX 创作者";
    else button.textContent = "升级为 MAX 创作者";
    button.classList.remove("max-capsule-button");
    button.disabled = false;
    button.hidden = false;
  });
}

function updateEntitlementControls() {
  const loggedIn = hasFullDiyAccess();
  els.previewCard?.classList.toggle("is-limited", !loggedIn);
  els.previewPlayBtn?.classList.toggle("is-locked", !loggedIn);
  if (els.previewAnimationSelect) els.previewAnimationSelect.disabled = !loggedIn;
  if (els.previewPlayBtn) {
    els.previewPlayBtn.disabled = false;
    els.previewPlayBtn.setAttribute("aria-disabled", String(!loggedIn));
    els.previewPlayBtn.title = loggedIn ? "" : "登录后可使用实时预览";
  }
  if (els.previewFpsSelect) els.previewFpsSelect.disabled = !loggedIn;

  document.querySelectorAll('[data-action="left"], [data-action="right"], [data-unified-action="left"], [data-unified-action="right"], [data-unified-action="reverse"]').forEach((button) => {
    button.disabled = false;
    button.classList.toggle("is-locked", !loggedIn);
    button.setAttribute("aria-disabled", String(!loggedIn));
    button.title = loggedIn ? "" : "登录后可在线调整帧顺序";
  });
  document.querySelectorAll(".reverse-input").forEach((input) => {
    input.disabled = !loggedIn;
    input.title = loggedIn ? "" : "登录后可在线调整帧顺序";
  });
}

function hasFullDiyAccess() {
  return Boolean(state.account.loggedIn || state.account.developerMax);
}

function loginRequiredMessage(feature = "该功能") {
  return `${feature}需要登录后使用。`;
}

function showLoginRequired(feature) {
  alert(loginRequiredMessage(feature));
}

function guestResolutionLimitMessage() {
  return `未登录用户上传图片分辨率最高支持 ${core.FREE_FRAME_SIZE_LIMIT}x${core.FREE_FRAME_SIZE_LIMIT}，超过请先登录。`;
}

function imageExceedsGuestLimit(image, options = {}) {
  if (hasFullDiyAccess() || !image) return false;
  if (options.sheet) {
    const sheet = core.inferSheet(image);
    if (!sheet.errors.length && sheet.frameWidth && sheet.frameHeight) {
      return sheet.frameWidth > core.FREE_FRAME_SIZE_LIMIT || sheet.frameHeight > core.FREE_FRAME_SIZE_LIMIT;
    }
  }
  return image.width > core.FREE_FRAME_SIZE_LIMIT || image.height > core.FREE_FRAME_SIZE_LIMIT;
}

function enforceGuestImageLimit(image, options = {}) {
  if (!imageExceedsGuestLimit(image, options)) return true;
  alert(guestResolutionLimitMessage());
  return false;
}

function updateDescriptionCounter() {
  els.descriptionCounter.textContent = `${els.description.value.length}/200`;
}

async function setPreviewFile(file) {
  const preview = await readImageFile(file);
  if (!enforceGuestImageLimit(preview)) return;
  state.preview = preview;
  els.previewDropzone.classList.add("has-preview");
  drawStill(els.previewThumbCanvas, state.preview.image);
  refreshReview();
}

function clearPreviewFile() {
  state.preview = null;
  els.previewInput.value = "";
  els.previewDropzone.classList.remove("has-preview");
  clearCanvas(els.previewThumbCanvas);
  refreshReview();
}

async function imageMetaFromBytes(name, bytes) {
  const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
  const url = URL.createObjectURL(new Blob([data], { type: "image/png" }));
  const image = await loadImage(url);
  return {
    name: name || "imported.png",
    width: image.naturalWidth || image.width || 0,
    height: image.naturalHeight || image.height || 0,
    bytes: data,
    url,
    image,
  };
}

async function videoMetaFromBytes(name, bytes, metadata = {}) {
  const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
  const url = URL.createObjectURL(new Blob([data], { type: "video/webm" }));
  const detected = await loadVideoMetadata(url).catch(() => ({
    width: Number(metadata.width) || 0,
    height: Number(metadata.height) || 0,
    durationSeconds: Number(metadata.durationSeconds) || 0,
  }));
  return {
    name: name || "imported.webm",
    width: detected.width || Number(metadata.width) || 0,
    height: detected.height || Number(metadata.height) || 0,
    durationSeconds: detected.durationSeconds || Number(metadata.durationSeconds) || 0,
    bytes: data,
    url,
  };
}

async function splitImportedSheetFrames(sheet, spec, namePrefix) {
  const frameCount = Number(spec?.importFrameCount) || 0;
  const frameWidth = Number(spec?.importFrameWidth) || 0;
  const frameHeight = Number(spec?.importFrameHeight) || 0;
  if (!sheet || !frameCount || !frameWidth || !frameHeight) return null;
  if (sheet.width !== frameWidth * frameCount || sheet.height !== frameHeight) return null;
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;
  canvas.width = frameWidth;
  canvas.height = frameHeight;
  ctx.imageSmoothingEnabled = false;
  const frames = [];
  for (let index = 0; index < frameCount; index += 1) {
    ctx.clearRect(0, 0, frameWidth, frameHeight);
    ctx.drawImage(
      sheet.image,
      index * frameWidth,
      0,
      frameWidth,
      frameHeight,
      0,
      0,
      frameWidth,
      frameHeight,
    );
    const blob = await canvasBlob(canvas);
    if (!blob) return null;
    const bytes = new Uint8Array(await blobArrayBuffer(blob));
    const frameName = `${namePrefix || "frame"}_${String(index + 1).padStart(2, "0")}.png`;
    frames.push(await imageMetaFromBytes(frameName, bytes));
  }
  return frames;
}

async function hydrateImportedImageTarget(target, entries, namePrefix) {
  const entryPath = core.findZipEntryPath(entries, target?.importFile);
  if (!entryPath) return;
  const sheet = await imageMetaFromBytes(entryPath.split("/").pop(), entries[entryPath]);
  const frames = await splitImportedSheetFrames(sheet, target, namePrefix);
  if (frames?.length) {
    target.frames = frames;
    target.sheet = null;
  } else {
    target.frames = [];
    target.sheet = sheet;
  }
}

async function hydrateImportedVideoTarget(target, entries) {
  const importFile = target?.video?.importFile || target?.importFile;
  const entryPath = core.findZipEntryPath(entries, importFile);
  if (!entryPath) return;
  target.assetType = "webm";
  target.video = await videoMetaFromBytes(entryPath.split("/").pop(), entries[entryPath], target.video);
  target.frames = [];
  target.sheet = null;
}

async function hydrateImportedDraftAssets(draft, entries) {
  const previewPath = core.findZipEntryPath(entries, "preview.png");
  if (previewPath) {
    draft.preview = await imageMetaFromBytes("preview.png", entries[previewPath]);
  }
  for (const animation of Object.values(draft.animations || {})) {
    if (core.isLoopAnimation?.(animation)) {
      ensureLoopSegments(animation);
      for (const segment of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
        if (core.isVideoAnimationAsset?.(animation.segments[segment])) {
          await hydrateImportedVideoTarget(animation.segments[segment], entries);
        } else {
          await hydrateImportedImageTarget(animation.segments[segment], entries, `${animation.key}_${segment}`);
        }
      }
      continue;
    }
    if (core.isVideoAnimationAsset?.(animation)) {
      await hydrateImportedVideoTarget(animation, entries);
    } else {
      await hydrateImportedImageTarget(animation, entries, animation.key);
    }
  }
}

function applyImportedDraft(draft) {
  resetEditorState();
  state.metadata = { ...(draft.metadata || {}) };
  state.animations = draft.animations || {};
  state.moodBindings = { ...state.moodBindings, ...(draft.moodBindings || {}) };
  state.behaviorBindings = { ...state.behaviorBindings, ...(draft.behaviorBindings || {}) };
  state.behaviorTempo = core.resolveBehaviorTempo({ behaviorTempo: draft.behaviorTempo });
  state.appReactions = { ...state.appReactions, ...(draft.appReactions || {}) };
  state.interactionConfig = { ...state.interactionConfig, ...(draft.interactionConfig || {}) };
  state.officialSceneRules = sanitizeOfficialSceneRules(draft.officialSceneRules);
  state.processBehaviorRules = sanitizeProcessBehaviorRules(draft.processBehaviorRules);
  state.preview = draft.preview || null;
  state.draftAnimation = null;
  state.selectedFrames = {};
  state.expandedAnimations = {};
  state.selectedAnimationKey = Object.keys(state.animations)[0] || "";
  state.previewAnimationKey = state.selectedAnimationKey;
  state.previewFrameIndex = 0;
  state.importedPackId = draft.importedPackId || "";
  state.importedPackSignature = draft.importedPackSignature || null;

  if (els.name) els.name.value = state.metadata.name || "";
  if (els.author) els.author.value = state.metadata.author || "";
  if (els.version) els.version.value = state.metadata.version || "1.0.0";
  if (els.description) els.description.value = state.metadata.description || "";
  if (state.preview) {
    els.previewDropzone?.classList.add("has-preview");
    if (els.previewThumbCanvas) drawStill(els.previewThumbCanvas, state.preview.image);
  }
  pruneMissingAnimationReferences();
  updateDescriptionCounter();
  renderAllAnimationCards();
  refreshReview();
}

async function importPetpackFile(file) {
  if (!file) return;
  try {
    const bytes = new Uint8Array(await file.arrayBuffer());
    const entries = core.readStoredZip(bytes);
    const manifestPath = core.findZipEntryPath(entries, "manifest.json");
    if (!manifestPath) throw new Error("manifest.json not found.");
    const manifest = JSON.parse(core.utf8FromBytes(entries[manifestPath]));
    const draft = core.draftFromImportedManifest(manifest);
    await hydrateImportedDraftAssets(draft, entries);
    applyImportedDraft(draft);
    if (typeof alert === "function") alert("宠物包已导入，可以继续编辑。");
  } catch (error) {
    const message = error?.message || String(error);
    if (typeof alert === "function") alert(`导入宠物包失败：${message}`);
  }
}

async function composeSheet(animation) {
  if (animation.sheet) return animation.sheet.bytes;
  const sizeResult = core.inferFrameSize(animation.frames);
  if (sizeResult.errors.length) throw new Error(sizeResult.errors.join(" "));
  const frameWidth = sizeResult.frameWidth;
  const frameHeight = sizeResult.frameHeight;
  const canvas = document.createElement("canvas");
  canvas.width = frameWidth * animation.frames.length;
  canvas.height = frameHeight;
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false;
  animation.frames.forEach((frame, index) => {
    ctx.drawImage(frame.image, index * frameWidth, 0, frameWidth, frameHeight);
  });
  return new Uint8Array(await blobArrayBuffer(await canvasBlob(canvas)));
}

function canvasBlob(canvas) {
  return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}

async function blobArrayBuffer(blob) {
  return blob.arrayBuffer();
}

async function previewBytes() {
  if (state.preview) return state.preview.bytes;
  const fallback = orderedAnimations()
    .map(({ animation }) => animation)
    .find((animation) => animationFrameCount(animation));
  const image = fallback?.frames[0]?.image || fallback?.sheet?.image;
  if (!image) return null;
  const canvas = document.createElement("canvas");
  canvas.width = 128;
  canvas.height = 128;
  drawStill(canvas, image);
  return new Uint8Array(await blobArrayBuffer(await canvasBlob(canvas)));
}

async function buildPetpackArchive(project, validation) {
  const frameSize = validation.frameSize;
  const anchoredProject = projectWithAnimationAnchors(project);
  const manifest = core.buildManifest({
    metadata: anchoredProject.metadata,
    frameSize,
    frameWidth: validation.frameWidth,
    frameHeight: validation.frameHeight,
    animations: anchoredProject.animations,
    moodBindings: anchoredProject.moodBindings,
    behaviorBindings: anchoredProject.behaviorBindings,
    appReactions: anchoredProject.appReactions,
    interactionConfig: anchoredProject.interactionConfig,
    account: anchoredProject.account,
    ownershipSignature: anchoredProject.ownershipSignature,
    resizeHandleAnchor: resizeHandleAnchorFromAnchors(anchoredProject) || resizeHandleAnchorFromIdle(anchoredProject),
  });
  const entries = [
    { path: "manifest.json", data: core.textBytes(JSON.stringify(manifest, null, 2)) },
    { path: "dialogs.json", data: core.textBytes(JSON.stringify({ schema_version: "1.0", groups: {} }, null, 2)) },
  ];
  const preview = await previewBytes();
  if (preview) entries.push({ path: "preview.png", data: preview });
  for (const [key, animation] of Object.entries(anchoredProject.animations || {})) {
    if (!core.buildManifest({
      metadata: anchoredProject.metadata,
      frameSize,
      frameWidth: validation.frameWidth,
      frameHeight: validation.frameHeight,
      animations: { [key]: animation },
      moodBindings: {},
      account: anchoredProject.account,
    }).animations[key]) continue;
    if (core.isLoopAnimation?.(animation)) {
      ensureLoopSegments(animation);
      for (const segment of core.LOOP_SEGMENTS || ["intro", "loop", "outro"]) {
        const payload = animation.segments[segment];
        if (core.isVideoAnimationAsset?.(payload)) {
          if (!payload.video?.bytes) throw new Error(`${key}.${segment}: WebM 原始文件缺失，请重新上传。`);
          entries.push({ path: `videos/${key}_${segment}.webm`, data: payload.video.bytes });
          continue;
        }
        if (!payload.frames.length && !payload.sheet) continue;
        entries.push({ path: `sprites/${key}_${segment}.png`, data: await composeSheet(payload) });
      }
      continue;
    }
    if (core.isVideoAnimationAsset?.(animation)) {
      if (!animation.video?.bytes) throw new Error(`${key}: WebM 原始文件缺失，请重新上传。`);
      entries.push({ path: `videos/${key}.webm`, data: animation.video.bytes });
      continue;
    }
    if (!animation.frames.length && !animation.sheet) continue;
    entries.push({ path: `sprites/${key}.png`, data: await composeSheet(animation) });
  }
  return { manifest, zip: core.createStoredZip(entries) };
}

function animationSegmentDurationSeconds(segment, fps) {
  const count = core.animationSegmentFrameCount
    ? core.animationSegmentFrameCount(segment)
    : ((segment?.frames?.length || 0) || (segment?.sheet ? core.inferSheet(segment.sheet).frames : 0));
  return count / Math.max(1, Number(fps) || 8);
}

function desktopPreviewTimeoutSeconds(animation) {
  if (!animation) return 8;
  const fps = Math.max(1, Number(animation.fps) || 8);
  let duration = 0;
  if (core.isLoopAnimation?.(animation)) {
    const segments = ensureLoopSegments(animation);
    duration += animationSegmentDurationSeconds(segments.intro, fps);
    const loopDuration = animation.loopDurationSeconds === null || animation.loopDurationSeconds === undefined
      ? DESKTOP_PREVIEW_LOOP_FALLBACK_SECONDS
      : Number(animation.loopDurationSeconds);
    duration += Number.isFinite(loopDuration) ? Math.max(0, loopDuration) : DESKTOP_PREVIEW_LOOP_FALLBACK_SECONDS;
    duration += animationSegmentDurationSeconds(segments.outro, fps);
  } else {
    duration = animationFrameCount(animation) / fps;
  }
  return Math.max(2, Math.min(60, duration + 2));
}

async function testBehaviorOnDesktop(typeKey, judgementKey, animationKey, rowIndex = 0) {
  const animation = state.animations[animationKey];
  if (!animation) {
    alert("桌面测试失败：找不到当前宠物行为绑定的动画。");
    return;
  }
  const project = projectForDesktopPreview();
  const validation = core.validateProject(project);
  if (validation.errors.length) {
    refreshReview();
    alert(`桌面测试前请先修正草稿问题：${validation.errors[0]}`);
    return;
  }
  const { zip } = await buildPetpackArchive(project, validation);
  let payload = null;
  try {
    const response = await fetch(DESKTOP_PREVIEW_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        petpack_base64: bytesToBase64(zip),
        target: {
          type_key: typeKey,
          judgement_key: judgementKey,
          animation_key: animationKey,
          slot: rowIndex + 1,
        },
        timeout_seconds: desktopPreviewTimeoutSeconds(animation),
      }),
    });
    payload = await response.json().catch(() => null);
    if (!response.ok || !payload?.ok) {
      const reason = payload?.reason || payload?.error || `HTTP ${response.status}`;
      alert(`桌面测试失败：${reason}`);
    }
  } catch (_error) {
    alert("桌面测试失败：请先启动豆豆桌宠，或确认本地行为测试端口 8765 已开启。");
  }
}

function diyApiBase() {
  localStorage.setItem(DIY_API_BASE_KEY, CLOUD_API_BASE);
  return CLOUD_API_BASE;
}

function diyAuthHeaders(extra = {}) {
  const token = localStorage.getItem(DIY_AUTH_TOKEN_KEY) || "";
  if (!token) throw new Error("请先登录桌宠账号后继续。");
  return { ...extra, Authorization: `Bearer ${token}` };
}

async function diyFetch(path, options = {}) {
  const response = await fetch(`${diyApiBase()}${path}`, options);
  const payload = await response.json().catch(() => null);
  if (!response.ok) {
    throw new Error(payload?.detail || payload?.message || `HTTP ${response.status}`);
  }
  return payload;
}

function refreshCdkeyQuotaButtonRefs() {
  els.buyCdkeyQuota100 = document.getElementById("buyCdkeyQuota100Btn");
  els.buyCdkeyQuota300 = document.getElementById("buyCdkeyQuota300Btn");
  els.buyCdkeyQuota1000 = document.getElementById("buyCdkeyQuota1000Btn");
}

function dataAttributeName(key) {
  return `data-${String(key).replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
}

function readElementData(element, key) {
  return element?.dataset?.[key] || element?.getAttribute?.(dataAttributeName(key)) || "";
}

function writeElementData(element, key, value) {
  if (!element) return;
  if (element.dataset) element.dataset[key] = String(value);
  element.setAttribute?.(dataAttributeName(key), String(value));
}

function cdkeyButtonTier(button) {
  const explicit = readElementData(button, "cdkeyPackageTier");
  if (explicit) return explicit;
  const card = button?.closest?.("[data-cdkey-package-tier]");
  const cardTier = readElementData(card, "cdkeyPackageTier");
  if (cardTier) return cardTier;
  if (button?.id === "buyCdkeyQuota300Btn") return "cdkey_quota_300";
  if (button?.id === "buyCdkeyQuota1000Btn") return "cdkey_quota_1000";
  return "cdkey_quota_100";
}

function bindCdkeyQuotaProductButtons() {
  refreshCdkeyQuotaButtonRefs();
  const buttons = new Set([
    els.buyCdkeyQuota100,
    els.buyCdkeyQuota300,
    els.buyCdkeyQuota1000,
    ...(els.cdkeyPackagesPage?.querySelectorAll?.("[data-cdkey-product-button='true']") || []),
  ].filter(Boolean));
  buttons.forEach((button) => {
    if (button.__cdkeyQuotaBound) return;
    button.__cdkeyQuotaBound = true;
    button.addEventListener("click", () => buyCdkeyQuotaPack(cdkeyButtonTier(button)));
  });
}

function formatProductPrice(product) {
  const amountFen = Number(product?.amount_fen ?? product?.price_fen);
  if (!Number.isFinite(amountFen) || amountFen <= 0) return "按产品定价";
  const amount = amountFen / 100;
  return `¥${Number.isInteger(amount) ? amount.toFixed(0) : amount.toFixed(2)}`;
}

function renderCdkeyQuotaProducts(products = []) {
  if (!els.cdkeyPackagesPage) return;
  const quotaProducts = (Array.isArray(products) ? products : []).filter((product) => (
    product
    && product.billing_mode === "native"
    && product.grant_kind === "cdkey_quota"
    && product.sku
  ));
  if (!quotaProducts.length) {
    if (readElementData(els.cdkeyPackagesPage, "renderedCatalog") === "true" && DEFAULT_CDKEY_PACKAGES_MARKUP) {
      els.cdkeyPackagesPage.innerHTML = DEFAULT_CDKEY_PACKAGES_MARKUP;
      if (els.cdkeyPackagesPage.dataset) delete els.cdkeyPackagesPage.dataset.renderedCatalog;
      els.cdkeyPackagesPage.removeAttribute?.("data-rendered-catalog");
      bindCdkeyQuotaProductButtons();
      updateCdkeyPublishControls();
    }
    return;
  }

  const header = document.createElement("header");
  const title = document.createElement("h3");
  title.textContent = "购买 CDKEY 折扣包";
  const copy = document.createElement("p");
  copy.textContent = "额度包价格来自支付产品目录，支付后可用于生成 CDKEY。";
  header.append(title, copy);
  els.cdkeyPackagesPage.replaceChildren(header);

  quotaProducts.forEach((product) => {
    const card = document.createElement("article");
    card.className = `cdkey-package-card ${product.enabled === false ? "pending" : "active"}`;
    writeElementData(card, "cdkeyPackageTier", product.sku);

    const body = document.createElement("div");
    const heading = document.createElement("h3");
    heading.textContent = product.title || product.name || product.sku;
    const description = document.createElement("p");
    description.textContent = product.description || `购买后增加 ${Number(product.grant_quantity) || 0} 个 CDKEY 配额。`;
    body.append(heading, description);

    const price = document.createElement("span");
    price.className = "cdkey-package-price";
    price.textContent = formatProductPrice(product);

    const button = document.createElement("button");
    button.className = "button";
    button.type = "button";
    button.textContent = product.enabled === false ? "暂不可购买" : "购买";
    button.disabled = product.enabled === false || !state.account.loggedIn || !state.account.developerMax;
    writeElementData(button, "cdkeyProductButton", "true");
    writeElementData(button, "cdkeyPackageTier", product.sku);
    button.addEventListener("click", () => buyCdkeyQuotaPack(product.sku));

    card.append(body, price, button);
    els.cdkeyPackagesPage.appendChild(card);
  });
  writeElementData(els.cdkeyPackagesPage, "renderedCatalog", "true");
}

async function refreshPaymentProducts() {
  if (!state.account.loggedIn || typeof fetch !== "function") {
    state.account.paymentProducts = [];
    renderCdkeyQuotaProducts([]);
    return;
  }
  try {
    const payload = await diyFetch("/pay/products", {
      method: "GET",
      headers: diyAuthHeaders(),
    });
    const products = Array.isArray(payload?.products) ? payload.products : [];
    state.account.paymentProducts = products;
    renderCdkeyQuotaProducts(products);
  } catch (_error) {
    renderCdkeyQuotaProducts(state.account.paymentProducts || []);
  }
}

async function verifyImportedPackOwnership() {
  if (!state.importedPackId) return;
  if (typeof state.importedPackSignature !== "string" || !state.importedPackSignature.trim()) {
    throw new Error("导入的宠物包缺少原始归属签名，不能重新导出。");
  }
  const ownershipSignature = state.importedPackSignature.trim();
  await diyFetch("/packs/developer/verify_ownership", {
    method: "POST",
    headers: diyAuthHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({ ownership_signature: ownershipSignature }),
  });
}

function updateCdkeyPublishControls(validation = core.validateProject(projectForValidation())) {
  const hasErrors = validation.errors.length > 0;
  const quantity = requestedCdkeyQuantity();
  const quotaBalance = Number(state.account.cdkeyQuotaBalance) || 0;
  const hasEnoughQuota = quotaBalance >= quantity;
  const canPublish = state.account.loggedIn && state.account.developerMax && !hasErrors;
  const canGenerate = Boolean(state.publishedCdkeyPack) && state.account.developerMax && hasEnoughQuota;
  if (els.cdkeyQuotaBalance) els.cdkeyQuotaBalance.textContent = String(quotaBalance);
  if (els.openCdkeyPackages) els.openCdkeyPackages.disabled = !state.account.loggedIn || !state.account.developerMax;
  if (els.buyCdkeyQuota100) els.buyCdkeyQuota100.disabled = !state.account.loggedIn || !state.account.developerMax;
  if (els.buyCdkeyQuota300) els.buyCdkeyQuota300.disabled = true;
  if (els.buyCdkeyQuota1000) els.buyCdkeyQuota1000.disabled = true;
  if (els.publishCdkeyPack) els.publishCdkeyPack.disabled = !canPublish;
  if (els.createCdkeyBatch) els.createCdkeyBatch.disabled = !canGenerate;
  if (els.downloadCdkeyCsv) els.downloadCdkeyCsv.disabled = !state.generatedCdkeyCodes.length;
  if (!els.cdkeyPublishStatus) return;
  if (!state.account.loggedIn) {
    els.cdkeyPublishStatus.textContent = "请先登录后再发布 CDKEY 宠物包。";
  } else if (!state.account.developerMax) {
    els.cdkeyPublishStatus.textContent = "升级为 MAX 创作者后可发布 CDKEY 宠物包并购买生成配额。";
  } else if (hasErrors) {
    els.cdkeyPublishStatus.textContent = "修复检查结果里的错误后可发布 CDKEY 宠物包。";
  } else if (state.publishedCdkeyPack && !hasEnoughQuota) {
    els.cdkeyPublishStatus.textContent = `配额不足：当前配额：${quotaBalance}，本批需要 ${quantity}。请先购买 CDKEY 配额包。`;
  } else if (state.publishedCdkeyPack) {
    els.cdkeyPublishStatus.textContent = `已发布：${state.publishedCdkeyPack.title || state.publishedCdkeyPack.manifest_pack_id}。当前配额：${quotaBalance}`;
  } else {
    els.cdkeyPublishStatus.textContent = "将当前草稿上传为 CDKEY 宠物包；生成兑换码会消耗配额，不按外部售价分成。";
  }
}

function requestedCdkeyQuantity() {
  return Math.max(1, Math.min(200, Number(els.cdkeyBatchQuantity?.value) || 1));
}

function toggleCdkeyPackagesPage(forceOpen = null) {
  if (!els.cdkeyPackagesPage || !els.openCdkeyPackages) return;
  const open = forceOpen == null ? els.cdkeyPackagesPage.hidden : Boolean(forceOpen);
  els.cdkeyPackagesPage.hidden = !open;
  els.openCdkeyPackages.setAttribute("aria-expanded", String(open));
  els.openCdkeyPackages.textContent = open ? "收起 CDKEY 折扣包" : "购买 CDKEY 折扣包";
}

async function buyCdkeyQuotaPack(tier = "cdkey_quota_100") {
  if (!state.account.loggedIn || !state.account.developerMax) {
    alert("请先登录并升级为 MAX 创作者，再购买 CDKEY 配额包。");
    return;
  }
  try {
    const order = await diyFetch("/pay/create_order", {
      method: "POST",
      headers: diyAuthHeaders({ "Content-Type": "application/json" }),
      body: JSON.stringify({ tier }),
    });
    if (order.code_url) {
      els.cdkeyPublishStatus.textContent = `CDKEY 配额包订单已创建：${order.code_url}`;
    }
    const paid = await diyFetch(`/pay/status?order_no=${encodeURIComponent(order.order_no)}`, {
      method: "GET",
      headers: diyAuthHeaders(),
    });
    const quota = paid?.entitlements?.cdkey_quota_balance;
    if (Number.isFinite(quota)) {
      state.account.cdkeyQuotaBalance = quota;
      persistDraft();
      updateCdkeyPublishControls();
      alert(`CDKEY 配额包购买成功，当前剩余配额：${quota}。`);
    } else {
      updateCdkeyPublishControls();
    }
  } catch (error) {
    alert(`CDKEY 配额包购买失败：${error?.message || error}`);
  }
}

async function publishCdkeyPack() {
  const project = projectForValidation();
  const validation = core.validateProject(project);
  if (validation.errors.length) {
    refreshReview();
    return;
  }
  if (!state.account.developerMax) {
    alert("只有 MAX 创作者可以发布 CDKEY 宠物包。");
    return;
  }
  try {
    await verifyImportedPackOwnership();
    const { manifest, zip } = await buildPetpackArchive(project, validation);
    const form = new FormData();
    form.append("file", new Blob([zip], { type: "application/zip" }), `${manifest.id}.petpack`);
    const draft = await diyFetch("/packs/developer/upload", {
      method: "POST",
      headers: diyAuthHeaders(),
      body: form,
    });
    state.publishedCdkeyPack = await diyFetch(`/packs/developer/${draft.id}/publish`, {
      method: "POST",
      headers: diyAuthHeaders({ "Content-Type": "application/json" }),
      body: JSON.stringify({
        distribution_mode: "cdkey_only",
        download_fee_fen: draft.download_fee_fen,
        allow_redeemer_download_after_unpublish: true,
      }),
    });
    state.generatedCdkeyCodes = [];
    if (els.cdkeyCodesOutput) els.cdkeyCodesOutput.value = "";
    updateCdkeyPublishControls(validation);
  } catch (error) {
    alert(`CDKEY 宠物包发布失败：${error?.message || error}`);
  }
}

async function createCdkeyBatch() {
  if (!state.publishedCdkeyPack) {
    alert("请先发布 CDKEY 宠物包。");
    return;
  }
  const quantity = requestedCdkeyQuantity();
  if ((Number(state.account.cdkeyQuotaBalance) || 0) < quantity) {
    updateCdkeyPublishControls();
    alert("CDKEY 配额不足，请先购买配额包。");
    return;
  }
  const expiresAt = els.cdkeyBatchExpiresAt?.value
    ? new Date(`${els.cdkeyBatchExpiresAt.value}T23:59:59`).toISOString()
    : null;
  try {
    const batch = await diyFetch(`/packs/developer/${state.publishedCdkeyPack.id}/cdkey_batches`, {
      method: "POST",
      headers: diyAuthHeaders({ "Content-Type": "application/json" }),
      body: JSON.stringify({
        batch_name: els.cdkeyBatchName?.value || "CDKEY batch",
        quantity,
        expires_at: expiresAt,
      }),
    });
    if (Number.isFinite(batch.cdkey_quota_balance)) {
      state.account.cdkeyQuotaBalance = batch.cdkey_quota_balance;
      persistDraft();
    }
    renderCdkeyCodes(batch.codes || [], batch.cdkey_quota_balance);
  } catch (error) {
    alert(`CDKEY 生成失败：${error?.message || error}`);
  }
}

function renderCdkeyCodes(codes, quotaBalance = null) {
  state.generatedCdkeyCodes = [...codes];
  if (els.cdkeyCodesOutput) {
    els.cdkeyCodesOutput.value = state.generatedCdkeyCodes.join("\n");
  }
  updateCdkeyPublishControls();
  if (els.cdkeyPublishStatus && Number.isFinite(quotaBalance)) {
    els.cdkeyPublishStatus.textContent = `已生成 ${codes.length} 个 CDKEY，剩余配额：${quotaBalance}。`;
  }
}

function downloadCdkeyCsv() {
  if (!state.generatedCdkeyCodes.length) return;
  const rows = ["code", ...state.generatedCdkeyCodes].join("\n");
  const blob = new Blob([rows], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${state.publishedCdkeyPack?.manifest_pack_id || "petpack"}-cdkeys.csv`;
  a.click();
  URL.revokeObjectURL(url);
}

async function exportPetpack() {
  const project = projectForValidation();
  const validation = core.validateProject(project);
  if (validation.errors.length) {
    refreshReview();
    return;
  }
  let manifest;
  let zip;
  try {
    await verifyImportedPackOwnership();
    ({ manifest, zip } = await buildPetpackArchive(project, validation));
  } catch (error) {
    const prefix = state.importedPackId ? "原始宠物包归属校验失败：" : "";
    alert(`${prefix}${error?.message || "导出失败。"}`);
    return;
  }
  const blob = new Blob([zip], { type: "application/zip" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${manifest.id}.petpack`;
  a.click();
  URL.revokeObjectURL(url);
  trackDiyEvent("petpack_exported", { version: manifest.version });
  clearDraft();
}

function applyDiyAccountSession(payload = {}) {
  const entitlements = payload.entitlements || {};
  const capabilities = entitlements.capabilities || {};
  state.account.loggedIn = Boolean(payload.token || state.account.loggedIn);
  state.account.developerMax = Boolean(capabilities.developer_max || payload.pro_tier >= 2);
  state.account.username = payload.nickname || state.account.username || "豆豆桌宠用户";
  state.account.cdkeyQuotaBalance = Number(entitlements.cdkey_quota_balance ?? state.account.cdkeyQuotaBalance) || 0;
  state.account.entitlements = entitlements;
  if (payload.user_id) {
    state.account.user_id = payload.user_id;
    state.account.userId = payload.user_id;
  }
  if (payload.token) {
    localStorage.setItem(DIY_AUTH_TOKEN_KEY, payload.token);
  }
  persistDraft();
  try {
    localStorage.setItem("doudou_diy_draft_notice", String(Date.now()));
  } catch (_error) {
    // Draft state already lives in memory; this is only a resume breadcrumb.
  }
  updatePermissionPanel();
  updateEntitlementControls();
  refreshPaymentProducts();
  refreshReview();
  renderBehaviorEditor();
}

function waitFor(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function startLogin() {
  state.account.loggedIn = true;
  state.account.username = state.account.username || "豆豆桌宠用户";
  try {
    localStorage.setItem("doudou_diy_draft_notice", String(Date.now()));
  } catch (_error) {
    // Draft state already lives in memory; this is only a resume breadcrumb.
  }
  updatePermissionPanel();
  updateEntitlementControls();
  refreshPaymentProducts();
  refreshReview();
  renderBehaviorEditor();
}

function startDeveloperMaxUpgrade() {
  state.account.loggedIn = true;
  state.account.developerMax = true;
  state.account.username = state.account.username || "豆豆桌宠用户";
  state.account.cdkeyQuotaBalance = Number(state.account.cdkeyQuotaBalance) || 0;
  try {
    localStorage.setItem("doudou_diy_draft_notice", String(Date.now()));
  } catch (_error) {
    // Draft state already lives in memory; this is only a resume breadcrumb.
  }
  updatePermissionPanel();
  updateEntitlementControls();
  refreshPaymentProducts();
  persistDraft();
  refreshReview();
  renderBehaviorEditor();
}

function logout() {
  state.account.loggedIn = false;
  state.account.developerMax = false;
  state.account.username = "";
  state.account.cdkeyQuotaBalance = 0;
  state.account.paymentProducts = [];
  delete state.account.user_id;
  delete state.account.userId;
  delete state.account.account_id;
  delete state.account.accountId;
  delete state.account.entitlements;
  try {
    localStorage.removeItem(DIY_AUTH_TOKEN_KEY);
  } catch (_error) {
    // Ignore storage errors; in-memory logout still applies.
  }
  updatePermissionPanel();
  updateEntitlementControls();
  renderCdkeyQuotaProducts([]);
  refreshReview();
  renderBehaviorEditor();
}

function bindStaticHelpTooltips() {
  document.querySelectorAll(".fps-help, .cdkey-method-help").forEach((help) => {
    help.addEventListener("mouseenter", () => help.classList.add("is-visible"));
    help.addEventListener("mouseleave", () => {
      if (help.dataset.pinned !== "true") help.classList.remove("is-visible");
    });
    help.addEventListener("focus", () => help.classList.add("is-visible"));
    help.addEventListener("blur", () => {
      help.dataset.pinned = "false";
      help.classList.remove("is-visible");
    });
    help.addEventListener("click", () => {
      const pinned = help.dataset.pinned === "true";
      help.dataset.pinned = pinned ? "false" : "true";
      help.classList.toggle("is-visible", !pinned);
    });
  });
}

function setSectionExpanded(section, expanded) {
  if (!section) return;
  section.dataset.sectionExpanded = String(expanded);
  section.classList.toggle("is-collapsed", !expanded);
  const content = section.querySelector("[data-collapse-content]");
  if (content) content.hidden = !expanded;
  const toggle = section.querySelector("[data-collapse-toggle]");
  if (toggle) {
    toggle.setAttribute("aria-expanded", String(expanded));
    const label = toggle.querySelector(".collapse-label");
    if (label) label.textContent = expanded ? "收起" : "展开";
  }
}

function bindCollapsibleSections() {
  document.querySelectorAll("[data-section-expanded]").forEach((section) => {
    const toggle = section.querySelector("[data-collapse-toggle]");
    setSectionExpanded(section, section.dataset.sectionExpanded !== "false");
    toggle?.addEventListener("click", () => {
      setSectionExpanded(section, section.dataset.sectionExpanded === "false");
    });
  });
}

function stepTargetForHash(hash) {
  const id = hash?.startsWith("#") ? hash.slice(1) : "";
  if (!id) return null;
  if (id === "review") return document.querySelector("#review .live-preview-card") || document.getElementById("review");
  return document.getElementById(id);
}

function stepScrollOffset() {
  const topNav = document.querySelector(".top-nav");
  return (topNav?.getBoundingClientRect().height || 72) + 24;
}

function setActiveStepLink(hash) {
  document.querySelectorAll('.step-item[href^="#"]').forEach((link) => {
    link.classList.toggle("active", link.getAttribute("href") === hash);
  });
}

function scrollToStepHash(hash, behavior = "smooth") {
  const target = stepTargetForHash(hash);
  if (!target) return;
  const section = target.matches?.("[data-section-expanded]")
    ? target
    : target.closest?.("[data-section-expanded]");
  if (section?.dataset?.sectionExpanded === "false") setSectionExpanded(section, true);
  const top = target.getBoundingClientRect().top + window.pageYOffset - stepScrollOffset();
  window.scrollTo({ top: Math.max(0, top), behavior });
  setActiveStepLink(hash);
}

function bindStepNavigation() {
  document.querySelectorAll('.step-item[href^="#"]').forEach((link) => {
    link.addEventListener("click", (event) => {
      const hash = link.getAttribute("href");
      if (!hash || !stepTargetForHash(hash)) return;
      event.preventDefault();
      if (window.history?.pushState) window.history.pushState(null, "", hash);
      else window.location.hash = hash;
      scrollToStepHash(hash);
    });
  });

  window.addEventListener?.("hashchange", () => {
    const hash = window.location?.hash;
    if (hash) scrollToStepHash(hash);
  });
}

function bindEvents() {
  bindStaticHelpTooltips();
  bindCollapsibleSections();
  bindAnimationUploadTabs();
  bindStepNavigation();

  for (const input of [els.name, els.author, els.version, els.description]) {
    input.addEventListener("input", updateMetadata);
  }

  els.previewInput.addEventListener("change", async () => {
    if (!els.previewInput.files[0]) return;
    await setPreviewFile(els.previewInput.files[0]);
  });

  els.previewDropzone.addEventListener("click", (event) => {
    if (event.target.closest("button")) return;
    els.previewInput.click();
  });

  els.previewDropzone.addEventListener("keydown", (event) => {
    if (event.key !== "Enter" && event.key !== " ") return;
    event.preventDefault();
    els.previewInput.click();
  });

  els.previewDropzone.addEventListener("dragover", (event) => {
    event.preventDefault();
    els.previewDropzone.classList.add("dragover");
  });

  els.previewDropzone.addEventListener("dragleave", () => {
    els.previewDropzone.classList.remove("dragover");
  });

  els.previewDropzone.addEventListener("drop", async (event) => {
    event.preventDefault();
    els.previewDropzone.classList.remove("dragover");
    const [file] = event.dataTransfer.files;
    if (file) await setPreviewFile(file);
  });

  els.removePreview.addEventListener("click", (event) => {
    event.preventDefault();
    event.stopPropagation();
    clearPreviewFile();
  });

  els.currentAnimationFps?.addEventListener("input", () => {
    const animation = selectedAnimation();
    if (!animation) return;
    animation.fps = Number(els.currentAnimationFps.value);
    renderUnifiedAnimationEditor();
    renderAnimationLibrary();
    refreshReview();
  });
  els.currentAnimationLoop?.addEventListener("change", () => {
    setCurrentAnimationLoopEnabled(els.currentAnimationLoop.checked);
  });
  els.currentLoopDuration?.addEventListener("change", updateCurrentLoopDuration);
  els.currentLoopInfinite?.addEventListener("change", updateCurrentLoopDuration);
  els.currentAnimationName?.addEventListener("change", handleAnimationNameChange);
  els.saveAnimationName?.addEventListener("click", saveCurrentAnimationName);
  els.unifiedFrameInput?.addEventListener("change", async () => {
    await addUnifiedFrames(els.unifiedFrameInput.files);
    els.unifiedFrameInput.value = "";
  });
  els.unifiedSheetInput?.addEventListener("change", async () => {
    await setUnifiedSheet(els.unifiedSheetInput.files[0]);
    els.unifiedSheetInput.value = "";
  });
  els.unifiedWebmInput?.addEventListener("change", async () => {
    await setUnifiedWebm(els.unifiedWebmInput.files[0]);
    els.unifiedWebmInput.value = "";
  });
  for (const [segment, input] of Object.entries(els.loopFrameInputs || {})) {
    input?.addEventListener("change", async () => {
      await addLoopSegmentFrames(segment, input.files);
      input.value = "";
    });
  }
  for (const [segment, input] of Object.entries(els.loopSheetInputs || {})) {
    input?.addEventListener("change", async () => {
      await setLoopSegmentSheet(segment, input.files[0]);
      input.value = "";
    });
  }
  for (const [segment, input] of Object.entries(els.loopWebmInputs || {})) {
    input?.addEventListener("change", async () => {
      await setLoopSegmentWebm(segment, input.files[0]);
      input.value = "";
    });
  }
  document.querySelectorAll("[data-unified-action]").forEach((button) => {
    button.addEventListener("click", () => applyUnifiedFrameAction(button.dataset.unifiedAction));
  });

  for (const [appName, select] of Object.entries(els.appReactionSelects)) {
    if (!select) continue;
    select.addEventListener("change", () => {
      state.appReactions[appName] = select.value;
      refreshReview();
    });
  }

  els.previewAnimationSelect.addEventListener("change", () => {
    state.previewAnimationKey = els.previewAnimationSelect.value;
    state.previewFrameIndex = 0;
    updateMainPreview();
  });

  els.previewPlayBtn.addEventListener("click", () => {
    if (!hasFullDiyAccess()) {
      showLoginRequired("实时预览");
      return;
    }
    state.previewPlaying = !state.previewPlaying;
    updateMainPreview();
  });

  els.previewFpsSelect.addEventListener("change", updateMainPreview);
  els.loginActions.forEach((button) => button.addEventListener("click", startLogin));
  els.logoutActions.forEach((button) => button.addEventListener("click", logout));
  els.upgradeActions.forEach((button) => button.addEventListener("click", startDeveloperMaxUpgrade));
  els.importPetpackBtn?.addEventListener("click", () => {
    els.importPetpackInput?.click();
  });
  els.importPetpackInput?.addEventListener("change", async () => {
    await importPetpackFile(els.importPetpackInput.files?.[0]);
    els.importPetpackInput.value = "";
  });
  els.exportBtn.addEventListener("click", exportPetpack);
  if (els.topExportBtn) els.topExportBtn.addEventListener("click", exportPetpack);
  els.publishCdkeyPack?.addEventListener("click", publishCdkeyPack);
  els.openCdkeyPackages?.addEventListener("click", () => toggleCdkeyPackagesPage());
  bindCdkeyQuotaProductButtons();
  els.cdkeyBatchQuantity?.addEventListener("input", () => updateCdkeyPublishControls());
  els.createCdkeyBatch?.addEventListener("click", createCdkeyBatch);
  els.downloadCdkeyCsv?.addEventListener("click", downloadCdkeyCsv);
  if (els.clearDraftBtn) els.clearDraftBtn.addEventListener("click", handleClearDraftClick);
}

initializeState();
trackDiyEvent("diy_opened");
restoreDraft();
renderAllAnimationCards();
bindEvents();
updateDescriptionCounter();
updatePermissionPanel();
updateEntitlementControls();
refreshPaymentProducts();
refreshReview();
