/**
 * PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11).
 *
 * Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре →
 * полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет».
 * Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
 *
 * Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
 * Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
 *
 * Скриптовый API игры (через GameRuntime → game.placement.*):
 *   start(itemKey, opts)  — войти в режим расстановки
 *   cancel()              — выйти (как ПКМ/Esc)
 *   confirm()             — поставить на текущей позиции (как ЛКМ)
 *   rotate(deg)           — повернуть preview (как R / колесо)
 *   onPlace / onCancel / onMove — колбэки (роутятся в worker как события)
 *
 * Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
 */
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';

const VALID_TINT = new Color3(0.30, 0.95, 0.40);   // зелёный — можно ставить
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя

export class PlacementManager {
    constructor(scene3d) {
        this.s = scene3d;               // BabylonScene
        this.scene = scene3d.scene;
        this._active = null;            // активная сессия placement или null
        this._tickObs = null;           // observer renderLoop
        this._placementSeq = 0;
        // Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
        this._onPlace = null;
        this._onCancel = null;
        this._onMove = null;
    }

    setCallbacks({ onPlace, onCancel, onMove } = {}) {
        if (onPlace !== undefined) this._onPlace = onPlace;
        if (onCancel !== undefined) this._onCancel = onCancel;
        if (onMove !== undefined) this._onMove = onMove;
    }

    isActive() { return !!this._active; }

    /**
     * Войти в placement-режим.
     * @param {string} itemKey  — ключ предмета (передаётся обратно в onPlace)
     * @param {object} opts     — см. 11_placement_mode.md §2.1
     * @returns {string} placementId
     */
    start(itemKey, opts = {}) {
        // Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
        if (this._active) this._teardown(false);

        const o = {
            previewType: opts.previewType || 'primitive:cube',
            previewColor: opts.previewColor || '#a0522d',
            previewScale: Number(opts.previewScale) || 1,
            // modelScale — реальный scale воксельной модели для превью (чтобы
            // полупрозрачная копия была того же размера, что и ставимый объект).
            modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
            ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
            surfaceMode: opts.surfaceMode || 'ground',     // 'ground'|'any'|'tag'
            allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
            forbidOverlap: opts.forbidOverlap !== false,
            grid: opts.grid != null ? Number(opts.grid) : 1,
            rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
            targetZone: opts.targetZone || null,           // ref-строка примитива-зоны
            showZoneOutline: opts.showZoneOutline !== false,
            showArrowFrom: opts.showArrowFrom || null,     // 'player' | ref
            cost: Number(opts.cost) || 0,
            currency: opts.currency || 'rubles',
            hint: opts.hint || '',
            hintError: opts.hintError || 'Разместите в отмеченном месте!',
            placedType: opts.placedType || null,
            chainPlace: !!opts.chainPlace,
            maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
            maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
            forceCameraMode: opts.forceCameraMode !== false,
            freezePlayer: !!opts.freezePlayer,
            previewPulse: opts.previewPulse !== false,
        };

        const id = 'placement_' + (++this._placementSeq);
        const preview = this._createPreview(o);

        this._active = {
            id, itemKey, opts: o, preview,
            rotationY: 0,
            valid: false,
            pos: new Vector3(0, 0, 0),
            zoneOutline: null,
            arrowFxRef: null,
            placedCount: 0,
            pulseT: 0,
            prevCameraMode: null,
            prevFrozen: null,
        };

        // Зона размещения — красный контур по AABB.
        if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
        // Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
        if (o.showArrowFrom && o.targetZone) this._createArrow();
        // Камера: placement требует видимый курсор — в first переводим в third.
        if (o.forceCameraMode) this._forceThirdCamera();
        // Заморозка игрока (опция).
        if (o.freezePlayer) this._setPlayerFrozen(true);

        // HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
        this._emitHud(true);

        this._startTick();
        return id;
    }

    cancel() {
        if (!this._active) return;
        const cb = this._onCancel;
        this._teardown(true);
        if (typeof cb === 'function') cb();
    }

    /** Поставить на текущей позиции (как ЛКМ). */
    confirm() {
        const a = this._active;
        if (!a) return false;
        if (!a.valid) {
            // Невалидно — звук «не получилось» + мигание preview в красный.
            this._playFail();
            this._flashInvalid();
            return false;
        }
        // Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
        // поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
        // ровно туда, где показывалось превью. Для куба-превью offset = 0.
        let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
        if (ox || oz) {
            const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
            const rx = ox * c - oz * s;
            const rz = ox * s + oz * c;
            ox = rx; oz = rz;
        }
        const result = {
            itemKey: a.itemKey,
            position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
            rotationY: a.rotationY,
        };
        // Списание стоимости (если задана и есть валюта-хелпер в движке).
        if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
        a.placedCount++;
        this._playPlace();

        if (typeof this._onPlace === 'function') this._onPlace(result);

        if (a.opts.chainPlace) {
            // Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
            // Просто продолжаем тик; valid пересчитается в следующем кадре.
            return true;
        }
        this._teardown(false);
        return true;
    }

    /** Повернуть preview на N градусов вокруг Y. */
    rotate(deg) {
        const a = this._active;
        if (!a) return;
        const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
        a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
        if (a.preview) a.preview.rotation.y = a.rotationY;
    }

    // ── Внутреннее ──────────────────────────────────────────────────────

    _createPreview(o) {
        const base = Color3.FromHexString(o.previewColor || '#a0522d');

        // Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
        // модели — полупрозрачную копию. Так тень точно повторяет форму предмета
        // И совпадает по позиционированию с реальным spawn (модель растёт от угла
        // root, а не центрируется — куб-превью раньше центрировался → предмет
        // вставал в угол превью). Здесь превью = тот же addInstance, поэтому
        // угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
        const pt = o.previewType || '';
        if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
            // Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
            const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
            stub.isPickable = false;
            stub._baseColor = base;
            this._buildUserModelPreview(pt, o, base);
            return stub;
        }

        // Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
        const edge = Number(o.previewScale) || 1;
        const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
        const mat = new StandardMaterial('placementGhostMat', this.scene);
        mat.diffuseColor = base;
        mat.emissiveColor = base.scale(0.25);
        mat.specularColor = new Color3(0, 0, 0);
        mat.alpha = o.ghostOpacity;
        mat.disableLighting = true;
        ghost.material = mat;
        ghost.isPickable = false;
        ghost._baseColor = base;
        return ghost;
    }

    /** Построить полупрозрачное превью из реальной воксельной модели (async). */
    async _buildUserModelPreview(previewType, o, base) {
        try {
            const um = this.s.userModelManager;
            // Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
            const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
                scale: o.modelScale || o.previewScale || 1,
                canCollide: false, visible: true, anchored: true,
                currentUserId: this.s._currentUserId || null,
            });
            if (instId == null) return;
            // Сессия уже могла завершиться/смениться, пока грузилось.
            const a = this._active;
            if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
            const inst = um.instances.get(instId);
            if (!inst || !inst.rootNode) return;
            // Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
            const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
            ghostMat.diffuseColor = base;
            ghostMat.emissiveColor = base.scale(0.25);
            ghostMat.specularColor = new Color3(0, 0, 0);
            ghostMat.alpha = o.ghostOpacity;
            ghostMat.disableLighting = true;
            ghostMat.backFaceCulling = false;
            for (const m of (inst.meshes || [])) {
                m.isPickable = false;
                m.material = ghostMat;
            }
            // Центр модели по X/Z (воксели растут углом от root → центр смещён).
            // Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
            // Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
            // по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
            // и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
            let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
            for (const m of (inst.meshes || [])) {
                m.computeWorldMatrix(true);
                const bb = m.getBoundingInfo().boundingBox;
                minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
                minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
            }
            const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
            const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
            a._modelOffsetX = offX;
            a._modelOffsetZ = offZ;

            // Удаляем временный stub, новый root становится превью.
            const old = a.preview;
            a.preview = inst.rootNode;
            a.preview._baseColor = base;
            a.preview._userModelInstId = instId;   // для teardown
            a.preview._ghostMat = ghostMat;
            if (old) { try { old.dispose(); } catch (e) {} }
        } catch (e) {
            // тихо — превью некритично, останется stub
        }
    }

    _startTick() {
        this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
    }

    _tick() {
        const a = this._active;
        if (!a) return;
        const scn = this.scene;

        // Raycast от камеры через текущую позицию курсора.
        const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
            m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
        if (pick && pick.hit && pick.pickedPoint) {
            let p = pick.pickedPoint.clone();
            // surfaceMode 'ground' — нормаль должна смотреть вверх.
            // Поверхность валидна, если смотрит вверх (горизонтальная грань).
            // Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
            let surfOk = true;
            if (a.opts.surfaceMode === 'ground') {
                const n = pick.getNormal(true);
                surfOk = n && n.y > 0.6;   // только грань, обращённая вверх
            }
            // Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
            // лёг ровно сверху на пол ИЛИ на другой объект (стопка).
            if (a.opts.grid > 0) {
                p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
                p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
            }
            a.pos.copyFrom(p);
            if (a.preview) {
                if (a.preview._userModelInstId != null) {
                    // userModel-превью: root = угол модели. Вычитаем offset центра
                    // по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
                    // Высота p.y без сдвига (низ модели на поверхность).
                    a.preview.position.set(
                        p.x - (a._modelOffsetX || 0),
                        p.y,
                        p.z - (a._modelOffsetZ || 0),
                    );
                } else {
                    // Куб-превью центрирован → поднимаем на полвысоты.
                    a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
                }
            }
            // Валидность. forbidOverlap теперь означает «не врезаться вбок в
            // объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
            a.valid = surfOk
                && this._inZone(p, a.opts)
                && this._distanceOk(p, a.opts)
                && this._limitOk(a.opts)
                && this._affordable(a)
                && (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
        } else {
            a.valid = false;
        }

        // Цвет preview: зелёный/красный.
        this._applyTint(a, a.valid);

        // Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
        // напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
        const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
        if (a.opts.previewPulse && pmat) {
            a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
            const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI);   // 0..1
            pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
        }

        // HUD-индикатор ошибки (красный текст когда невалидно).
        this._emitHudError(!a.valid);

        // Стрелка к зоне — обновим конечную точку (если игрок движется).
        if (a.arrowFxRef) this._updateArrow();

        // onMove колбэк автору (каждый кадр).
        if (typeof this._onMove === 'function') {
            this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
        }
    }

    _applyTint(a, valid) {
        // Материал куба-превью напрямую, userModel-превью — в _ghostMat.
        const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
        if (!pmat) return;
        if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
            return; // во время flash держим красный
        }
        const tint = valid ? VALID_TINT : INVALID_TINT;
        // Смешиваем базовый цвет с tint-ом (multiply-эффект).
        const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
        pmat.diffuseColor = new Color3(
            b.r * tint.r + tint.r * 0.4,
            b.g * tint.g + tint.g * 0.4,
            b.b * tint.b + tint.b * 0.4,
        );
        pmat.emissiveColor = tint.scale(0.35);
    }

    _flashInvalid() {
        const a = this._active;
        if (!a || !a.preview || !a.preview.material) return;
        try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
        a.preview.material.diffuseColor = INVALID_TINT;
        a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
    }

    _isSurface(mesh, o) {
        if (!o.allowSurfaces) return true;     // любая поверхность
        // Совпадение по имени или тегу.
        const name = mesh.name || '';
        if (o.allowSurfaces.some(s => name.includes(s))) return true;
        const tags = mesh.metadata && mesh.metadata.tags;
        if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
        return false;
    }

    _inZone(p, o) {
        if (!o.targetZone) return true;
        const z = this._resolveZoneMesh(o.targetZone);
        if (!z) return true;
        const bb = z.getBoundingInfo().boundingBox;
        const min = bb.minimumWorld, max = bb.maximumWorld;
        return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
    }

    _distanceOk(p, o) {
        if (!o.maxDistance || o.maxDistance <= 0) return true;
        const pl = this.s.player && this.s.player._pos;
        if (!pl) return true;
        const dx = p.x - pl.x, dz = p.z - pl.z;
        return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
    }

    _limitOk(o) {
        if (!o.maxItems || o.maxItems <= 0) return true;
        return (this._active.placedCount || 0) < o.maxItems;
    }

    _overlapsSide(p, a) {
        // Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
        // (его тело пересекает уровень, куда ляжет новый объект). Объект строго
        // НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
        // строить башню из кубов, но не даёт двум кубам слипнуться вбок.
        const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
        const newY = p.y;                         // высота поверхности (низ нового объекта)
        const newTop = newY + (a.opts.previewScale || 1);
        for (const m of this.scene.meshes) {
            if (!m.isPickable || m === a.preview) continue;
            if (!m.getBoundingInfo) continue;
            const bb = m.getBoundingInfo().boundingBox;
            const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
            if (sizeX > 8) continue;              // пол/большая поверхность — не препятствие
            const c = bb.centerWorld;
            const dx = c.x - p.x, dz = c.z - p.z;
            if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue;   // не под курсором
            const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
            // Пересечение по вертикали: тела перекрываются по Y → бок в бок.
            const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
            if (overlapY) return true;
        }
        return false;
    }

    /** Хватает ли валюты на текущий предмет (если задан баланс). */
    _affordable(a) {
        const cur = a.opts.currency;
        const cost = a.opts.cost || 0;
        if (!cost) return true;
        const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
        return cost <= bal;
    }

    /** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
    setBalance(currency, amount) {
        if (!this._balances) this._balances = {};
        if (currency) this._balances[currency] = Number(amount) || 0;
    }

    _resolveZoneMesh(ref) {
        // ref может быть строкой ('primitive:N' / имя) или уже мешем.
        if (ref && ref.getBoundingInfo) return ref;
        if (typeof ref === 'string') {
            // через scene3d — найти примитив/модель по ref
            try {
                const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
                if (mesh) return mesh;
            } catch { /* ignore */ }
            // fallback — по имени
            return this.scene.getMeshByName(ref) || null;
        }
        return null;
    }

    _createZoneOutline() {
        const a = this._active;
        const z = this._resolveZoneMesh(a.opts.targetZone);
        if (!z) return;
        const bb = z.getBoundingInfo().boundingBox;
        const min = bb.minimumWorld, max = bb.maximumWorld;
        const y = min.y + 0.06;
        const pts = [
            new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
            new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
            new Vector3(min.x, y, min.z),
        ];
        const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
        line.color = new Color3(1, 0.19, 0.19);
        line.isPickable = false;
        // glow-имитация: чуть приподнятая полупрозрачная плоскость
        a.zoneOutline = line;
    }

    _createArrow() {
        const a = this._active;
        // Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
        try {
            const bm = this.s.beamManager;
            if (!bm || !bm.addPointer) return;
            const z = this._resolveZoneMesh(a.opts.targetZone);
            if (!z) return;
            const c = z.getBoundingInfo().boundingBox.centerWorld;
            const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
                ? this.s.player._pos
                : this._resolveZoneMesh(a.opts.showArrowFrom);
            const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
            if (!fromV) return;
            a.arrowFxRef = bm.addPointer({
                from: { x: fromV.x, y: fromV.y, z: fromV.z },
                to: { x: c.x, y: c.y + 0.6, z: c.z },
                preset: 'guide',
            });
        } catch { /* стрелка не критична */ }
    }

    _updateArrow() {
        // Стрелка статична от точки старта к зоне (как в Roblox tycoon —
        // указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
        // а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
    }

    _forceThirdCamera() {
        const a = this._active;
        try {
            if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
                a.prevCameraMode = this.s.player.getCameraMode();
                if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
            }
        } catch { /* ignore */ }
    }

    _setPlayerFrozen(frozen) {
        try {
            if (this.s.player && this.s.player.setFrozen) {
                if (this._active) this._active.prevFrozen = true;
                this.s.player.setFrozen(frozen);
            }
        } catch { /* ignore */ }
    }

    _spendCurrency(currency, amount) {
        // Движок не держит «кошелёк» — это делает игра через onPlace + save.
        // Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
        try {
            if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
        } catch { /* ignore */ }
    }

    _playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
    _playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }

    _emitHud(show) {
        // Сообщаем движку показать/скрыть placement-HUD (подсказки).
        try {
            if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
        } catch { /* ignore */ }
    }

    _emitHudError(isError) {
        try {
            if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
        } catch { /* ignore */ }
    }

    _teardown(emitHudOff) {
        const a = this._active;
        if (!a) return;
        if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
        if (a.preview) {
            try {
                if (a.preview._userModelInstId != null && this.s.userModelManager) {
                    // userModel-превью — это реальный инстанс; удаляем через менеджер
                    // (снимет из Map + dispose мешей). + чистим ghost-материал.
                    try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
                    this.s.userModelManager.removeInstance(a.preview._userModelInstId);
                } else {
                    a.preview.material && a.preview.material.dispose();
                    a.preview.dispose();
                }
            } catch { /* ignore */ }
        }
        if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
        if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
            try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
        }
        if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
            try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
        }
        if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
            try { this.s.player.setFrozen(false); } catch { /* ignore */ }
        }
        this._active = null;
        if (emitHudOff !== false) this._emitHud(false);
    }

    /** Полный сброс при Stop игры. */
    dispose() {
        this._teardown(true);
        this._onPlace = this._onCancel = this._onMove = null;
    }
}
