(function () {
  const PLUGIN_ID = 'MultiactorEditor';
  const ACTOR_NAMES_PROPERTY = 'multiactor_actor_names';
  const ACTOR_TEXTURES_PROPERTY = 'multiactor_actor_textures';
  const BONE_TEXTURE_OVERRIDES_PROPERTY = 'multiactor_bone_texture_overrides';
  const ACTOR_ANIMDEF_METADATA_PROPERTY = 'multiactor_actor_animdef_metadata';

  let importAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
  let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty;
  let actorTexturePreviewProjectListener;
  const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path'];
  let actorTexturePreviewTimeouts = [];

  // ----------------------------
  // Helpers
  // ----------------------------
  const num = (n, fallback = 0) => {
    const v = Number(n);
    return Number.isFinite(v) ? v : fallback;
  };

  const v3 = (arr, fallback = 0) =>
    Array.isArray(arr) ? [num(arr[0], fallback), num(arr[1], fallback), num(arr[2], fallback)] : [fallback, fallback, fallback];

  const sanitizeFilePart = (name) => {
    let s = String(name || 'animation').trim();

    // Windows-invalid filename chars + backslash without using escapes
    const backslash = String.fromCharCode(92);
    const bad = new Set([backslash, '/', ':', '*', '?', '"', '<', '>', '|']);

    let out = '';
    for (const ch of s) out += bad.has(ch) ? '_' : ch;

    out = out.trim();
    while (out.indexOf('  ') !== -1) out = out.split('  ').join(' ');
    return out;
  };

  const clone = (obj) => JSON.parse(JSON.stringify(obj));

  function getProjectStringMap(propertyName) {
    if (!Project) return {};

    const raw = Project[propertyName];
    if (typeof raw !== 'string' || !raw.trim()) return {};

    try {
      const parsed = JSON.parse(raw);
      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};

      const map = {};
      for (const [key, value] of Object.entries(parsed)) {
        const idx = parseInt(key, 10);
        const text = String(value || '').trim();
        if (Number.isFinite(idx) && idx > 0 && text) map[String(idx)] = text;
      }
      return map;
    } catch (e) {
      console.warn(`Could not parse saved project map "${propertyName}":`, e);
      return {};
    }
  }

  function setProjectStringMap(propertyName, values) {
    if (!Project) return;

    const clean = {};
    for (const [key, value] of Object.entries(values || {})) {
      const idx = parseInt(key, 10);
      const text = String(value || '').trim();
      if (Number.isFinite(idx) && idx > 0 && text) clean[String(idx)] = text;
    }

    Project[propertyName] = JSON.stringify(clean);
    Project.saved = false;
  }

  function getProjectActorNames() {
    return getProjectStringMap(ACTOR_NAMES_PROPERTY);
  }

  function setProjectActorNames(names) {
    setProjectStringMap(ACTOR_NAMES_PROPERTY, names);
  }

  function getProjectActorTextures() {
    return getProjectStringMap(ACTOR_TEXTURES_PROPERTY);
  }

  function setProjectActorTextures(textures) {
    setProjectStringMap(ACTOR_TEXTURES_PROPERTY, textures);
  }

  function uniqueTrimmedStrings(values) {
    const out = [];
    const seen = new Set();
    for (const value of Array.isArray(values) ? values : []) {
      const text = String(value || '').trim();
      if (!text || seen.has(text)) continue;
      seen.add(text);
      out.push(text);
    }
    return out;
  }

  function parseCommaSeparatedStrings(value) {
    return uniqueTrimmedStrings(String(value || '').split(','));
  }

  function cleanActorAnimdefMetadata(value) {
    const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
    const out = {};
    const entityTypes = uniqueTrimmedStrings(source.entity_types);
    const actorTags = uniqueTrimmedStrings(source.actor_tags);
    const actorTagsAny = uniqueTrimmedStrings(source.actor_tags_any);
    const entityVariant = String(source.entity_variant || '').trim();
    const activity = String(source.activity || '').trim().toLowerCase();
    const injector = String(source.injector || '').trim().toUpperCase();
    const propLeft = String(source.prop_left || '').trim();
    const propRight = String(source.prop_right || '').trim();

    if (entityTypes.length) out.entity_types = entityTypes;
    if (entityVariant) out.entity_variant = entityVariant;
    if (actorTags.length) out.actor_tags = actorTags;
    if (actorTagsAny.length) out.actor_tags_any = actorTagsAny;
    if (activity === 'active' || activity === 'passive') out.activity = activity;
    if (injector === 'V' || injector === 'A' || injector === 'M') out.injector = injector;
    if (source.receiver === true) out.receiver = true;
    if (propLeft) out.prop_left = propLeft;
    if (propRight) out.prop_right = propRight;
    return out;
  }

  function getProjectActorAnimdefMetadata() {
    if (!Project) return {};

    const raw = Project[ACTOR_ANIMDEF_METADATA_PROPERTY];
    if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {};

    try {
      const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};

      const map = {};
      for (const [key, value] of Object.entries(parsed)) {
        const idx = parseInt(key, 10);
        if (!Number.isFinite(idx) || idx <= 0) continue;
        map[String(idx)] = cleanActorAnimdefMetadata(value);
      }
      return map;
    } catch (e) {
      console.warn(`Could not parse saved project map "${ACTOR_ANIMDEF_METADATA_PROPERTY}":`, e);
      return {};
    }
  }

  function setProjectActorAnimdefMetadata(metadata) {
    if (!Project) return;

    const clean = {};
    for (const [key, value] of Object.entries(metadata || {})) {
      const idx = parseInt(key, 10);
      if (!Number.isFinite(idx) || idx <= 0) continue;
      const entry = cleanActorAnimdefMetadata(value);
      if (Object.keys(entry).length) clean[String(idx)] = entry;
    }

    Project[ACTOR_ANIMDEF_METADATA_PROPERTY] = JSON.stringify(clean);
    Project.saved = false;
  }

  function getProjectBoneTextureOverrides() {
    if (!Project) return {};

    const raw = Project[BONE_TEXTURE_OVERRIDES_PROPERTY];
    if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {};

    try {
      const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};

      const map = {};
      for (const [key, value] of Object.entries(parsed)) {
        if (!value || typeof value !== 'object' || Array.isArray(value)) continue;

        const uuid = String(value.uuid || key || '').trim();
        const name = String(value.name || '').trim();
        const texture = String(value.texture || '').trim();
        const mapKey = uuid || name;
        if (mapKey && texture) map[mapKey] = { uuid, name, texture };
      }
      return map;
    } catch (e) {
      console.warn(`Could not parse saved project map "${BONE_TEXTURE_OVERRIDES_PROPERTY}":`, e);
      return {};
    }
  }

  function setProjectBoneTextureOverrides(overrides) {
    if (!Project) return;

    const clean = {};
    for (const value of Object.values(overrides || {})) {
      if (!value || typeof value !== 'object' || Array.isArray(value)) continue;

      const uuid = String(value.uuid || '').trim();
      const name = String(value.name || '').trim();
      const texture = String(value.texture || '').trim();
      const key = uuid || name;
      if (key && texture) clean[key] = { uuid, name, texture };
    }

    Project[BONE_TEXTURE_OVERRIDES_PROPERTY] = JSON.stringify(clean);
    Project.saved = false;
  }

  function getActorExportLabel(actorIndex) {
    const names = getProjectActorNames();
    const saved = String(names[String(actorIndex)] || '').trim();
    return saved || `actor${actorIndex}`;
  }

  function getActorSelectLabel(actorIndex) {
    const label = getActorExportLabel(actorIndex);
    const fallback = `actor${actorIndex}`;
    return label === fallback ? fallback : `${fallback} (${label})`;
  }

  function buildActorSelectOptions() {
    const indices = getSortedUsedActorIndicesFromProject();
    const options = {};
    for (const idx of indices) options[String(idx)] = getActorSelectLabel(idx);
    return options;
  }

  const GENERIC_ACTOR_LABELS = new Set(['actor', 'top', 'bottom', 'anchor', 'root', 'entity', 'mob']);
  const RESOURCE_ID_PATTERN = /^[a-z0-9_.-]+:[a-z0-9/._-]+$/;

  function inferEntityTypeFromActorLabel(label) {
    let candidate = String(label || '').trim().toLowerCase();
    candidate = candidate.replace(/[_-]?\d+$/, '');
    if (!candidate || GENERIC_ACTOR_LABELS.has(candidate)) return '';
    if (!/^[a-z0-9_]+$/.test(candidate)) return '';
    return `minecraft:${candidate}`;
  }

  function actorAnimdefMetadataWithInference(actorIndex, metadata) {
    const clean = cleanActorAnimdefMetadata(metadata);
    if (Array.isArray(clean.entity_types) && clean.entity_types.length) return clean;

    const inferred = inferEntityTypeFromActorLabel(getActorExportLabel(actorIndex));
    return inferred ? { ...clean, entity_types: [inferred] } : clean;
  }

  function addActorAnimdefFormFields(form, prefix, actorIndex, actorLabel, metadata, defaultActivity, condition) {
    const base = `${prefix}_${actorIndex}`;
    const fieldCondition = condition || undefined;
    form[`${base}_heading`] = {
      type: 'info',
      text: `actor${actorIndex}: ${actorLabel}`,
      condition: fieldCondition,
    };
    form[`${base}_entity_types`] = {
      label: 'Entity types',
      type: 'text',
      value: (metadata.entity_types || []).join(', '),
      placeholder: 'minecraft:wolf',
      description: 'Comma-separated entity IDs.',
      condition: fieldCondition,
    };
    form[`${base}_entity_variant`] = {
      label: 'Entity variant',
      type: 'text',
      value: metadata.entity_variant || '',
      placeholder: 'size_0',
      condition: fieldCondition,
    };
    form[`${base}_actor_tags`] = {
      label: 'Required actor tags',
      type: 'text',
      value: (metadata.actor_tags || []).join(', '),
      placeholder: 'gender.male',
      condition: fieldCondition,
    };
    form[`${base}_actor_tags_any`] = {
      label: 'Any actor tags',
      type: 'text',
      value: (metadata.actor_tags_any || []).join(', '),
      placeholder: 'tag.one, tag.two',
      condition: fieldCondition,
    };
    form[`${base}_activity`] = {
      label: 'Activity',
      type: 'select',
      options: { active: 'Active', passive: 'Passive' },
      value: metadata.activity || defaultActivity,
      condition: fieldCondition,
    };
    form[`${base}_injector`] = {
      label: 'Injector role',
      type: 'select',
      options: { '': 'None', V: 'V', A: 'A', M: 'M' },
      value: metadata.injector || '',
      condition: fieldCondition,
    };
    form[`${base}_receiver`] = {
      label: 'Explicit receiver',
      type: 'checkbox',
      value: metadata.receiver === true,
      description: 'An actor cannot be both an injector and an explicit receiver.',
      condition: fieldCondition,
    };
    form[`${base}_prop_left`] = {
      label: 'Default left prop',
      type: 'text',
      value: metadata.prop_left || '',
      placeholder: 'minecraft:carrot',
      condition: fieldCondition,
    };
    form[`${base}_prop_right`] = {
      label: 'Default right prop',
      type: 'text',
      value: metadata.prop_right || '',
      placeholder: 'minecraft:carrot',
      condition: fieldCondition,
    };
  }

  function readActorAnimdefFormFields(result, prefix, actorIndex) {
    const base = `${prefix}_${actorIndex}`;
    return cleanActorAnimdefMetadata({
      entity_types: parseCommaSeparatedStrings(result?.[`${base}_entity_types`]),
      entity_variant: result?.[`${base}_entity_variant`],
      actor_tags: parseCommaSeparatedStrings(result?.[`${base}_actor_tags`]),
      actor_tags_any: parseCommaSeparatedStrings(result?.[`${base}_actor_tags_any`]),
      activity: result?.[`${base}_activity`],
      injector: result?.[`${base}_injector`],
      receiver: !!result?.[`${base}_receiver`],
      prop_left: result?.[`${base}_prop_left`],
      prop_right: result?.[`${base}_prop_right`],
    });
  }

  function validateActorAnimdefMetadata(metadata, actorLabel) {
    for (const entityType of metadata.entity_types || []) {
      if (!RESOURCE_ID_PATTERN.test(entityType)) {
        throw new Error(`Actor "${actorLabel}" has invalid entity type "${entityType}". Use namespace:path.`);
      }
    }
    if (metadata.entity_variant && !/^[a-z0-9_]+$/.test(metadata.entity_variant)) {
      throw new Error(`Actor "${actorLabel}" has invalid entity variant "${metadata.entity_variant}".`);
    }
    for (const [key, value] of [
      ['left prop', metadata.prop_left],
      ['right prop', metadata.prop_right],
    ]) {
      if (value && !RESOURCE_ID_PATTERN.test(value)) {
        throw new Error(`Actor "${actorLabel}" has invalid ${key} item ID "${value}". Use namespace:path.`);
      }
    }
    if (metadata.injector && metadata.receiver) {
      throw new Error(`Actor "${actorLabel}" cannot be both injector ${metadata.injector} and an explicit receiver.`);
    }
  }

  function getSortedUsedActorIndicesFromProject() {
    return Array.from(getUsedActorIndicesFromProject()).sort((a, b) => a - b);
  }

  const convertPos = (p) => {
    const x = p?.[0] ?? 0,
      y = p?.[1] ?? 0,
      z = p?.[2] ?? 0;
    return [-x, y, z];
  };

  const convertRot = (r) => {
    const x = r?.[0] ?? 0,
      y = r?.[1] ?? 0,
      z = r?.[2] ?? 0;
    return [-x, -y, z];
  };

  function readActorIndexFromName(name) {
    if (typeof name !== 'string' || !name.startsWith('actor')) return null;

    let i = 5; // after 'actor'
    let digits = '';
    while (i < name.length) {
      const c = name[i];
      if (c < '0' || c > '9') break;
      digits += c;
      i++;
    }
    if (!digits || name[i] !== '_') return null;

    const idx = parseInt(digits, 10);
    return Number.isFinite(idx) && idx > 0 ? idx : null;
  }

  function getUsedActorIndicesFromProject() {
    const used = new Set();
    for (const g of Group.all) {
      const idx = readActorIndexFromName(g?.name || '');
      if (idx) used.add(idx);
    }
    return used;
  }

  function getNextActorIndex() {
    const used = getUsedActorIndicesFromProject();
    let i = 1;
    while (used.has(i)) i++;
    return i;
  }

  function getMaxActorIndexFromProject() {
    let max = 0;
    for (const g of Group.all) {
      const idx = readActorIndexFromName(g?.name || '');
      if (idx && idx > max) max = idx;
    }
    return max;
  }

  function extractGeckoGeometryRoot(json) {
    return json?.['minecraft:geometry']?.[0] || null;
  }

  function applyPerFaceUVWithXFlip(cube, uvObj) {
    if (!cube?.faces || !uvObj || typeof uvObj !== 'object') return;

    const uv = { ...uvObj };
    const tmp = uv.east;
    uv.east = uv.west;
    uv.west = tmp;

    const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
    for (const face of faces) {
      const f = uv[face];
      if (!f) continue;

      const a = Array.isArray(f.uv) ? f.uv : null;
      const s = Array.isArray(f.uv_size) ? f.uv_size : null;
      if (!a || !s) continue;

      const u1 = num(a[0], 0);
      const v1 = num(a[1], 0);
      const w = num(s[0], 0);
      const h = num(s[1], 0);

      if (cube.faces[face]) cube.faces[face].uv = [u1, v1, u1 + w, v1 + h];
    }

    cube.updateUV?.();
  }

  // ----------------------------
  // Import Actor Geometry
  // ----------------------------
  function importGeometryIntoCurrentProject(geo, actorIndex) {
    const bones = Array.isArray(geo?.bones) ? geo.bones : [];
    if (!bones.length) {
      Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
      return;
    }

    const prefix = `actor${actorIndex}_`;

    Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], animations: [] });

    const boneByName = new Map();
    for (const b of bones) if (b?.name) boneByName.set(b.name, b);

    const groupByBoneName = new Map();
    for (const boneName of boneByName.keys()) {
      const g = new Group({ name: prefix + boneName });
      g.init();
      groupByBoneName.set(boneName, g);
    }

    // parenting
    for (const [boneName, b] of boneByName) {
      const g = groupByBoneName.get(boneName);
      const parentGroup = b?.parent ? groupByBoneName.get(b.parent) : null;
      g.addTo(parentGroup || 'root');
    }

    // pivots + rotations
    for (const [boneName, b] of boneByName) {
      const g = groupByBoneName.get(boneName);
      g.origin = convertPos(v3(b.pivot, 0));
      g.rotation = convertRot(v3(b.rotation, 0));
    }

    // cubes
    for (const [boneName, b] of boneByName) {
      const g = groupByBoneName.get(boneName);
      const cubes = Array.isArray(b?.cubes) ? b.cubes : [];

      for (let i = 0; i < cubes.length; i++) {
        const c = cubes[i];
        const origin = v3(c?.origin, 0);
        const size = v3(c?.size, 0);

        const p1 = convertPos(origin);
        const p2 = convertPos([origin[0] + size[0], origin[1] + size[1], origin[2] + size[2]]);

        const from = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1]), Math.min(p1[2], p2[2])];
        const to = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1]), Math.max(p1[2], p2[2])];

        const cube = new Cube({
          name: `${prefix}${boneName}_cube${i + 1}`,
          from,
          to,
          inflate: num(c?.inflate, 0),
        });

        cube.init();
        cube.addTo(g);

        if (c?.pivot) cube.origin = convertPos(v3(c.pivot, 0));
        if (c?.rotation) cube.rotation = convertRot(v3(c.rotation, 0));

        if (Array.isArray(c?.uv)) {
          cube.box_uv = true;
          cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)];
        } else if (c?.uv && typeof c.uv === 'object') {
          cube.box_uv = false;
          applyPerFaceUVWithXFlip(cube, c.uv);
        }
      }
    }

    Canvas.updateAll();
    applyAllActorTexturePreviews(false);
    Undo.finishEdit(`Import Actor ${actorIndex}`);

    Blockbench.showQuickMessage(`Imported actor with prefix "${prefix}"`, 2500);
  }

  function importActorGeckoJson() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }

    Filesystem.importFile(
      {
        type: 'GeckoLib / Bedrock Geometry JSON',
        extensions: ['json', 'geo.json'],
        multiple: false,
        readtype: 'text',
        resource_id: 'model',
        title: 'Import Actor (.json / .geo.json) with actorN_ prefix',
      },
      (files) => {
        if (!files?.length) return;

        try {
          const json = JSON.parse(files[0]?.content || '');
          const geo = extractGeckoGeometryRoot(json);

          if (!geo) {
            Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000);
            return;
          }

          importGeometryIntoCurrentProject(geo, getNextActorIndex());
        } catch (e) {
          console.error(e);
          Blockbench.showQuickMessage(`Import failed: ${e?.message || e}`, 4000);
        }
      }
    );
  }

  // ----------------------------
  // Actor Preferences
  // ----------------------------
  function editActorPreferences() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }

    const actorIndices = getSortedUsedActorIndicesFromProject();
    if (!actorIndices.length) {
      Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
      return;
    }

    const names = getProjectActorNames();
    const textures = getProjectActorTextures();
    const animdefMetadata = getProjectActorAnimdefMetadata();
    const boneTextureOverrides = getProjectBoneTextureOverrides();
    const textureOverrideGroups = getGroupsForTextureOverrides();
    const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : [])
      .map((t) => getTextureDisplayName(t))
      .filter((name) => name);
    const textureOptions = buildTextureSelectOptions(textures, boneTextureOverrides);
    let showBoneTextureOverrides = false;
    let showAnimdefMetadata = false;

    const form = {
      _info: {
        type: 'info',
        text:
          'Set export names, actor preview textures, animation-definition metadata, and folder texture overrides for this project. Folder overrides apply recursively and take priority over actor textures. Loaded textures: ' +
          (loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'),
      },
    };

    for (const idx of actorIndices) {
      form[`name_${idx}`] = {
        label: `actor${idx} export name`,
        type: 'text',
        value: names[String(idx)] || '',
        placeholder: `actor${idx}`,
      };
      form[`texture_${idx}`] = {
        label: `actor${idx} preview texture`,
        type: 'select',
        options: textureOptions,
        value: textures[String(idx)] || '',
      };
    }

    form.show_animdef_metadata = {
      label: 'Animation definition metadata',
      type: 'checkbox',
      style: 'toggle_switch',
      value: showAnimdefMetadata,
      description: 'Show persistent AFW actor constraints used by animation-definition stub exports.',
    };
    form._animdef_info = {
      type: 'info',
      text: 'AFW actor metadata',
      condition: (result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
    };
    actorIndices.forEach((idx, actorPosition) => {
      const label = names[String(idx)] || `actor${idx}`;
      const metadata = actorAnimdefMetadataWithInference(idx, animdefMetadata[String(idx)]);
      addActorAnimdefFormFields(
        form,
        'preferences_animdef',
        idx,
        label,
        metadata,
        actorPosition === 0 ? 'active' : 'passive',
        (result) => result?.show_animdef_metadata ?? showAnimdefMetadata
      );
    });

    if (textureOverrideGroups.length) {
      form.show_bone_texture_overrides = {
        label: 'Folder texture overrides',
        type: 'checkbox',
        style: 'toggle_switch',
        value: showBoneTextureOverrides,
        description: 'Show recursive folder-level texture overrides. These take priority over actor textures.',
      };
      form._bone_info = {
        type: 'info',
        text: 'Folder texture overrides',
        condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
      };
    }

    for (let i = 0; i < textureOverrideGroups.length; i++) {
      const group = textureOverrideGroups[i];
      const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
      form[`bone_texture_${i}`] = {
        label: getGroupTextureOverrideLabel(group),
        type: 'select',
        options: textureOptions,
        value: saved?.texture || '',
        condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
      };
    }

    const dialog = new Dialog({
      id: 'edit_actor_preferences',
      title: 'Actor Preferences',
      width: 720,
      form,
      buttons: ['Cancel', 'Save & Apply'],
      cancelIndex: 0,
      confirmIndex: 1,
      onFormChange: (result) => {
        showBoneTextureOverrides = !!result?.show_bone_texture_overrides;
        showAnimdefMetadata = !!result?.show_animdef_metadata;
      },
      onConfirm: (result) => {
        try {
          const outNames = {};
          const outTextures = {};
          const outAnimdefMetadata = {};
          const outBoneTextureOverrides = {};
          for (const idx of actorIndices) {
            const name = String(result?.[`name_${idx}`] || '').trim();
            const texture = String(result?.[`texture_${idx}`] || '').trim();
            if (name) outNames[String(idx)] = name;
            if (texture) outTextures[String(idx)] = texture;
            const metadata = result?.show_animdef_metadata
              ? readActorAnimdefFormFields(result, 'preferences_animdef', idx)
              : cleanActorAnimdefMetadata(animdefMetadata[String(idx)]);
            validateActorAnimdefMetadata(metadata, name || `actor${idx}`);
            if (Object.keys(metadata).length) outAnimdefMetadata[String(idx)] = metadata;
          }
          for (let i = 0; i < textureOverrideGroups.length; i++) {
            const group = textureOverrideGroups[i];
            const key = getGroupTextureOverrideKey(group);
            const resultKey = `bone_texture_${i}`;
            const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
            const rawTexture = Object.prototype.hasOwnProperty.call(result || {}, resultKey) ? result[resultKey] : saved?.texture;
            const texture = String(rawTexture || '').trim();
            if (texture) {
              outBoneTextureOverrides[key] = {
                uuid: String(group.uuid || '').trim(),
                name: String(group.name || '').trim(),
                texture,
              };
            }
          }
          setProjectActorNames(outNames);
          setProjectActorTextures(outTextures);
          setProjectActorAnimdefMetadata(outAnimdefMetadata);
          setProjectBoneTextureOverrides(outBoneTextureOverrides);
          applyAllActorTexturePreviews(true);
          return true;
        } catch (e) {
          Blockbench.showMessageBox({
            title: 'Actor Preferences',
            message: e?.message || String(e),
            buttons: ['OK'],
          });
          return false;
        }
      },
    });

    dialog.show();
  }

  function editBoneTextureOverrides() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }

    const groups = getGroupsForTextureOverrides();
    if (!groups.length) {
      Blockbench.showQuickMessage('No folders found in this project.', 2500);
      return;
    }

    const boneTextureOverrides = getProjectBoneTextureOverrides();
    const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : [])
      .map((t) => getTextureDisplayName(t))
      .filter((name) => name);
    const textureOptions = buildTextureSelectOptions({}, boneTextureOverrides);

    const form = {
      _info: {
        type: 'info',
        text:
          'Assign preview/export texture overrides to individual folders. Overrides apply recursively to child folders and cubes. Loaded textures: ' +
          (loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'),
      },
    };

    for (let i = 0; i < groups.length; i++) {
      const group = groups[i];
      const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
      form[`bone_texture_${i}`] = {
        label: getGroupTextureOverrideLabel(group),
        type: 'select',
        options: textureOptions,
        value: saved?.texture || '',
      };
    }

    const dialog = new Dialog({
      id: 'edit_bone_texture_overrides',
      title: 'Bone Texture Overrides',
      width: 720,
      form,
      buttons: ['Cancel', 'Save & Apply'],
      cancelIndex: 0,
      confirmIndex: 1,
      onConfirm: (result) => {
        const out = {};
        for (let i = 0; i < groups.length; i++) {
          const group = groups[i];
          const key = getGroupTextureOverrideKey(group);
          const texture = String(result?.[`bone_texture_${i}`] || '').trim();
          if (texture) {
            out[key] = {
              uuid: String(group.uuid || '').trim(),
              name: String(group.name || '').trim(),
              texture,
            };
          }
        }

        setProjectBoneTextureOverrides(out);
        applyAllActorTexturePreviews(true);
        return true;
      },
    });

    dialog.show();
  }

  // ----------------------------
  // Actor Textures
  // ----------------------------
  function getTextureDisplayName(texture) {
    return String(texture?.name || texture?.id || texture?.uuid || '').trim();
  }

  function buildTextureSelectOptions(savedTextures, savedBoneTextureOverrides) {
    const options = { '': '(none)' };
    const add = (name) => {
      const text = String(name || '').trim();
      if (text && !Object.prototype.hasOwnProperty.call(options, text)) options[text] = text;
    };

    const list = Array.isArray(Texture.all) ? Texture.all : [];
    for (const texture of list) add(getTextureDisplayName(texture));
    for (const textureName of Object.values(savedTextures || {})) add(textureName);
    for (const override of Object.values(savedBoneTextureOverrides || {})) add(override?.texture);

    return options;
  }

  function findTextureByActorTextureName(name) {
    const wanted = String(name || '').trim();
    if (!wanted) return null;

    const wantedLower = wanted.toLowerCase();
    const list = Array.isArray(Texture.all) ? Texture.all : [];

    return (
      list.find((t) => String(t?.name || '').trim().toLowerCase() === wantedLower) ||
      list.find((t) => String(t?.id || '').trim().toLowerCase() === wantedLower) ||
      list.find((t) => String(t?.uuid || '').trim().toLowerCase() === wantedLower) ||
      list.find((t) => String(t?.name || '').trim().toLowerCase().replace(/\.[^.]+$/, '') === wantedLower) ||
      null
    );
  }

  function groupBelongsToActor(group, actorIndex) {
    const prefix = `actor${actorIndex}_`;
    let g = group;
    while (g) {
      if (typeof g?.name === 'string' && g.name.startsWith(prefix)) return true;
      g = g.parent instanceof Group ? g.parent : null;
    }
    return false;
  }

  function getGroupsForTextureOverrides() {
    const groups = Array.isArray(Group.all) ? Group.all.filter((group) => group) : [];
    return groups.sort((a, b) => String(a?.name || '').localeCompare(String(b?.name || '')));
  }

  function getGroupTextureOverrideKey(group) {
    return String(group?.uuid || group?.name || '').trim();
  }

  function getGroupTextureOverrideLabel(group) {
    return String(group?.name || '(unnamed folder)').trim();
  }

  function getBoneTextureOverrideForGroup(group, overrides) {
    if (!group || !overrides) return null;

    const key = getGroupTextureOverrideKey(group);
    const direct = key ? overrides[key] : null;
    if (direct?.texture) return direct;

    const name = String(group.name || '').trim();
    if (!name) return null;

    return Object.values(overrides).find((override) => override?.name === name && override?.texture) || null;
  }

  function cubeBelongsToActor(cube, actorIndex) {
    const prefix = `actor${actorIndex}_`;
    if (typeof cube?.name === 'string' && cube.name.startsWith(prefix)) return true;
    return groupBelongsToActor(cube?.parent, actorIndex);
  }

  function getTextureMaterial(texture) {
    if (!texture) return null;
    if (typeof texture.getMaterial === 'function') return texture.getMaterial();
    if (typeof texture.getOwnMaterial === 'function') return texture.getOwnMaterial();
    return texture.material || null;
  }

  function forceActorTextureMaterial(cube, texture) {
    const material = getTextureMaterial(texture);
    if (!cube?.mesh || !material) return false;

    cube.mesh.material = material;
    cube.mesh.material.needsUpdate = true;
    cube.mesh.needsUpdate = true;
    cube.mesh.userData = cube.mesh.userData || {};
    cube.mesh.userData.multiactor_actor_texture_uuid = texture.uuid || texture.id || texture.name;
    return true;
  }

  function applyTextureToCube(cube, texture) {
    if (!cube || !texture) return;

    const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
    const textureId = texture.uuid || texture.id || texture.name;

    if (textureId && cube.faces) {
      for (const face of faces) {
        const cubeFace = cube.faces[face];
        if (!cubeFace) continue;
        cubeFace.texture = textureId;
      }
    }

    // In GeckoLib/single-texture formats, Blockbench can still render every cube with the selected/default texture.
    // So after updating the normal face data, force the actual Three.js mesh material for preview only.
    cube.preview_controller?.updateFaces?.(cube);
    cube.preview_controller?.updateUV?.(cube);
    cube.preview_controller?.updateGeometry?.(cube);
    forceActorTextureMaterial(cube, texture);

    // Some preview updates are deferred internally, so apply the material again on the next tick.
    setTimeout(() => forceActorTextureMaterial(cube, texture), 0);
  }

  function applyActorTexturePreview(actorIndex, textureName) {
    const texture = findTextureByActorTextureName(textureName);
    if (!texture) return { applied: 0, missing: true };

    let applied = 0;
    const cubes = Array.isArray(Cube.all) ? Cube.all : [];
    for (const cube of cubes) {
      if (!cubeBelongsToActor(cube, actorIndex)) continue;
      applyTextureToCube(cube, texture);
      applied++;
    }

    return { applied, missing: false };
  }

  function getNearestBoneTextureOverride(cube, overrides) {
    let group = cube?.parent instanceof Group ? cube.parent : null;
    while (group) {
      const override = getBoneTextureOverrideForGroup(group, overrides);
      if (override?.texture) return override;
      group = group.parent instanceof Group ? group.parent : null;
    }
    return null;
  }

  function applyBoneTextureOverrides() {
    const overrides = getProjectBoneTextureOverrides();
    const missing = [];
    let applied = 0;

    if (!Object.keys(overrides).length) return { applied, missing };

    const textureByName = {};
    const cubes = Array.isArray(Cube.all) ? Cube.all : [];
    for (const cube of cubes) {
      const override = getNearestBoneTextureOverride(cube, overrides);
      const textureName = String(override?.texture || '').trim();
      if (!textureName) continue;

      if (!Object.prototype.hasOwnProperty.call(textureByName, textureName)) {
        textureByName[textureName] = findTextureByActorTextureName(textureName);
      }

      const texture = textureByName[textureName];
      if (!texture) {
        if (!missing.includes(textureName)) missing.push(textureName);
        continue;
      }

      applyTextureToCube(cube, texture);
      applied++;
    }

    return { applied, missing };
  }

  function applyAllActorTexturePreviews(showMessage = false) {
    const textures = getProjectActorTextures();
    const missing = [];
    let applied = 0;

    for (const [idxText, textureName] of Object.entries(textures)) {
      const idx = parseInt(idxText, 10);
      if (!Number.isFinite(idx) || idx <= 0) continue;

      const result = applyActorTexturePreview(idx, textureName);
      applied += result.applied || 0;
      if (result.missing) missing.push(textureName);
    }

    const boneResult = applyBoneTextureOverrides();
    applied += boneResult.applied || 0;
    for (const textureName of boneResult.missing || []) {
      if (!missing.includes(textureName)) missing.push(textureName);
    }

    Canvas.updateAll?.();

    if (showMessage) {
      if (missing.length) {
        const missingMessage =
          'Applied texture preferences where possible. These textures are not loaded in this project:' +
          '\n\n' +
          missing.map((x) => `- ${x}`).join('\n');

        Blockbench.showMessageBox({
          title: 'Actor Preferences',
          message: missingMessage,
          buttons: ['OK'],
        });
      } else {
        Blockbench.showQuickMessage(`Applied texture preferences to ${applied} cubes`, 2500);
      }
    }
  }

  function clearScheduledActorTexturePreviewApplies() {
    for (const timeout of actorTexturePreviewTimeouts) clearTimeout(timeout);
    actorTexturePreviewTimeouts = [];
  }

  function hasProjectActorTexturePreviews() {
    return Object.keys(getProjectActorTextures()).length > 0 || Object.keys(getProjectBoneTextureOverrides()).length > 0;
  }

  function scheduleActorTexturePreviewApply() {
    clearScheduledActorTexturePreviewApplies();
    if (!Project || !hasProjectActorTexturePreviews()) return;

    // Project loading can rebuild preview meshes and load textures after project events fire.
    // Reapply for a short window so the saved actor texture preview wins over the selected texture.
    for (const delay of [0, 50, 250, 1000, 2500, 5000, 10000]) {
      const timeout = setTimeout(() => {
        if (Project && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false);
      }, delay);
      actorTexturePreviewTimeouts.push(timeout);
    }
  }

  // ----------------------------
  // Export Models
  // ----------------------------
  function getTextureExportPath(textureName) {
    const saved = String(textureName || '').trim();
    if (!saved) return '';
    if (saved.includes(':')) return saved.replace(/\\/g, '/');

    const texture = findTextureByActorTextureName(saved);
    if (texture) {
      const path = String(texture.path || texture.relative_path || '').replace(/\\/g, '/');
      if (path) {
        const assetsMatch = path.match(/(?:^|\/)assets\/([^/]+)\/(.+)$/i);
        if (assetsMatch) return `${assetsMatch[1]}:${assetsMatch[2]}`;

        const texturesIndex = path.toLowerCase().lastIndexOf('/textures/');
        if (texturesIndex !== -1) {
          return `minecraft:${path.slice(texturesIndex + 1)}`;
        }
      }

      if (typeof texture.javaTextureLink === 'function') {
        const link = String(texture.javaTextureLink() || '').trim();
        if (link) {
          const normalized = link.replace(/\\/g, '/');
          return /\.[a-z0-9]+$/i.test(normalized) ? normalized : `${normalized}.png`;
        }
      }
    }

    return saved.replace(/\\/g, '/');
  }

  function getTextureExportPathFromTexture(texture) {
    if (!texture) return '';
    return getTextureExportPath(texture.name || texture.id || texture.uuid);
  }

  function findTextureById(textureId) {
    const wanted = String(textureId || '').trim();
    if (!wanted) return null;

    const wantedLower = wanted.toLowerCase();
    const list = Array.isArray(Texture.all) ? Texture.all : [];
    return (
      list.find((t) => String(t?.uuid || '').trim().toLowerCase() === wantedLower) ||
      list.find((t) => String(t?.id || '').trim().toLowerCase() === wantedLower) ||
      list.find((t) => String(t?.name || '').trim().toLowerCase() === wantedLower) ||
      null
    );
  }

  function getCubeReadableTexture(cube) {
    const forcedTextureId = cube?.mesh?.userData?.multiactor_actor_texture_uuid;
    const forcedTexture = findTextureById(forcedTextureId);
    if (forcedTexture) return forcedTexture;

    const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
    for (const face of faces) {
      const textureId = cube?.faces?.[face]?.texture;
      const texture = findTextureById(textureId);
      if (texture) return texture;
    }

    return null;
  }

  function getReadableTexturePathFromCube(cube) {
    return getTextureExportPathFromTexture(getCubeReadableTexture(cube));
  }

  function findGroupForTextureOverride(overrideKey, override) {
    const groups = Array.isArray(Group.all) ? Group.all : [];
    const uuid = String(override?.uuid || overrideKey || '').trim();
    const name = String(override?.name || '').trim();

    return (
      groups.find((group) => String(group?.uuid || '').trim() === uuid) ||
      groups.find((group) => String(group?.name || '').trim() === name) ||
      null
    );
  }

  function buildAfwBoneTextureMap() {
    const overrides = getProjectBoneTextureOverrides();
    const out = {};
    const explicitBones = new Set();

    for (const [key, override] of Object.entries(overrides)) {
      const group = findGroupForTextureOverride(key, override);
      const boneName = String(group?.name || override?.name || '').trim();
      const texturePath = getTextureExportPath(override?.texture);
      if (boneName && texturePath) {
        out[boneName] = texturePath;
        explicitBones.add(boneName);
      }
    }

    const cubes = Array.isArray(Cube.all) ? Cube.all : [];
    const textureCounts = {};
    const boneTextureCounts = {};

    for (const cube of cubes) {
      const group = cube?.parent instanceof Group ? cube.parent : null;
      const boneName = String(group?.name || '').trim();
      const texturePath = getReadableTexturePathFromCube(cube);
      if (!boneName || !texturePath) continue;

      textureCounts[texturePath] = (textureCounts[texturePath] || 0) + 1;
      boneTextureCounts[boneName] = boneTextureCounts[boneName] || {};
      boneTextureCounts[boneName][texturePath] = (boneTextureCounts[boneName][texturePath] || 0) + 1;
    }

    let baseTexture = '';
    let baseCount = 0;
    for (const [texturePath, count] of Object.entries(textureCounts)) {
      if (count > baseCount) {
        baseTexture = texturePath;
        baseCount = count;
      }
    }

    for (const [boneName, counts] of Object.entries(boneTextureCounts)) {
      if (explicitBones.has(boneName)) continue;

      let dominantTexture = '';
      let dominantCount = 0;
      for (const [texturePath, count] of Object.entries(counts)) {
        if (count > dominantCount) {
          dominantTexture = texturePath;
          dominantCount = count;
        }
      }

      if (dominantTexture && dominantTexture !== baseTexture) out[boneName] = dominantTexture;
    }

    return out;
  }

  function addAfwBoneTextureMapToGeckoJson(json, afwBoneTextures) {
    if (!Object.keys(afwBoneTextures || {}).length) return json;

    const output = {};
    output.afw_bone_textures = afwBoneTextures;

    for (const [key, value] of Object.entries(json || {})) {
      if (key !== 'afw_bone_textures') output[key] = value;
    }

    return output;
  }

  function stringifyCompactModelJson(obj, indent = 2) {
    let i = 0;
    const tokenPrefix = '__COMPACT_MODEL_JSON__';
    const replacements = new Map();

    const isNumericArray = (value) =>
      Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number' && Number.isFinite(n));

    const isCompactCube = (value) => {
      if (!value || typeof value !== 'object' || Array.isArray(value)) return false;

      const keys = Object.keys(value);
      if (!keys.length) return false;

      const compactKeys = new Set(['origin', 'size', 'pivot', 'rotation', 'uv', 'inflate', 'mirror']);
      for (const key of keys) {
        const child = value[key];
        if (!compactKeys.has(key)) return false;
        if (Array.isArray(child)) {
          if (!isNumericArray(child)) return false;
        } else if (typeof child !== 'number' && typeof child !== 'boolean') {
          return false;
        }
      }

      return Array.isArray(value.origin) && Array.isArray(value.size);
    };

    const isCompactUvFace = (value) => {
      if (!value || typeof value !== 'object' || Array.isArray(value)) return false;

      const keys = Object.keys(value);
      if (!keys.length || keys.length > 3) return false;
      if (!Array.isArray(value.uv) || !Array.isArray(value.uv_size)) return false;

      for (const key of keys) {
        if (key === 'uv' || key === 'uv_size') {
          if (!isNumericArray(value[key])) return false;
        } else if (key !== 'texture') {
          return false;
        }
      }

      return true;
    };

    const inline = (value) => {
      if (isNumericArray(value)) return `[${value.join(', ')}]`;

      const parts = [];
      for (const [key, child] of Object.entries(value)) {
        const childText = isNumericArray(child) ? `[${child.join(', ')}]` : JSON.stringify(child);
        parts.push(`${JSON.stringify(key)}: ${childText}`);
      }
      return `{${parts.join(', ')}}`;
    };

    const json = JSON.stringify(
      obj,
      (key, value) => {
        if (isCompactCube(value) || isCompactUvFace(value) || isNumericArray(value)) {
          const token = `${tokenPrefix}${i++}__`;
          replacements.set(token, inline(value));
          return token;
        }
        return value;
      },
      indent
    );

    let out = json;
    for (const [token, text] of replacements) out = out.split(`"${token}"`).join(text);
    return out;
  }

  function getGeckoModelCodecCandidates() {
    const candidates = [];
    const add = (codec) => {
      if (codec && typeof codec.compile === 'function' && !candidates.includes(codec)) candidates.push(codec);
    };

    add(Format?.codec);
    add(Codecs?.bedrock);
    add(Codecs?.bedrock_old);

    if (Codecs && typeof Codecs === 'object') {
      for (const codec of Object.values(Codecs)) {
        const name = String(codec?.name || codec?.id || '').toLowerCase();
        if (name.includes('gecko') || name.includes('bedrock')) add(codec);
      }
    }

    return candidates;
  }

  function parseCompiledModelContent(content) {
    if (typeof content === 'string') return JSON.parse(content);
    if (content && typeof content === 'object') return clone(content);
    throw new Error('Model compiler returned empty content.');
  }

  function compileGeckoModelJson() {
    const candidates = getGeckoModelCodecCandidates();
    let lastError = null;

    for (const codec of candidates) {
      try {
        const options = typeof codec.getExportOptions === 'function' ? codec.getExportOptions() : undefined;
        const json = parseCompiledModelContent(codec.compile(options));
        if (Array.isArray(json?.['minecraft:geometry'])) return json;
      } catch (e) {
        lastError = e;
      }
    }

    throw new Error(lastError?.message || 'Could not find a GeckoLib/Bedrock model compiler for this project.');
  }

  function getModelExportFileName() {
    const codec = Format?.codec;
    const codecName = typeof codec?.fileName === 'function' ? codec.fileName() : '';
    const name = codecName || Project?.geometry_name || Project?.name || 'model';
    return sanitizeFilePart(String(name).replace(/\.geo\.json$/i, '').replace(/\.json$/i, '')) || 'model';
  }

  function exportGeckoModel() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }

    try {
      const compiled = compileGeckoModelJson();
      const afwBoneTextures = buildAfwBoneTextureMap();
      const output = addAfwBoneTextureMapToGeckoJson(compiled, afwBoneTextures);

      Blockbench.export(
        {
          type: 'GeckoLib Model JSON',
          extensions: ['json', 'geo.json'],
          name: `${getModelExportFileName()}.geo`,
          content: stringifyCompactModelJson(output, 2),
          savetype: 'text',
        },
        () => Blockbench.showQuickMessage('Exported GeckoLib model', 2500)
      );
    } catch (e) {
      console.error(e);
      Blockbench.showMessageBox({
        title: 'Export GeckoLib Model',
        message: `Export failed: ${e?.message || e}`,
        buttons: ['OK'],
      });
    }
  }

  // ----------------------------
  // Export Actor Animations
  // ----------------------------
  function getSelectedAnimation() {
    return Animation.selected || Timeline.animation || Animation.all?.[0] || null;
  }

  function stripPrefixFromAnimationFileForActor(fileObj, actorIndex) {
    const prefix = `actor${actorIndex}_`;
    const anims = fileObj?.animations;
    if (!anims) return;

    for (const animKey of Object.keys(anims)) {
      const anim = anims[animKey];
      if (!anim) continue;

      if (anim.bones) {
        const newBones = {};
        for (const [boneName, payload] of Object.entries(anim.bones)) {
          if (boneName.startsWith(prefix)) newBones[boneName.slice(prefix.length)] = payload;
        }
        anim.bones = newBones;
      }

      // De-prefix common string fields (locators/particles)
      const fixStrings = (node) => {
        if (!node || typeof node !== 'object') return;
        for (const k of Object.keys(node)) {
          const v = node[k];
          if (typeof v === 'string') {
            if ((k === 'bone' || k === 'locator') && v.startsWith(prefix)) node[k] = v.slice(prefix.length);
          } else if (v && typeof v === 'object') {
            fixStrings(v);
          }
        }
      };
      fixStrings(anim);
    }
  }

  function stringifyInlineVec3(obj, indent = 2) {
    let i = 0;
    const tokenPrefix = '__INLINE_VEC3__';
    const replacements = new Map();

    const json = JSON.stringify(
      obj,
      (key, value) => {
        if (Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === 'number' && Number.isFinite(n))) {
          const token = `${tokenPrefix}${i++}__`;
          replacements.set(token, `[${value[0]},${value[1]},${value[2]}]`);
          return token;
        }
        return value;
      },
      indent
    );

    let out = json;
    for (const [token, vecStr] of replacements) out = out.split(`"${token}"`).join(vecStr);
    return out;
  }

  function buildActorAnimationOutputsForAnimation(anim, maxActor) {
    if (typeof anim?.compileBedrockAnimation !== 'function') {
      throw new Error(`Animation compiler not available for "${anim?.name || 'animation'}" (missing compileBedrockAnimation).`);
    }

    const animKey = anim.name || 'animation.unnamed';
    const safeAnim = sanitizeFilePart(animKey);

    const compiled = anim.compileBedrockAnimation() || {};
    const baseFile = compiled.animations ? compiled : { format_version: '1.8.0', animations: { [animKey]: compiled } };

    const outputs = [];
    for (let i = 1; i <= maxActor; i++) {
      const f = clone(baseFile);
      stripPrefixFromAnimationFileForActor(f, i);
      const safeActor = sanitizeFilePart(getActorExportLabel(i)) || `actor${i}`;
      outputs.push({
        name: `${safeAnim}_${safeActor}.animation`,
        content: stringifyInlineVec3(f, 2),
      });
    }
    return outputs;
  }

  function exportActorAnimationOutputs(outputs, doneMessage) {
    let idx = 0;
    const next = () => {
      if (idx >= outputs.length) {
        Blockbench.showQuickMessage(doneMessage || `Exported ${outputs.length} actor animation files`, 2500);
        return;
      }
      const o = outputs[idx++];
      Blockbench.export(
        {
          type: 'JSON',
          extensions: ['json'],
          name: o.name,
          content: o.content,
          savetype: 'text',
        },
        next
      );
    };
    next();
  }

  function exportSelectedActorAnimation() {
    const anim = getSelectedAnimation();
    if (!anim) {
      Blockbench.showQuickMessage('No animation selected.', 2500);
      return;
    }

    const maxActor = getMaxActorIndexFromProject();
    if (maxActor < 1) {
      Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
      return;
    }

    try {
      const outputs = buildActorAnimationOutputsForAnimation(anim, maxActor);
      exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files`);
    } catch (e) {
      console.error(e);
      Blockbench.showMessageBox({
        title: 'Export Selected Animations (Split Files)',
        message: `Export failed: ${e?.message || e}`,
        buttons: ['OK'],
      });
    }
  }

  function exportAllActorAnimations() {
    const animations = Array.isArray(Animation.all) ? Animation.all.filter((a) => a) : [];
    if (!animations.length) {
      Blockbench.showQuickMessage('No animations found.', 2500);
      return;
    }

    const maxActor = getMaxActorIndexFromProject();
    if (maxActor < 1) {
      Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
      return;
    }

    try {
      const outputs = [];
      for (const anim of animations) outputs.push(...buildActorAnimationOutputsForAnimation(anim, maxActor));
      exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files from ${animations.length} animations`);
    } catch (e) {
      console.error(e);
      Blockbench.showMessageBox({
        title: 'Export All Animations (Split Files)',
        message: `Export failed: ${e?.message || e}`,
        buttons: ['OK'],
      });
    }
  }

  function parseAfwStageAnimationName(animation) {
    const name = String(animation?.name || '').trim();
    const match = /^([a-z0-9._-]+)\.p(\d+)$/.exec(name);
    if (!match) return null;
    return {
      id: match[1],
      stageNumber: Number(match[2]),
      name,
      animation,
    };
  }

  function collectConjoinedAnimationGroups() {
    const groupsById = new Map();
    const ignored = [];
    const animations = Array.isArray(Animation.all) ? Animation.all.filter((animation) => animation) : [];
    for (const animation of animations) {
      const stage = parseAfwStageAnimationName(animation);
      if (!stage) {
        ignored.push(String(animation?.name || 'unnamed'));
        continue;
      }
      if (!groupsById.has(stage.id)) groupsById.set(stage.id, []);
      groupsById.get(stage.id).push(stage);
    }

    const groups = Array.from(groupsById, ([id, stages]) => {
      stages.sort((a, b) => a.stageNumber - b.stageNumber || a.name.localeCompare(b.name));
      const seenStages = new Set();
      for (const stage of stages) {
        if (seenStages.has(stage.stageNumber)) {
          throw new Error(`Animation "${id}" contains more than one .p${stage.stageNumber} stage.`);
        }
        seenStages.add(stage.stageNumber);
      }
      return { id, stages };
    });
    groups.sort((a, b) => a.id.localeCompare(b.id));
    return { groups, ignored };
  }

  function compileConjoinedStage(stage) {
    const animation = stage?.animation;
    if (typeof animation?.compileBedrockAnimation !== 'function') {
      throw new Error(`Animation compiler not available for "${stage?.name || 'animation'}".`);
    }

    const compiled = animation.compileBedrockAnimation() || {};
    if (!compiled.animations) {
      return { formatVersion: '1.8.0', clip: clone(compiled) };
    }

    let clip = compiled.animations[stage.name];
    if (!clip) {
      const entries = Object.entries(compiled.animations);
      if (entries.length !== 1) {
        throw new Error(`Compiled animation "${stage.name}" did not contain a matching animation key.`);
      }
      clip = entries[0][1];
    }
    return {
      formatVersion: compiled.format_version || '1.8.0',
      clip: clone(clip),
    };
  }

  function getActorIndicesFromAnimationClip(clip) {
    const actors = new Set();
    for (const boneName of Object.keys(clip?.bones || {})) {
      const actorIndex = readActorIndexFromName(boneName);
      if (actorIndex) actors.add(actorIndex);
    }
    return actors;
  }

  function validateConjoinedActorLabels(actorIndices) {
    const labels = new Map();
    const used = new Set();
    for (const actorIndex of actorIndices) {
      const label = String(getActorExportLabel(actorIndex) || '').trim();
      if (!/^[a-z0-9._-]+$/.test(label)) {
        throw new Error(`Actor ${actorIndex} export name "${label}" must use only lowercase letters, numbers, ., _ or -.`);
      }
      if (used.has(label)) {
        throw new Error(`Actor export name "${label}" is assigned to more than one actor.`);
      }
      labels.set(actorIndex, label);
      used.add(label);
    }
    return labels;
  }

  function collectParticleOwnerIndices(node, owners, fieldName = '') {
    if (node == null) return;
    if (typeof node === 'string') {
      if (fieldName === 'locator' || fieldName === 'bone') {
        const actorIndex = readActorIndexFromName(node);
        if (actorIndex) owners.add(actorIndex);
      }
      return;
    }
    if (typeof node !== 'object') return;
    if (Array.isArray(node)) {
      for (const value of node) collectParticleOwnerIndices(value, owners, fieldName);
      return;
    }
    for (const [key, value] of Object.entries(node)) {
      collectParticleOwnerIndices(value, owners, key);
    }
  }

  function particleOwnerForEvent(event, stageName, cueTime) {
    const owners = new Set();
    collectParticleOwnerIndices(event, owners);
    if (owners.size !== 1) {
      const detail = owners.size === 0 ? 'no actor-prefixed locator' : 'locators from multiple actors';
      throw new Error(`Particle cue ${stageName}@${cueTime} has ${detail}. Use one actorN_ locator per cue.`);
    }
    return owners.values().next().value;
  }

  function filterParticleEffectsForActor(clip, actorIndex, stageName) {
    if (!clip?.particle_effects || typeof clip.particle_effects !== 'object') return;
    const filtered = {};
    for (const [cueTime, payload] of Object.entries(clip.particle_effects)) {
      const events = Array.isArray(payload) ? payload : [payload];
      const kept = events.filter((event) => particleOwnerForEvent(event, stageName, cueTime) === actorIndex);
      if (!kept.length) continue;
      filtered[cueTime] = Array.isArray(payload) ? kept : kept[0];
    }
    if (Object.keys(filtered).length) clip.particle_effects = filtered;
    else delete clip.particle_effects;
  }

  function buildConjoinedAnimationOutput(group) {
    const compiledStages = group.stages.map((stage) => ({ ...stage, ...compileConjoinedStage(stage) }));
    const actorSet = new Set();
    for (const stage of compiledStages) {
      for (const actorIndex of getActorIndicesFromAnimationClip(stage.clip)) actorSet.add(actorIndex);
    }
    const actorIndices = Array.from(actorSet).sort((a, b) => a - b);
    if (!actorIndices.length) {
      throw new Error(`Animation "${group.id}" has no animated actorN_ bones.`);
    }

    const actorLabels = validateConjoinedActorLabels(actorIndices);
    const soundOwner = actorIndices[0];
    const output = {
      format_version: compiledStages[0]?.formatVersion || '1.8.0',
      animations: {},
    };

    for (const stage of compiledStages) {
      for (const actorIndex of actorIndices) {
        const clip = clone(stage.clip);
        filterParticleEffectsForActor(clip, actorIndex, stage.name);
        if (actorIndex !== soundOwner) delete clip.sound_effects;

        const wrapped = { animations: { clip } };
        stripPrefixFromAnimationFileForActor(wrapped, actorIndex);
        output.animations[`p${stage.stageNumber}_${actorLabels.get(actorIndex)}`] = wrapped.animations.clip;
      }
    }

    return {
      name: `${sanitizeFilePart(group.id)}.animation`,
      content: stringifyInlineVec3(output, 2),
    };
  }

  function exportSelectedConjoinedAnimationGroups(groups) {
    try {
      const outputs = groups.map(buildConjoinedAnimationOutput);
      exportActorAnimationOutputs(
        outputs,
        `Exported ${outputs.length} animation file${outputs.length === 1 ? '' : 's'}`
      );
    } catch (e) {
      console.error(e);
      Blockbench.showMessageBox({
        title: 'Export Animations',
        message: `Export failed: ${e?.message || e}`,
        buttons: ['OK'],
      });
    }
  }

  function showConjoinedAnimationExportDialog() {
    let detected;
    try {
      detected = collectConjoinedAnimationGroups();
    } catch (e) {
      Blockbench.showMessageBox({
        title: 'Export Animations',
        message: e?.message || String(e),
        buttons: ['OK'],
      });
      return;
    }
    if (!detected.groups.length) {
      Blockbench.showMessageBox({
        title: 'Export Animations',
        message: 'No animations named <animationId>.p<number> were found in this project.',
        buttons: ['OK'],
      });
      return;
    }

    const form = {
      _info: {
        type: 'info',
        text: 'Select the animation IDs to export. Each selected ID becomes one .animation.json file.',
      },
    };
    detected.groups.forEach((group, index) => {
      form[`animation_${index}`] = {
        label: `${group.id} (${group.stages.length} stage${group.stages.length === 1 ? '' : 's'})`,
        type: 'checkbox',
        value: true,
      };
    });
    if (detected.ignored.length) {
      form._ignored = {
        type: 'info',
        text: `Ignored animations without a valid <animationId>.p<number> name: ${detected.ignored.join(', ')}`,
      };
    }

    const dialog = new Dialog({
      id: 'export_conjoined_actor_animations',
      title: 'Export Animations',
      width: 680,
      form,
      buttons: ['Cancel', 'Export Selected'],
      cancelIndex: 0,
      confirmIndex: 1,
      onConfirm: (result) => {
        const selected = detected.groups.filter((group, index) => !!result?.[`animation_${index}`]);
        if (!selected.length) {
          Blockbench.showQuickMessage('No animation IDs selected.', 2500);
          return false;
        }
        exportSelectedConjoinedAnimationGroups(selected);
        return true;
      },
    });
    dialog.show();
  }

  // ----------------------------
  // Create AFW Animation Definition Stub
  // ----------------------------
  function compactNumber(value) {
    return Number(Number(value).toFixed(6));
  }

  function parseLoopMode(value) {
    if (value === true) return true;
    if (value === false) return false;
    const normalized = String(value ?? '').trim().toLowerCase();
    if (normalized === 'loop' || normalized === 'true') return true;
    if (normalized === 'once' || normalized === 'hold' || normalized === 'hold_on_last_frame' || normalized === 'false') return false;
    return null;
  }

  function detectStageLoop(stage, clip) {
    const animationLoop = parseLoopMode(stage?.animation?.loop);
    if (animationLoop !== null) return animationLoop;
    const compiledLoop = parseLoopMode(clip?.loop);
    return compiledLoop === null ? false : compiledLoop;
  }

  function detectStageCycleSeconds(stage, clip) {
    for (const candidate of [clip?.animation_length, stage?.animation?.length]) {
      const value = Number(candidate);
      if (Number.isFinite(value) && value > 0) return compactNumber(value);
    }
    throw new Error(`Animation "${stage?.name || 'unknown'}" has no positive animation length.`);
  }

  function analyzeAnimationDefinitionGroup(group) {
    const actorSet = new Set();
    const warnings = [];
    const stages = group.stages.map((stage) => {
      const compiled = compileConjoinedStage(stage);
      const stageActors = getActorIndicesFromAnimationClip(compiled.clip);
      for (const actorIndex of stageActors) actorSet.add(actorIndex);
      if (!stageActors.size) warnings.push(`${stage.name} has no animated actorN_ bones.`);
      return {
        stageNumber: stage.stageNumber,
        name: stage.name,
        loop: detectStageLoop(stage, compiled.clip),
        cycleSeconds: detectStageCycleSeconds(stage, compiled.clip),
      };
    });

    const actorIndices = Array.from(actorSet).sort((a, b) => a - b);
    if (!actorIndices.length) throw new Error(`Animation "${group.id}" has no animated actorN_ bones.`);
    const actorLabels = validateConjoinedActorLabels(actorIndices);

    for (let i = 0; i < stages.length; i++) {
      const expected = i + 1;
      if (stages[i].stageNumber !== expected) {
        warnings.push(`Stages are not contiguous: expected p${expected}, found p${stages[i].stageNumber}.`);
        break;
      }
    }
    return { actorIndices, actorLabels, stages, warnings };
  }

  function parseRequiredPositiveNumber(value, label) {
    const parsed = Number(value);
    if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${label} must be a positive number.`);
    return compactNumber(parsed);
  }

  function parseOptionalNumber(value, label, validator) {
    const raw = String(value ?? '').trim();
    if (!raw) return null;
    const parsed = Number(raw);
    if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number.`);
    if (validator && !validator(parsed)) throw new Error(`${label} is outside its supported range.`);
    return compactNumber(parsed);
  }

  function buildActorConstraint(actorLabel, metadata) {
    validateActorAnimdefMetadata(metadata, actorLabel);

    const actor = { label: actorLabel };
    if (metadata.entity_types?.length) actor.entity_types = metadata.entity_types;
    if (metadata.entity_variant) actor.entity_variant = metadata.entity_variant;
    if (metadata.actor_tags?.length) actor.actor_tags = metadata.actor_tags;
    if (metadata.actor_tags_any?.length) actor.actor_tags_any = metadata.actor_tags_any;
    if (metadata.activity) actor.activity = metadata.activity;
    if (metadata.injector) actor.injector = metadata.injector;
    if (metadata.receiver) actor.receiver = true;
    if (metadata.prop_left) actor.prop_left = metadata.prop_left;
    if (metadata.prop_right) actor.prop_right = metadata.prop_right;
    return actor;
  }

  function readStageDefinitionFromForm(result, stage, isLastStage) {
    const base = `stub_stage_${stage.stageNumber}`;
    const loop = !!result?.[`${base}_loop`];
    const cycleSeconds = parseRequiredPositiveNumber(result?.[`${base}_cycle_seconds`], `Stage p${stage.stageNumber} cycle seconds`);
    const speed = parseOptionalNumber(result?.[`${base}_speed`], `Stage p${stage.stageNumber} speed`, (value) => value > 0);
    const midpointOffset = parseOptionalNumber(
      result?.[`${base}_midpoint_offset`],
      `Stage p${stage.stageNumber} midpoint offset`,
      () => loop
    );
    const stageSeconds = parseOptionalNumber(
      result?.[`${base}_stage_seconds`],
      `Stage p${stage.stageNumber} stage seconds`,
      (value) => Number.isInteger(value) && (value === -1 || (value >= 1 && value <= 300))
    );
    const durationMultiplier = parseOptionalNumber(
      result?.[`${base}_duration_multiplier`],
      `Stage p${stage.stageNumber} duration multiplier`,
      (value) => loop && value >= 0.05 && value <= 20
    );
    const escapable = Object.prototype.hasOwnProperty.call(result || {}, `${base}_escapable`)
      ? !!result[`${base}_escapable`]
      : !isLastStage;
    const peaked = Object.prototype.hasOwnProperty.call(result || {}, `${base}_peaked`)
      ? !!result[`${base}_peaked`]
      : isLastStage;

    const output = {
      stage: stage.stageNumber,
      loop,
      cycle_seconds: cycleSeconds,
      allow_join: Object.prototype.hasOwnProperty.call(result || {}, `${base}_allow_join`)
        ? !!result[`${base}_allow_join`]
        : !isLastStage,
    };
    if (midpointOffset !== null) output.cycle_midpoint_offset_seconds = midpointOffset;
    if (speed !== null) output.speed = speed;
    if (peaked) output.non_peak = true;
    output.escapable = escapable;
    if (stageSeconds !== null) output.stage_seconds = stageSeconds;
    if (durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier;
    return output;
  }

  function exportAnimationDefinitionStub(group, definition, actorMetadata) {
    const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata };
    setProjectActorAnimdefMetadata(mergedMetadata);
    Blockbench.export(
      {
        type: 'AFW Animation Definition',
        extensions: ['json'],
        name: sanitizeFilePart(group.id),
        content: JSON.stringify(definition, null, 2),
        savetype: 'text',
      },
      () =>
        Blockbench.showQuickMessage(
          `Created ${group.id}.json for data/<namespace>/afw_animdefs/`,
          3500
        )
    );
  }

  function confirmUnresolvedActorTypes(unresolvedLabels, onContinue) {
    if (!unresolvedLabels.length) {
      onContinue();
      return;
    }
    Blockbench.showMessageBox(
      {
        title: 'Unresolved Entity Types',
        message:
          `Could not infer entity types for: ${unresolvedLabels.join(', ')}.\n\n` +
          'The stub will omit entity_types for these actors, which makes them wildcard constraints. Manually add the correct entity_types before using this animation definition.',
        buttons: ['Cancel', 'Export Stub'],
      },
      (button) => {
        if (button === 1 || button === 'Export Stub') onContinue();
      }
    );
  }

  function showAnimationDefinitionReviewDialog(group, analysis) {
    const savedMetadata = getProjectActorAnimdefMetadata();
    const form = {
      _info: {
        type: 'info',
        text:
          `Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` +
          'Save it under data/<namespace>/afw_animdefs/. Actor metadata is stored in this Blockbench project for later exports.',
      },
    };
    if (analysis.warnings.length) {
      form._warnings = {
        type: 'info',
        text: `Warnings: ${analysis.warnings.join(' ')}`,
      };
    }

    form.display_name = {
      label: 'Display Name',
      type: 'text',
      value: '',
      placeholder: group.id,
      description: 'Optional player-facing name. AFW uses the animation ID when this is empty.',
    };

    form._actors = { type: 'info', text: 'Actor constraints' };
    analysis.actorIndices.forEach((actorIndex, actorPosition) => {
      const label = analysis.actorLabels.get(actorIndex);
      const metadata = actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]);
      addActorAnimdefFormFields(
        form,
        'stub_actor',
        actorIndex,
        label,
        metadata,
        actorPosition === 0 ? 'active' : 'passive'
      );
    });

    form._stages = { type: 'info', text: 'Stage metadata' };

    analysis.stages.forEach((stage, index) => {
      const base = `stub_stage_${stage.stageNumber}`;
      const isLastStage = index === analysis.stages.length - 1;
      const showAdvanced = (result) => !!result?.[`${base}_advanced`];
      const showAdvancedLoop = (result) =>
        showAdvanced(result) && (result?.[`${base}_loop`] ?? stage.loop);
      form[`${base}_heading`] = {
        type: 'info',
        text: `Stage p${stage.stageNumber}: detected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`,
      };
      form[`${base}_loop`] = {
        label: 'Loop',
        type: 'checkbox',
        value: stage.loop,
      };
      form[`${base}_cycle_seconds`] = {
        label: 'Cycle seconds',
        type: 'number',
        value: stage.cycleSeconds,
        min: 0.001,
        step: 0.001,
        description: 'Detected from the compiled GeckoLib animation_length.',
      };
      form[`${base}_allow_join`] = {
        label: 'Allow joins',
        type: 'checkbox',
        value: !isLastStage,
      };
      form[`${base}_escapable`] = {
        label: 'Escapable',
        type: 'checkbox',
        value: !isLastStage,
      };
      form[`${base}_peaked`] = {
        label: 'Peaked stage',
        type: 'checkbox',
        value: isLastStage,
        description: "Marks this as NoN's peaked stage.",
      };
      form[`${base}_advanced`] = {
        label: 'Advanced stage settings',
        type: 'checkbox',
        style: 'toggle_switch',
        value: false,
        description: 'Show optional playback and duration controls.',
      };
      form[`${base}_speed`] = {
        label: 'Playback speed',
        type: 'text',
        value: '',
        placeholder: 'Default (1.0)',
        condition: showAdvanced,
      };
      form[`${base}_stage_seconds`] = {
        label: 'Stage seconds',
        type: 'text',
        value: '',
        placeholder: 'Use NoN settings',
        condition: showAdvanced,
      };
      form[`${base}_midpoint_offset`] = {
        label: 'Cycle midpoint offset seconds',
        type: 'text',
        value: '',
        placeholder: 'Default (0.0)',
        condition: showAdvancedLoop,
      };
      form[`${base}_duration_multiplier`] = {
        label: 'Stage duration multiplier',
        type: 'text',
        value: '',
        placeholder: 'Default (1.0)',
        condition: showAdvancedLoop,
      };
    });

    const dialog = new Dialog({
      id: 'create_afw_animation_definition_review',
      title: `Create Animation Definition: ${group.id}`,
      width: 760,
      form,
      buttons: ['Cancel', 'Create Stub'],
      cancelIndex: 0,
      confirmIndex: 1,
      onConfirm: (result) => {
        try {
          const actorMetadata = {};
          const actors = [];
          const unresolvedLabels = [];
          for (const actorIndex of analysis.actorIndices) {
            const label = analysis.actorLabels.get(actorIndex);
            const metadata = readActorAnimdefFormFields(result, 'stub_actor', actorIndex);
            actorMetadata[String(actorIndex)] = metadata;
            if (!metadata.entity_types?.length) unresolvedLabels.push(label);
            actors.push(buildActorConstraint(label, metadata));
          }

          const stages = analysis.stages.map((stage, index) =>
            readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1)
          );
          const displayName = String(result?.display_name || '').trim();
          const definition = {};
          if (displayName) definition.display_name = displayName;
          definition.actors = actors;
          definition.stages = stages;
          confirmUnresolvedActorTypes(unresolvedLabels, () =>
            exportAnimationDefinitionStub(group, definition, actorMetadata)
          );
          return true;
        } catch (e) {
          Blockbench.showMessageBox({
            title: 'Create Animation Definition',
            message: e?.message || String(e),
            buttons: ['OK'],
          });
          return false;
        }
      },
    });
    dialog.show();
  }

  function showCreateAnimationDefinitionDialog() {
    let detected;
    try {
      detected = collectConjoinedAnimationGroups();
    } catch (e) {
      Blockbench.showMessageBox({
        title: 'Create Animation Definition',
        message: e?.message || String(e),
        buttons: ['OK'],
      });
      return;
    }
    if (!detected.groups.length) {
      Blockbench.showMessageBox({
        title: 'Create Animation Definition',
        message: 'No animations named <animationId>.p<number> were found in this project.',
        buttons: ['OK'],
      });
      return;
    }

    const options = {};
    for (const group of detected.groups) {
      options[group.id] = `${group.id} (${group.stages.length} stage${group.stages.length === 1 ? '' : 's'})`;
    }
    const selectedStage = parseAfwStageAnimationName(getSelectedAnimation());
    const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id;
    const dialog = new Dialog({
      id: 'create_afw_animation_definition',
      title: 'Create Animation Definition Stub',
      width: 620,
      form: {
        _info: {
          type: 'info',
          text: 'Select one animation ID. The plugin will detect its actors, stages, loop modes, and exact GeckoLib cycle lengths.',
        },
        animation_id: {
          label: 'Animation ID',
          type: 'select',
          options,
          value: defaultId,
        },
      },
      buttons: ['Cancel', 'Continue'],
      cancelIndex: 0,
      confirmIndex: 1,
      onConfirm: (result) => {
        try {
          const selectedId = String(result?.animation_id || '');
          const group = detected.groups.find((candidate) => candidate.id === selectedId);
          if (!group) throw new Error('No animation ID was selected.');
          const analysis = analyzeAnimationDefinitionGroup(group);
          showAnimationDefinitionReviewDialog(group, analysis);
          return true;
        } catch (e) {
          Blockbench.showMessageBox({
            title: 'Create Animation Definition',
            message: e?.message || String(e),
            buttons: ['OK'],
          });
          return false;
        }
      },
    });
    dialog.show();
  }

  // ----------------------------
  // Import Animations
  // ----------------------------
  function stripJsonSuffix(name) {
    const s = String(name || '');
    return s.toLowerCase().endsWith('.json') ? s.slice(0, -5) : s;
  }

  function stripAnimationSuffix(name) {
    const s = String(name || '');
    return s.toLowerCase().endsWith('.animation') ? s.slice(0, -10) : s;
  }

  function parseActorSuffix(base) {
    const marker = '_actor';
    const at = base.lastIndexOf(marker);
    if (at === -1) return null;

    let i = at + marker.length;
    let digits = '';
    while (i < base.length) {
      const c = base[i];
      if (c < '0' || c > '9') break;
      digits += c;
      i++;
    }

    if (!digits) return null;
    if (i !== base.length) return null;

    const idx = parseInt(digits, 10);
    return Number.isFinite(idx) && idx > 0 ? idx : null;
  }

  function splitWeakBaseAndLabel(base) {
    const s = String(base || '');
    const last = s.lastIndexOf('_');
    if (last <= 0 || last >= s.length - 1) return { base: null, label: null };
    return { base: s.slice(0, last), label: s.slice(last + 1) };
  }

  function getFilenameParts(fileName) {
    const rawBase = stripAnimationSuffix(stripJsonSuffix(fileName));

    // Strong rule: ..._actorN
    const actor = parseActorSuffix(rawBase);
    if (actor) {
      const marker = '_actor';
      const at = rawBase.lastIndexOf(marker);
      const strictBaseName = at === -1 ? rawBase : rawBase.slice(0, at);
      return {
        rawBase,
        strictActorIndex: actor,
        strictBaseName,
        weakBaseName: null,
        weakLabel: null,
      };
    }

    // Weak rule: split at last underscore: <anim>_<label>
    const w = splitWeakBaseAndLabel(rawBase);
    return {
      rawBase,
      strictActorIndex: null,
      strictBaseName: null,
      weakBaseName: w.base,
      weakLabel: w.label,
    };
  }

  function hasActorPrefix(name) {
    return readActorIndexFromName(name) !== null;
  }

  function addPrefixIfMissing(name, actorIndex) {
    return hasActorPrefix(name) ? name : `actor${actorIndex}_${name}`;
  }

  function prefixAnimationObject(animObj, actorIndex) {
    const a = clone(animObj || {});

    const bones = a?.bones && typeof a.bones === 'object' ? a.bones : null;
    if (!bones) throw new Error('Animation has no "bones" map.');

    const newBones = {};
    for (const boneName of Object.keys(bones)) {
      const newName = addPrefixIfMissing(boneName, actorIndex);
      if (newBones[newName]) throw new Error(`Bone collision inside one file: "${newName}"`);
      newBones[newName] = bones[boneName];
    }
    a.bones = newBones;

    const fixStrings = (node) => {
      if (!node) return;
      if (Array.isArray(node)) {
        for (const v of node) fixStrings(v);
        return;
      }
      if (typeof node !== 'object') return;

      for (const k of Object.keys(node)) {
        const v = node[k];
        if (typeof v === 'string' && (k === 'bone' || k === 'locator')) {
          node[k] = addPrefixIfMissing(v, actorIndex);
        } else {
          fixStrings(v);
        }
      }
    };
    fixStrings(a);

    return a;
  }

  function parseSplitStageAnimationName(name) {
    const match = /^(.+)\.p([1-9]\d*)$/.exec(String(name || '').trim());
    if (!match) return null;
    return {
      animationId: match[1],
      stageNumber: Number(match[2]),
    };
  }

  function parseSplitStageFilename(rawBase) {
    const match = /^(.+?\.p[1-9]\d*)_(.+)$/.exec(String(rawBase || '').trim());
    if (!match) return null;
    return {
      stageName: match[1],
      actorLabel: match[2],
    };
  }

  function describeSplitFileActor(parts, animKey, fileIndex) {
    const filenameIdentity = parseSplitStageFilename(parts.rawBase);
    if (filenameIdentity) {
      const actorMatch = /^actor([1-9]\d*)$/.exec(filenameIdentity.actorLabel);
      const actorGuess = actorMatch ? Number(actorMatch[1]) : null;
      return {
        stageName: filenameIdentity.stageName,
        key: actorGuess ? `actor:${actorGuess}` : `label:${filenameIdentity.actorLabel}`,
        label: filenameIdentity.actorLabel,
        actorGuess,
      };
    }

    if (parts.strictActorIndex) {
      return {
        stageName: parts.strictBaseName || animKey,
        key: `actor:${parts.strictActorIndex}`,
        label: `actor${parts.strictActorIndex}`,
        actorGuess: parts.strictActorIndex,
      };
    }

    const exactPrefix = `${animKey}_`;
    let label = parts.rawBase.startsWith(exactPrefix)
      ? parts.rawBase.slice(exactPrefix.length)
      : parts.weakLabel;
    label = String(label || '').trim();

    if (!label) {
      return {
        stageName: animKey,
        key: `file:${fileIndex}`,
        label: parts.rawBase || `File ${fileIndex + 1}`,
        actorGuess: null,
      };
    }

    const actorMatch = /^actor([1-9]\d*)$/.exec(label);
    return {
      stageName: animKey,
      key: `label:${label}`,
      label,
      actorGuess: actorMatch ? Number(actorMatch[1]) : null,
    };
  }

  function analyzeSplitAnimationFiles(parsed) {
    const stagesByName = new Map();
    const actorsByKey = new Map();

    for (let i = 0; i < parsed.length; i++) {
      const entry = parsed[i];
      const actor = describeSplitFileActor(entry.parts, entry.animKey, i);
      entry.actorKey = actor.key;
      entry.stageName = actor.stageName;

      let stage = stagesByName.get(entry.stageName);
      if (!stage) {
        stage = {
          animKey: entry.stageName,
          parsedName: parseSplitStageAnimationName(entry.stageName),
          firstFileIndex: i,
          entries: [],
        };
        stagesByName.set(entry.stageName, stage);
      }
      if (stage.entries.some((stageEntry) => stageEntry.actorKey === actor.key)) {
        throw new Error(`Stage "${entry.stageName}" contains more than one file for actor "${actor.label}".`);
      }
      stage.entries.push(entry);

      if (!actorsByKey.has(actor.key)) {
        actorsByKey.set(actor.key, { ...actor, fileCount: 1 });
      } else {
        actorsByKey.get(actor.key).fileCount++;
      }
    }

    const stages = Array.from(stagesByName.values());
    const parsedStageNames = stages.map((stage) => stage.parsedName);
    const commonAnimationId = parsedStageNames.length > 0
      && parsedStageNames.every((stage) => stage && stage.animationId === parsedStageNames[0].animationId)
      ? parsedStageNames[0].animationId
      : null;

    if (commonAnimationId) {
      stages.sort((a, b) => a.parsedName.stageNumber - b.parsedName.stageNumber);
    } else {
      stages.sort((a, b) => a.firstFileIndex - b.firstFileIndex);
    }

    return {
      stages,
      actors: Array.from(actorsByKey.values()),
      commonAnimationId,
    };
  }

  function buildSplitImportFile(analysis, actorMapping, requestedName, formatVersion) {
    const animations = {};
    const stageNames = [];

    for (const stage of analysis.stages) {
      let outputName;
      if (analysis.commonAnimationId) {
        outputName = `${requestedName}.p${stage.parsedName.stageNumber}`;
      } else if (analysis.stages.length === 1) {
        outputName = requestedName;
      } else {
        outputName = stage.animKey;
      }

      const mergeStage = {
        entries: stage.entries.map((entry) => ({
          actorLabel: entry.actorKey,
          animObj: entry.animObj,
        })),
      };
      animations[outputName] = mergeConjoinedStage(mergeStage, actorMapping, outputName);
      stageNames.push(outputName);
    }

    return {
      fileObj: {
        format_version: formatVersion || '1.8.0',
        animations,
      },
      stageNames,
    };
  }

  function parseConjoinedStageActorKey(key) {
    const match = /^p([1-9]\d*)_(.+)$/.exec(String(key || '').trim());
    if (!match || !/^[a-z0-9._-]+$/.test(match[2])) return null;
    return {
      stageNumber: Number(match[1]),
      actorLabel: match[2],
    };
  }

  function conjoinedAnimationIdFromFile(file) {
    const rawName = String(file?.name || file?.path || '').trim();
    const fileName = rawName.split(/[\\/]/).pop() || '';
    const match = /^([a-z0-9._-]+)\.animation\.json$/.exec(fileName);
    if (!match) {
      throw new Error(
        `Conjoined animation filename "${fileName || '(unknown)'}" must use <animationId>.animation.json.`
      );
    }
    return match[1];
  }

  function parseConjoinedAnimationFile(file) {
    const text = file?.content;
    if (typeof text !== 'string' || !text.trim()) {
      throw new Error(`Could not read file contents: ${file?.name || '(unknown)'}`);
    }

    const json = JSON.parse(text);
    const animations = json?.animations;
    if (!animations || typeof animations !== 'object' || Array.isArray(animations)) {
      throw new Error(`Missing "animations" map in ${file?.name || '(unknown)'}.`);
    }

    const animationId = conjoinedAnimationIdFromFile(file);
    const stagesByNumber = new Map();
    const actorLabels = [];
    const seenActorLabels = new Set();

    for (const [key, animObj] of Object.entries(animations)) {
      const parsedKey = parseConjoinedStageActorKey(key);
      if (!parsedKey) {
        throw new Error(
          `Animation key "${key}" does not use the expected p<number>_<actor name> format.`
        );
      }
      if (!animObj || typeof animObj !== 'object' || Array.isArray(animObj)) {
        throw new Error(`Invalid animation object for "${key}".`);
      }

      const stageNumber = parsedKey.stageNumber;
      const actorLabel = parsedKey.actorLabel;

      if (!stagesByNumber.has(stageNumber)) {
        stagesByNumber.set(stageNumber, { stageNumber, entries: [] });
      }
      const stage = stagesByNumber.get(stageNumber);
      if (stage.entries.some((entry) => entry.actorLabel === actorLabel)) {
        throw new Error(`Stage p${stageNumber} contains actor "${actorLabel}" more than once.`);
      }
      stage.entries.push({ key, actorLabel, animObj });

      if (!seenActorLabels.has(actorLabel)) {
        seenActorLabels.add(actorLabel);
        actorLabels.push(actorLabel);
      }
    }

    if (!stagesByNumber.size) {
      throw new Error(`No conjoined animation clips found in ${file?.name || '(unknown)'}.`);
    }

    return {
      file,
      animationId,
      formatVersion: json?.format_version || '1.8.0',
      actorLabels,
      stages: Array.from(stagesByNumber.values()).sort((a, b) => a.stageNumber - b.stageNumber),
    };
  }

  function stableJsonString(value) {
    if (Array.isArray(value)) return `[${value.map(stableJsonString).join(',')}]`;
    if (value && typeof value === 'object') {
      return `{${Object.keys(value)
        .sort()
        .map((key) => `${JSON.stringify(key)}:${stableJsonString(value[key])}`)
        .join(',')}}`;
    }
    return JSON.stringify(value);
  }

  function animationClipSettings(clip) {
    const settings = clone(clip || {});
    delete settings.bones;
    delete settings.sound_effects;
    delete settings.particle_effects;
    return settings;
  }

  function mergeCuePayload(existing, incoming) {
    const merged = [];
    const seen = new Set();
    const add = (payload) => {
      const values = Array.isArray(payload) ? payload : [payload];
      for (const value of values) {
        const signature = stableJsonString(value);
        if (seen.has(signature)) continue;
        seen.add(signature);
        merged.push(clone(value));
      }
    };
    add(existing);
    add(incoming);
    return merged.length === 1 ? merged[0] : merged;
  }

  function mergeTimedEffectMap(target, source, fieldName, stageName) {
    if (source == null) return;
    if (typeof source !== 'object' || Array.isArray(source)) {
      throw new Error(`Stage "${stageName}" has an invalid "${fieldName}" map.`);
    }
    if (!target[fieldName]) target[fieldName] = {};
    for (const [cueTime, payload] of Object.entries(source)) {
      if (!Object.prototype.hasOwnProperty.call(target[fieldName], cueTime)) {
        target[fieldName][cueTime] = clone(payload);
      } else {
        target[fieldName][cueTime] = mergeCuePayload(target[fieldName][cueTime], payload);
      }
    }
  }

  function mergeConjoinedStage(stage, actorMapping, outputStageName) {
    let combined = null;
    let expectedSettings = null;

    for (const entry of stage.entries) {
      const actorIndex = actorMapping.get(entry.actorLabel);
      if (!actorIndex) throw new Error(`No project actor selected for "${entry.actorLabel}".`);

      const clip = clone(entry.animObj);
      if (clip.bones == null) clip.bones = {};
      if (typeof clip.bones !== 'object' || Array.isArray(clip.bones)) {
        throw new Error(`Animation "${entry.key}" has an invalid "bones" map.`);
      }

      const prefixed = prefixAnimationObject(clip, actorIndex);
      const settings = animationClipSettings(prefixed);
      if (expectedSettings === null) {
        expectedSettings = settings;
        combined = clone(settings);
        combined.bones = {};
      } else if (stableJsonString(settings) !== stableJsonString(expectedSettings)) {
        throw new Error(
          `Actor clips in stage "${outputStageName}" disagree on loop, length, or other stage-wide settings.`
        );
      }

      for (const [boneName, payload] of Object.entries(prefixed.bones)) {
        if (Object.prototype.hasOwnProperty.call(combined.bones, boneName)) {
          throw new Error(`Bone collision in stage "${outputStageName}": "${boneName}".`);
        }
        combined.bones[boneName] = payload;
      }

      mergeTimedEffectMap(combined, prefixed.sound_effects, 'sound_effects', outputStageName);
      mergeTimedEffectMap(combined, prefixed.particle_effects, 'particle_effects', outputStageName);
    }

    return combined;
  }

  function buildConjoinedImportFile(parsed, outputAnimationId, actorMapping) {
    const animations = {};
    const stageNames = [];
    for (const stage of parsed.stages) {
      const stageName = `${outputAnimationId}.p${stage.stageNumber}`;
      stageNames.push(stageName);
      animations[stageName] = mergeConjoinedStage(stage, actorMapping, stageName);
    }
    return {
      fileObj: {
        format_version: parsed.formatVersion,
        animations,
      },
      stageNames,
    };
  }

  function defaultActorOptionForLabel(actorLabel, actorOptions) {
    for (const key of Object.keys(actorOptions)) {
      if (getActorExportLabel(Number(key)) === actorLabel) return key;
    }
    const actorMatch = /^actor([1-9]\d*)$/.exec(actorLabel);
    if (actorMatch && Object.prototype.hasOwnProperty.call(actorOptions, actorMatch[1])) return actorMatch[1];
    return Object.keys(actorOptions)[0];
  }

  function importConjoinedAnimation() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }
    if (typeof Animator?.loadFile !== 'function') {
      Blockbench.showMessageBox({
        title: 'Import Animation',
        message: 'Animation importer not available in this Blockbench setup (missing Animator.loadFile).',
        buttons: ['OK'],
      });
      return;
    }

    Filesystem.importFile(
      {
        type: 'AnimationDirector Conjoined Animation JSON',
        extensions: ['json'],
        multiple: false,
        readtype: 'text',
        resource_id: 'animation',
        title: 'Select a conjoined animation file',
      },
      (files) => {
        if (!files?.length) return;

        try {
          const parsed = parseConjoinedAnimationFile(files[0]);
          const actorOptions = buildActorSelectOptions();
          const actorOptionKeys = Object.keys(actorOptions);
          if (!actorOptionKeys.length) {
            Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
            return;
          }

          const form = {
            animation_id: {
              label: 'Animation ID',
              type: 'text',
              value: parsed.animationId,
              placeholder: 'wolfmplayer',
            },
            _info: {
              type: 'info',
              text: 'Map each actor stored in the file to an actor in this project. All stages will be imported together.',
            },
          };
          for (let i = 0; i < parsed.actorLabels.length; i++) {
            const actorLabel = parsed.actorLabels[i];
            form[`actor_${i}`] = {
              label: actorLabel,
              type: 'select',
              options: actorOptions,
              value: defaultActorOptionForLabel(actorLabel, actorOptions),
            };
          }

          const dialog = new Dialog({
            id: 'import_conjoined_actor_animation',
            title: 'Import Animation',
            width: 620,
            form,
            buttons: ['Cancel', 'Import'],
            cancelIndex: 0,
            confirmIndex: 1,
            onConfirm: (result) => {
              try {
                const outputAnimationId = String(result?.animation_id || '').trim();
                if (!/^[a-z0-9._-]+$/.test(outputAnimationId)) {
                  throw new Error('Animation ID must use only lowercase letters, numbers, ., _ or -.');
                }

                const actorMapping = new Map();
                const usedActorIndices = new Set();
                for (let i = 0; i < parsed.actorLabels.length; i++) {
                  const actorLabel = parsed.actorLabels[i];
                  const actorIndex = Number(result?.[`actor_${i}`]);
                  if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
                    throw new Error(`Invalid project actor selected for "${actorLabel}".`);
                  }
                  if (usedActorIndices.has(actorIndex)) {
                    throw new Error('Each file actor must map to a different project actor.');
                  }
                  actorMapping.set(actorLabel, Math.floor(actorIndex));
                  usedActorIndices.add(Math.floor(actorIndex));
                }

                const imported = buildConjoinedImportFile(parsed, outputAnimationId, actorMapping);
                const doImport = () => {
                  const fakeFile = {
                    name: `${outputAnimationId}.animation.json`,
                    path: '',
                    content: JSON.stringify(imported.fileObj, null, 2),
                  };
                  Animator.loadFile(fakeFile, imported.stageNames);
                  Blockbench.showQuickMessage(
                    `Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
                    2500
                  );
                };

                const existing = imported.stageNames.filter(animationNameExists);
                if (existing.length) {
                  Blockbench.showMessageBox(
                    {
                      title: 'Import Animation',
                      message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}\n\nOverwrite them?`,
                      buttons: ['Cancel', 'Overwrite'],
                    },
                    (button) => {
                      if (button !== 1 && button !== 'Overwrite') return;
                      for (const name of existing) removeAnimationByName(name);
                      doImport();
                    }
                  );
                } else {
                  doImport();
                }
                return true;
              } catch (e) {
                console.error(e);
                Blockbench.showMessageBox({
                  title: 'Import Animation',
                  message: `Import failed: ${e?.message || e}`,
                  buttons: ['OK'],
                });
                return false;
              }
            },
          });
          dialog.show();
        } catch (e) {
          console.error(e);
          Blockbench.showMessageBox({
            title: 'Import Animation',
            message: `Import failed: ${e?.message || e}`,
            buttons: ['OK'],
          });
        }
      }
    );
  }

  function animationNameExists(name) {
    const list = Animation.all;
    if (!Array.isArray(list)) return false;
    for (const a of list) if (a?.name === name) return true;
    return false;
  }

  function removeAnimationByName(name) {
    const list = Animation.all;
    if (!Array.isArray(list)) return;

    const a = list.find((x) => x?.name === name);
    if (!a) return;

    if (typeof a.remove === 'function') a.remove();
    else if (typeof a.delete === 'function') a.delete();
  }

  function importSplitActorAnimation() {
    if (!Project) {
      Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
      return;
    }

    if (typeof Animator?.loadFile !== 'function') {
      Blockbench.showMessageBox({
        title: 'Import Animation (Split Files)',
        message: 'Animation importer not available in this Blockbench setup (missing Animator.loadFile).',
        buttons: ['OK'],
      });
      return;
    }

    Filesystem.importFile(
      {
        type: 'GeckoLib Animation JSON (per actor)',
        extensions: ['json'],
        multiple: true,
        readtype: 'text',
        resource_id: 'animation',
        title: 'Select split per-actor animation files to combine',
      },
      (files) => {
        if (!files?.length) return;

        try {
          const parsed = [];
          for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
            const f = files[fileIndex];
            const text = f?.content;
            if (typeof text !== 'string' || !text.trim()) throw new Error(`Could not read file contents: ${f?.name || '(unknown)'}`);

            const json = JSON.parse(text);
            const anims = json?.animations;
            if (!anims || typeof anims !== 'object') throw new Error(`Missing "animations" map in ${f?.name || '(unknown)'}`);

            const keys = Object.keys(anims);
            if (keys.length !== 1) {
              throw new Error(
                `Expected exactly 1 animation clip inside ${f?.name || '(unknown)'} (found ${keys.length}). Each selected split file must contain one actor's clip for one stage.`
              );
            }

            const animKey = keys[0];
            const animObj = anims[animKey];
            if (!animObj || typeof animObj !== 'object') throw new Error(`Invalid animation object in ${f?.name || '(unknown)'}`);

            const parts = getFilenameParts(f?.name || '');

            parsed.push({
              file: f,
              format_version: json?.format_version,
              animKey,
              animObj,
              parts,
              fileIndex,
            });
          }

          const analysis = analyzeSplitAnimationFiles(parsed);
          const actorOptions = buildActorSelectOptions();
          const actorOptionKeys = Object.keys(actorOptions);
          if (!actorOptionKeys.length) {
            Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
            return;
          }

          const canRename = analysis.commonAnimationId !== null || analysis.stages.length === 1;
          const defaultName = analysis.commonAnimationId || analysis.stages[0]?.animKey || 'animation';
          const form = {};
          if (canRename) {
            form.name = {
              label: analysis.stages.length > 1 ? 'Animation ID' : 'Combined animation name',
              type: 'text',
              value: defaultName,
              placeholder: analysis.stages.length > 1 ? 'wolfmplayer' : 'wolfmplayer.p1',
            };
          }
          form._info = {
            type: 'info',
            text: analysis.stages.length > 1
              ? `Detected ${analysis.stages.length} stages. Map each file actor once; the mapping is reused for every stage.`
              : "Pick which project actor each file actor belongs to. Bones receive that actor's 'actorN_' prefix.",
          };
          if (!canRename) {
            form._stage_names = {
              type: 'info',
              text: `The selected files use unrelated animation names, so they will be preserved: ${analysis.stages.map((stage) => stage.animKey).join(', ')}`,
            };
          }

          for (let i = 0; i < analysis.actors.length; i++) {
            const actor = analysis.actors[i];
            const guessedActor = String(actor.actorGuess || '');
            form[`actor_${i}`] = {
              label: `${actor.label} (${actor.fileCount} file${actor.fileCount === 1 ? '' : 's'})`,
              type: 'select',
              options: actorOptions,
              value: Object.prototype.hasOwnProperty.call(actorOptions, guessedActor)
                ? guessedActor
                : defaultActorOptionForLabel(actor.label, actorOptions),
            };
          }

          const dialog = new Dialog({
            id: 'import_combined_actor_animation',
            title: 'Import Animation (Split Files)',
            width: 720,
            form,
            buttons: ['Cancel', 'Import'],
            cancelIndex: 0,
            confirmIndex: 1,
            onConfirm: (result) => {
              try {
                const requestedName = canRename
                  ? String(result?.name || '').trim() || defaultName
                  : defaultName;
                if (canRename && !requestedName) throw new Error('Animation name cannot be empty.');

                const actorMapping = new Map();
                const usedActorIndices = new Set();
                for (let i = 0; i < analysis.actors.length; i++) {
                  const actor = analysis.actors[i];
                  const actorIndex = Number(result?.[`actor_${i}`]);
                  if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
                    throw new Error(`Invalid project actor selected for "${actor.label}".`);
                  }
                  const normalizedActorIndex = Math.floor(actorIndex);
                  if (usedActorIndices.has(normalizedActorIndex)) {
                    throw new Error('Each file actor must map to a different project actor.');
                  }
                  actorMapping.set(actor.key, normalizedActorIndex);
                  usedActorIndices.add(normalizedActorIndex);
                }

                const imported = buildSplitImportFile(
                  analysis,
                  actorMapping,
                  requestedName,
                  parsed[0]?.format_version
                );

                const doImport = (nameOverrides = null) => {
                  const animations = {};
                  const finalNames = [];
                  for (const sourceName of imported.stageNames) {
                    const finalName = nameOverrides?.get(sourceName) || sourceName;
                    animations[finalName] = imported.fileObj.animations[sourceName];
                    finalNames.push(finalName);
                  }

                  const fakeFile = {
                    name: `${sanitizeFilePart(requestedName || 'split_animation_import')}.animation.json`,
                    path: '',
                    content: JSON.stringify({
                      format_version: imported.fileObj.format_version,
                      animations,
                    }, null, 2),
                  };

                  Animator.loadFile(fakeFile, finalNames);
                  Blockbench.showQuickMessage(
                    `Imported ${finalNames.length} stage${finalNames.length === 1 ? '' : 's'} from ${files.length} split files`,
                    2500
                  );
                };

                const existing = imported.stageNames.filter(animationNameExists);
                if (existing.length) {
                  Blockbench.showMessageBox(
                    {
                      title: 'Import Animation (Split Files)',
                      message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}`,
                      buttons: ['Cancel', 'Overwrite', 'Import Copies'],
                    },
                    (button) => {
                      if (button === 1 || button === 'Overwrite') {
                        for (const name of existing) removeAnimationByName(name);
                        doImport();
                      } else if (button === 2 || button === 'Import Copies') {
                        const usedNames = new Set(
                          (Array.isArray(Animation.all) ? Animation.all : [])
                            .map((animation) => animation?.name)
                            .filter(Boolean)
                        );
                        const copyNames = new Map();
                        for (const name of imported.stageNames) {
                          let copyName = name;
                          let suffix = 2;
                          while (usedNames.has(copyName)) copyName = `${name}_${suffix++}`;
                          usedNames.add(copyName);
                          copyNames.set(name, copyName);
                        }
                        doImport(copyNames);
                      }
                    }
                  );
                } else {
                  doImport();
                }

                return true;
              } catch (e) {
                console.error(e);
                Blockbench.showMessageBox({
                  title: 'Import Animation (Split Files)',
                  message: `Import failed: ${e?.message || e}`,
                  buttons: ['OK'],
                });
                return false;
              }
            },
          });

          dialog.show();
        } catch (e) {
          console.error(e);
          Blockbench.showMessageBox({
            title: 'Import Animation (Split Files)',
            message: `Import failed: ${e?.message || e}`,
            buttons: ['OK'],
          });
        }
      }
    );
  }

  // ----------------------------
  // Register
  // ----------------------------
  Plugin.register(PLUGIN_ID, {
    title: 'Multi Actor Animator',
    author: 'L1Z0',
    icon: 'groups',
    description: 'Multi-actor animation in Blockbench without bone-name clashes.',
    about: `Multi Actor Animator is a Blockbench plugin made for the AnimationDirector mod.

It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent.

You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a reviewed AFW animation-definition stub from the current project. The deprecated split-file import and export tools remain available for older projects.`,
    version: '1.0.0',
    variant: 'both',
    onload() {
      if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
        if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
          actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, {
            default: '{}',
          });
        }
        if (!ModelProject.properties[ACTOR_TEXTURES_PROPERTY]) {
          actorTexturesProjectProperty = new Property(ModelProject, 'string', ACTOR_TEXTURES_PROPERTY, {
            default: '{}',
          });
        }
        if (!ModelProject.properties[BONE_TEXTURE_OVERRIDES_PROPERTY]) {
          boneTextureOverridesProjectProperty = new Property(ModelProject, 'string', BONE_TEXTURE_OVERRIDES_PROPERTY, {
            default: '{}',
          });
        }
        if (!ModelProject.properties[ACTOR_ANIMDEF_METADATA_PROPERTY]) {
          actorAnimdefMetadataProjectProperty = new Property(ModelProject, 'string', ACTOR_ANIMDEF_METADATA_PROPERTY, {
            default: '{}',
          });
        }
      }

      importAction = new Action('import_actor_gecko_json', {
        name: 'Import Actor',
        description: 'Import a GeckoLib/Bedrock geometry JSON into the current project and prefix bones with actorN_.',
        icon: 'person_add',
        click: importActorGeckoJson,
      });

      importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', {
        name: 'Import Animation',
        description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.',
        icon: 'playlist_add',
        click: importConjoinedAnimation,
      });

      importSplitAnimationAction = new Action('import_split_actor_animation', {
        name: 'Import Animation (Split Files)',
        description:
          'Select split per-actor files from one or more stages, rebuild each stage, and re-apply actor prefixes.',
        icon: 'playlist_add',
        click: importSplitActorAnimation,
      });

      actorPreferencesAction = new Action('edit_actor_preferences', {
        name: 'Actor Preferences',
        description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.',
        icon: 'badge',
        click: editActorPreferences,
      });

      boneTextureOverridesAction = new Action('edit_bone_texture_overrides', {
        name: 'Bone Texture Overrides',
        description: 'Set recursive texture overrides for folders in any model project.',
        icon: 'texture',
        click: editBoneTextureOverrides,
      });

      exportGeckoModelAction = new Action('animationdirector_export_gecko_model', {
        name: 'Export GeckoLib Model',
        description: 'Export the current model as GeckoLib/Bedrock geometry with AFW bone texture overrides.',
        icon: 'file_upload',
        click: exportGeckoModel,
      });

      exportSelectedAction = new Action('export_selected_actor_animation', {
        name: 'Export Selected Animations (Split Files)',
        description:
          'Export selected animation as one <animation>_<actor name>.animation.json per actor, stripping actor prefixes from bone names.',
        icon: 'file_download',
        click: exportSelectedActorAnimation,
      });

      exportAllAction = new Action('export_all_actor_animations', {
        name: 'Export All Animations (Split Files)',
        description:
          'Export every animation as one <animation>_<actor name>.animation.json per actor, stripping actor prefixes from bone names.',
        icon: 'download_for_offline',
        click: exportAllActorAnimations,
      });

      exportConjoinedAction = new Action('export_conjoined_actor_animations', {
        name: 'Export Animations',
        description:
          'Select AFW animation IDs and export each as one file containing every stage and actor clip.',
        icon: 'file_download',
        click: showConjoinedAnimationExportDialog,
      });

      createAnimationDefinitionAction = new Action('create_afw_animation_definition_stub', {
        name: 'Create Animation Definition Stub',
        description: 'Create a reviewed AFW animdef stub from one <animationId>.p<number> animation group.',
        icon: 'post_add',
        click: showCreateAnimationDefinitionDialog,
      });

      MenuBar.menus.tools.addAction({
        id: 'animationdirector_model',
        name: 'AnimationDirector Model',
        icon: 'deployed_code',
        children: [boneTextureOverridesAction, exportGeckoModelAction],
      });

      MenuBar.menus.tools.addAction({
        id: 'animationdirector_anims',
        name: 'AnimationDirector Anims',
        icon: 'movie',
        children: [
          importAction,
          importConjoinedAnimationAction,
          actorPreferencesAction,
          exportConjoinedAction,
          createAnimationDefinitionAction,
          {
            id: 'animationdirector_deprecated',
            name: 'Deprecated',
            icon: 'history',
            children: [importSplitAnimationAction, exportSelectedAction, exportAllAction],
          },
        ],
      });

      actorTexturePreviewProjectListener = scheduleActorTexturePreviewApply;
      for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
        Blockbench.on(eventName, actorTexturePreviewProjectListener);
      }
      scheduleActorTexturePreviewApply();

      MenuBar.update();
    },
    onunload() {
      clearScheduledActorTexturePreviewApplies();
      if (actorTexturePreviewProjectListener) {
        for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
          Blockbench.removeListener(eventName, actorTexturePreviewProjectListener);
        }
        actorTexturePreviewProjectListener = null;
      }
      MenuBar.menus.tools.removeAction('animationdirector_model');
      MenuBar.menus.tools.removeAction('animationdirector_anims');
      importAction.delete();
      importConjoinedAnimationAction.delete();
      importSplitAnimationAction.delete();
      actorPreferencesAction.delete();
      boneTextureOverridesAction.delete();
      exportGeckoModelAction.delete();
      exportSelectedAction.delete();
      exportAllAction.delete();
      exportConjoinedAction.delete();
      createAnimationDefinitionAction.delete();
      if (actorNamesProjectProperty?.delete) actorNamesProjectProperty.delete();
      if (actorTexturesProjectProperty?.delete) actorTexturesProjectProperty.delete();
      if (boneTextureOverridesProjectProperty?.delete) boneTextureOverridesProjectProperty.delete();
      if (actorAnimdefMetadataProjectProperty?.delete) actorAnimdefMetadataProjectProperty.delete();
    },
  });
})();
