/**
 * MixamoAnimator — проигрывает Mixamo-анимации на скелете персонажа.
 *
 * Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ
 * AnimationGroups в их собственном GLB. Анимации лежат отдельными
 * GLB-файлами в /character-assets/animations/:
 *
 *   idle.glb, walk.glb, run.glb, jump.glb, fall.glb
 *   emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb
 *
 * Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones
 * с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д.
 *
 * Что делает этот класс:
 * 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы
 *    (singleton — один loader на сессию).
 * 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости.
 *    Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`,
 *    `mixamorig9:` или вообще без префикса — детектим автоматически.
 * 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` +
 *    плавный кросс-фейд (blending) между состояниями.
 * 4. `playEmote(name, onDone)` — одноразово проиграть эмоцию поверх,
 *    после конца автоматически вернуться в текущее состояние.
 *
 * Bone-имена которые ретаргетим (24 обязательных):
 *   Hips, Spine, Spine1, Spine2, Neck, Head,
 *   LeftShoulder, LeftArm, LeftForeArm, LeftHand,
 *   RightShoulder, RightArm, RightForeArm, RightHand,
 *   LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase,
 *   RightUpLeg, RightLeg, RightFoot, RightToeBase
 *
 * Использование:
 *   const anim = new MixamoAnimator();
 *   await anim.load();                        // один раз на сессию
 *   anim.attach(scene, skeleton, modelRoot);  // на каждую загрузку скина
 *   anim.setState('idle');
 *   // каждый кадр в _tick (необязательно — Babylon сам тикает groups):
 *   anim.update(dt);
 *   // эмоция:
 *   anim.playEmote('emote_taunt');
 *   // при смене скина:
 *   anim.detach();
 */

import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core";
import "@babylonjs/loaders/glTF";

// Базовые состояния — соответствуют файлам *.glb в animations/.
// Базовые (всегда грузятся при старте — нужны для движения):
const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];

// Дополнительные движения (грузятся лениво при первом setState):
const EXTRA_STATES = [
    "jump_anticipate", "jump_air", "jump_land",
    "jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
    "jump_run_anticipate", "jump_run_air", "jump_run_land",
    "walk_backward", "run_backward", "run_to_stop", "run_slide",
    "jump_forward", "jump_backward", "jump_down",
    "crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
    "climb_up", "climb_down", "climb_to_top", "sit_idle", "lie_idle", "sleeping",
    "hit_react", "die_forward", "die_back",
    "punch_left", "kick_low", "kick_high",
    "gun_fire", "gun_reload", "rifle_walk",
    "sword_idle", "sword_slash",
    "push_button", "open_door", "throw_action",
];

// Эмоции (вызываются через playEmote()):
const EMOTES = [
    "emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt",
    "emote_salute", "emote_pointing", "emote_no",
    "dance_hiphop", "dance_rumba", "dance_breakdance",
];

// Все известные анимации (для опциональной полной предзагрузки)
const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES];

// Кэш сырых данных анимаций между инстансами (singleton-ish):
// один раз загрузили — используем для всех аватаров.
let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... }
let _loadPromise = null;

/**
 * Строит абсолютный URL для статики Mixamo-анимаций.
 * Локально — localhost:3000 (rublox-site dev-server),
 * на проде — rublox.pro/character-assets/.
 */
function _assetsBase() {
    if (typeof window === "undefined") return "";
    const isLocal = window.location.hostname === "localhost"
        || window.location.hostname === "127.0.0.1";
    return isLocal ? "http://localhost:3000" : "https://rublox.pro";
}

/**
 * Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`,
 * `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`.
 */
function _normalizeBone(name) {
    if (!name) return "";
    // mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc
    let n = name;
    const colon = n.lastIndexOf(":");
    if (colon >= 0) n = n.slice(colon + 1);
    n = n.replace(/^mixamorig\d*[_:.]?/i, "");
    n = n.replace(/^Armature\|/, "");
    return n;
}

/**
 * Загружает один GLB-файл с анимациями. Возвращает массив
 * { boneName, animations: [Babylon.Animation] } — сырые треки,
 * привязанные к именам костей (без префикса).
 */
async function _loadAnimGlb(scene, url) {
    // ImportAnimations не годится — он сразу target-ит конкретный
    // скелет. Нам нужны сырые animations[], чтобы потом каждому
    // скину пристёгивать отдельно.
    const result = await SceneLoader.LoadAssetContainerAsync(
        url.substring(0, url.lastIndexOf("/") + 1),
        url.substring(url.lastIndexOf("/") + 1),
        scene,
    );
    const out = [];
    // В GLB от Mixamo каждая кость — это TransformNode (или Bone),
    // содержит свои keyframe animations. После загрузки они на
    // result.transformNodes / result.skeletons[].bones.
    const allNodes = [
        ...(result.transformNodes || []),
        ...((result.skeletons || []).flatMap(sk => sk.bones || [])),
    ];
    for (const node of allNodes) {
        if (!node.animations || node.animations.length === 0) continue;
        const cleanName = _normalizeBone(node.name);
        if (!cleanName) continue;
        out.push({ boneName: cleanName, animations: node.animations.slice() });
    }
    // Освободим геометрию (если случайно приехала — у анимаций мешей нет)
    result.dispose();
    return out;
}

/**
 * Загрузить базовые анимации (idle/walk/run/jump/fall) один раз.
 * Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded
 * при первом обращении — это экономит трафик: юзер качает только то что
 * реально использует в игре.
 */
export async function loadMixamoAnimations(scene) {
    if (_loadPromise) return _loadPromise;
    _cachedRawTargets = _cachedRawTargets || {};
    _loadPromise = (async () => {
        const base = _assetsBase();
        const entries = await Promise.all(
            BASE_STATES.map(async (name) => {
                try {
                    const tracks = await _loadAnimGlb(
                        scene, `${base}/character-assets/animations/${name}.glb`);
                    return [name, tracks];
                } catch (e) {
                    console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e);
                    return [name, []];
                }
            })
        );
        for (const [k, v] of entries) _cachedRawTargets[k] = v;
        // eslint-disable-next-line no-console
        console.log("[MixamoAnimator] базовые анимации загружены:",
            Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", "));
        return _cachedRawTargets;
    })();
    return _loadPromise;
}

/**
 * Ленивая подгрузка одной анимации по имени (если ещё не в кэше).
 * Возвращает массив tracks или null если не удалось.
 */
async function _ensureLoaded(scene, name) {
    if (!_cachedRawTargets) _cachedRawTargets = {};
    if (_cachedRawTargets[name]) return _cachedRawTargets[name];
    const base = _assetsBase();
    try {
        const tracks = await _loadAnimGlb(
            scene, `${base}/character-assets/animations/${name}.glb`);
        _cachedRawTargets[name] = tracks;
        // eslint-disable-next-line no-console
        console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`);
        return tracks;
    } catch (e) {
        console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e);
        _cachedRawTargets[name] = [];
        return null;
    }
}

export class MixamoAnimator {
    constructor() {
        this.scene = null;
        this.skeleton = null;
        this.modelRoot = null;
        /** Map<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
        this._groups = new Map();
        this._currentState = null;
        this._currentGroup = null;
        this._currentEmote = null;
        this._emoteOnDone = null;
        this._blendInProgress = false;
    }

    /**
     * Пристёгивает аниматор к конкретному скелету (после загрузки модели).
     * scene — Babylon Scene, skeleton — Babylon Skeleton, modelRoot — TransformNode.
     */
    attach(scene, skeleton, modelRoot) {
        this.scene = scene;
        this.skeleton = skeleton;
        this.modelRoot = modelRoot;
        // Резолвим маппинг "clean name" → Bone (из текущего скелета).
        this._cleanToBone = new Map();
        for (const b of (skeleton.bones || [])) {
            const clean = _normalizeBone(b.name);
            if (clean && !this._cleanToBone.has(clean)) {
                this._cleanToBone.set(clean, b);
            }
        }
        // Также детектим target-property: TransformNode? linkedTransformNode?
        // Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть),
        // потому что в glTF skin'ы делают joints через nodes, не через Bones.
        // Для каждой кости берём её _linkedTransformNode (Babylon API).
        this._cleanToTarget = new Map();
        for (const [name, bone] of this._cleanToBone) {
            const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
            this._cleanToTarget.set(name, tnode || bone);
        }
        // Запомним bind-pose позиции (особенно Hips) — нужны для нормализации
        // Hips.position в jump_air/jump_land и для сброса после анимаций.
        this._restPositions = new Map();
        for (const [name, target] of this._cleanToTarget) {
            if (target && target.position) {
                this._restPositions.set(name, {
                    x: target.position.x,
                    y: target.position.y,
                    z: target.position.z,
                });
            }
        }
    }

    /** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
    _ensureGroup(state) {
        if (this._groups.has(state)) return this._groups.get(state);
        if (!_cachedRawTargets || !_cachedRawTargets[state]) return null;
        const raw = _cachedRawTargets[state];
        const group = new AnimationGroup(`mixamo_${state}`, this.scene);
        let attached = 0;
        for (const t of raw) {
            const target = this._cleanToTarget.get(t.boneName);
            if (!target) continue;
            for (const anim of t.animations) {
                // Клонируем анимацию (одна Babylon.Animation не может
                // быть в двух разных AnimationGroup одновременно).
                const cloned = anim.clone();
                // Mixamo всегда грузит Hips.position — это сдвигает
                // персонажа по сцене. В in-place анимациях должно быть
                // близко к нулю, но иногда сдвиг есть. Для базовых
                // движений (walk/run/jump) фильтруем targetProperty=position
                // у кости с именем Hips — её двигает наш PlayerController.
                if (t.boneName === "Hips" && cloned.targetProperty === "position") {
                    // 3-фазная модель прыжка:
                    //   jump_anticipate — присед перед прыжком. baseY = первый кадр
                    //     (стоячая поза → опускается ниже).
                    //   jump_air — физика поднимает _modelRoot, Hips.Y не используем.
                    //   jump_land — приземление с амортизацией. baseY = МИНИМУМ
                    //     (самая низкая точка приседа), так первый кадр будет Y > 0
                    //     (только что приземлились, ноги пружинят к bind),
                    //     середина = 0 (присед на полу), конец = выпрямление.
                    // Для всех остальных — фильтруем (физика двигает _modelRoot).
                    const PHASES = new Set([
                        'jump_anticipate', 'jump_land',
                        'jump_fwd_anticipate', 'jump_fwd_land',
                        'jump_run_anticipate', 'jump_run_land',
                    ]);
                    if (!PHASES.has(state)) {
                        continue;
                    }
                    const rest = this._restPositions?.get('Hips');
                    try {
                        const keys = cloned.getKeys();
                        if (keys && keys.length > 0 && keys[0].value) {
                            // baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max
                            // всегда ≤ 0 → Hips только опускается ниже bind.
                            // jump_land: персонаж приземлился (ноги на полу = bind),
                            //   потом корпус опускается = присед амортизации,
                            //   потом возвращается обратно к bind (выпрямление).
                            // jump_anticipate: то же — корпус опускается из стоячей.
                            let maxY = -Infinity;
                            for (const k of keys) {
                                const y = k.value.y || 0;
                                if (y > maxY) maxY = y;
                            }
                            const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0);
                            const newKeys = keys.map(k => ({
                                frame: k.frame,
                                value: new (k.value.constructor)(
                                    rest ? rest.x : 0,
                                    (rest ? rest.y : 0) + ((k.value.y || 0) - baseY),
                                    rest ? rest.z : 0,
                                ),
                                inTangent: k.inTangent,
                                outTangent: k.outTangent,
                                interpolation: k.interpolation,
                            }));
                            cloned.setKeys(newKeys);
                        }
                    } catch (e) { continue; }
                }
                group.addTargetedAnimation(cloned, target);
                attached++;
            }
        }
        if (attached === 0) {
            group.dispose();
            // eslint-disable-next-line no-console
            console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`);
            return null;
        }
        // Зацикливаем базовые состояния, кроме jump (он one-shot).
        // ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА
        // САМОМ GROUP до start(). Параметр loop в start() игнорируется в
        // некоторых версиях Babylon 7.x.
        // One-shot анимации (играются один раз, не зацикливаются):
        // jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и
        // эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
        const ONE_SHOT = new Set([
            "jump", "jump_forward", "jump_backward", "jump_down",
            "jump_anticipate", "jump_land",
            "jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
            "jump_run_anticipate", "jump_run_air", "jump_run_land",
            "crouch_enter", "crouch_to_stand",
            "climb_to_top",
            "hit_react", "die_forward", "die_back",
            "throw_action", "pickup", "push_button", "open_door",
            "gun_fire", "gun_reload", "sword_slash",
            "kick_low", "kick_high", "punch_left",
        ]);
        // emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться)
        const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_");
        group.loopAnimation = loopable;
        group.normalize();
        // Safety-net: если Babylon всё равно по какой-то причине отыграл
        // клип до конца И не зациклил (что бывает с короткими "still pose"
        // клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем
        // принудительно. Это даёт стабильно зацикленную анимацию.
        if (loopable) {
            group.onAnimationGroupEndObservable.add(() => {
                if (this._currentGroup === group && !this._currentEmote) {
                    try {
                        group.reset();
                        group.start(true, 1.0, group.from, group.to, false);
                    } catch (_) {}
                }
            });
        }
        // eslint-disable-next-line no-console
        console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`);
        this._groups.set(state, group);
        return group;
    }

    /** Установить состояние с плавным кросс-фейдом 150 мс.
     *  Если анимация ещё не подгружена — стартует lazy-load, при этом
     *  setState вернётся синхронно (без ожидания) — анимация подхватится
     *  на следующем тике после успешной загрузки.
     *
     *  Anti-flicker: между переключениями требуется минимальная задержка
     *  120мс (кроме переходов в воздух/идл из приземления). Это убирает
     *  «дрожание» crouch_walk ↔ crouch_idle когда игрок едет по диагонали
     *  и одно из направлений физически дёргается между кадрами. */
    setState(state) {
        if (this._currentEmote) return; // эмоция блокирует смену состояния
        if (state === this._currentState) return;
        // Сброс Hips.position в bind-pose при выходе из jump-фаз.
        // Иначе последний keyframe анимации остаётся на Hips и idle/walk
        // подхватывает смещённую позицию → персонаж проседает.
        const JUMP_STATES = new Set([
            'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate',
            'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
            'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
        ]);
        if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state)
            && this._restPositions) {
            const rest = this._restPositions.get('Hips');
            const hips = this._cleanToTarget?.get('Hips');
            if (rest && hips && hips.position) {
                try {
                    hips.position.x = rest.x;
                    hips.position.y = rest.y;
                    hips.position.z = rest.z;
                } catch (_) {}
            }
        }
        const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
        // Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
        // КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
        // и one-shot crouch_enter/crouch_to_stand (они короткие).
        const JUMP_VITAL = new Set([
            'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate',
            'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
            'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
        ]);
        const isVitalSwitch = JUMP_VITAL.has(state)
            || JUMP_VITAL.has(this._currentState)
            || state === 'crouch_enter' || state === 'crouch_to_stand';
        if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
            // Запомним последний запрошенный state — если он не изменится за
            // окно debounce, тогда применим, иначе отбросим вспышку.
            this._pendingState = state;
            if (!this._debounceTimer) {
                const delay = Math.max(0, 120 - (now - this._lastSwitchAt));
                this._debounceTimer = setTimeout(() => {
                    this._debounceTimer = null;
                    const s = this._pendingState;
                    this._pendingState = null;
                    if (s && s !== this._currentState) this.setState(s);
                }, delay);
            }
            return;
        }
        this._lastSwitchAt = now;
        // Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию
        // НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает
        // в bind-pose пока crouch_idle асинхронно качается).
        if (!_cachedRawTargets || !_cachedRawTargets[state]) {
            if (!this._pendingLoads) this._pendingLoads = new Set();
            if (!this._pendingLoads.has(state)) {
                this._pendingLoads.add(state);
                _ensureLoaded(this.scene, state).then(() => {
                    this._pendingLoads.delete(state);
                });
            }
            return; // подхватится при следующем setState когда tracks будут
        }
        const next = this._ensureGroup(state);
        if (!next) return;
        const prev = this._currentGroup;
        // Loop-флаг берём напрямую с group — _ensureGroup уже разрулил
        // (one-shot list + emote_* → не лупим).
        const loop = next.loopAnimation;
        // Лог переключений (только если изменилось — иначе спам)
        // eslint-disable-next-line no-console
        console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`);

        // Per-state speedRatio: подгоняем длительность под физику.
        // jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с
        //   → speedRatio = 0.59 (замедлить чтобы клип не зациклился).
        // jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59
        // jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71
        const SPEED_RATIO = {
            jump_fwd_air: 0.59,
            jump_run_air: 0.71,
        };
        const speedRatio = SPEED_RATIO[state] || 1.0;
        // Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
        // в start() иногда игнорится — дублируем через loopAnimation
        // (выставлен в _ensureGroup).
        try {
            next.reset();
            next.start(loop, speedRatio, next.from, next.to, false);
        } catch (e) {
            try { next.play(loop); } catch (_) {}
        }

        // Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
        // Climb-состояния переключаем МГНОВЕННО (0мс) — при blend'е персонаж
        // на доли секунды виден в промежуточном развороте (старая поза + новый
        // _modelYaw), что выглядит как «дёрг разворота» при входе/выходе с лестницы.
        const CLIMB_STATES = new Set(['climb_up', 'climb_down', 'climb_to_top']);
        const BLEND_MS = (CLIMB_STATES.has(state) || CLIMB_STATES.has(this._currentState))
            ? 0 : 150;
        try { next.setWeightForAllAnimatables(0); } catch (_) {}
        // Снимаем ВСЕ предыдущие blend-observers — rapid-switching
        // (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов.
        if (this._blendObservers && this._blendObservers.length) {
            for (const o of this._blendObservers) {
                try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {}
            }
        }
        this._blendObservers = [];
        // КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы
        // кроме новой. Это убирает кейсы когда rapid-switching между
        // prev/next/третий оставляет висящую группу из позапрошлого setState
        // (и она «крутится» дальше в фоне с весом 1).
        for (const g of this._groups.values()) {
            if (g !== next) {
                // Не стопим текущую blend-исходную — она нужна для фейда.
                if (g !== prev) {
                    try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {}
                }
            }
        }
        if (prev && prev !== next) {
            const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
            const prevGroup = prev;
            const nextGroup = next;
            const obs = this.scene.onBeforeRenderObservable.add(() => {
                const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
                const t = Math.min(1, (nowMs - startedAt) / BLEND_MS);
                // Если за это время _currentGroup сменилась ещё раз —
                // прекращаем blend (новый setState уже разрулил).
                if (this._currentGroup !== nextGroup) {
                    try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {}
                    return;
                }
                try {
                    prevGroup.setWeightForAllAnimatables(1 - t);
                    nextGroup.setWeightForAllAnimatables(t);
                } catch (_) {}
                if (t >= 1) {
                    try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {}
                    try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {}
                    this.scene.onBeforeRenderObservable.remove(obs);
                }
            });
            this._blendObservers.push(obs);
        } else {
            try { next.setWeightForAllAnimatables(1); } catch (_) {}
        }
        this._currentState = state;
        this._currentGroup = next;
    }

    /** Проиграть эмоцию (one-shot), потом вернуться в idle.
     *  Если эмоция ещё не подгружена — подгружает на лету и стартует. */
    async playEmote(name, onDone) {
        const tracks = await _ensureLoaded(this.scene, name);
        if (!tracks || tracks.length === 0) {
            console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`);
            if (onDone) onDone();
            return;
        }
        const group = this._ensureGroup(name);
        if (!group) { if (onDone) onDone(); return; }
        // Стоп текущего состояния
        if (this._currentGroup) {
            try { this._currentGroup.stop(); } catch (_) {}
        }
        this._currentEmote = name;
        this._emoteOnDone = onDone || null;
        const savedState = this._currentState;
        try {
            group.start(false, 1.0, group.from, group.to, false);
        } catch (e) {
            try { group.play(false); } catch (_) {}
        }
        const onEnd = () => {
            this._currentEmote = null;
            this._currentState = null;       // принудим setState заново запустить
            this.setState(savedState || "idle");
            if (this._emoteOnDone) {
                const cb = this._emoteOnDone;
                this._emoteOnDone = null;
                try { cb(); } catch (_) {}
            }
        };
        group.onAnimationGroupEndObservable.addOnce(onEnd);
    }

    /** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы
     *  при первом setState анимация уже была готова (нет дёрга от walk). */
    preload(name) {
        try { _ensureLoaded(this.scene, name); } catch (e) {}
    }

    /** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */
    // eslint-disable-next-line no-unused-vars
    update(dt) { /* noop */ }

    /** Остановить и освободить все группы для этого скелета. */
    detach() {
        if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} }
        for (const g of this._groups.values()) {
            try { g.dispose(); } catch (_) {}
        }
        this._groups.clear();
        this._currentGroup = null;
        this._currentState = null;
        this._currentEmote = null;
        this.scene = null;
        this.skeleton = null;
        this.modelRoot = null;
    }
}
