/**
 * ModelManager — управление 3D-моделями (GLB) в сцене Babylon.js.
 *
 * Концепция:
 *   - GLB-файл загружается ОДИН РАЗ через SceneLoader.LoadAssetContainerAsync
 *     и кешируется как "prototype" (AssetContainer).
 *   - При размещении модели на сцене делаем clone() из prototype — это создаёт
 *     обычный mesh, который можно двигать/поворачивать/удалять независимо.
 *
 * Хранилище инстансов:
 *   instances: Map<instanceId, { rootMesh, modelTypeId, gridX, gridY, gridZ, rotation }>
 *
 * Public API:
 *   addInstance(modelTypeId, x, y, z, rotationY)   → instanceId | null
 *   removeInstance(instanceId)                      → bool
 *   removeInstanceByMesh(mesh)                      → bool
 *   getInstanceCount()
 *   serialize() / loadFromArray()
 *   preloadAll(progressCb)                          → promise (опционально)
 *
 * Инстансы НЕ привязаны к 1×1×1 сетке — модели могут стоять в произвольных
 * координатах (некоторые модели Kenney крупнее единичного куба, например стены).
 */
import {
    SceneLoader,
    Vector3,
    Color3,
    TransformNode,
    VertexBuffer,
    MeshBuilder,
    StandardMaterial,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';   // регистрирует GLB/GLTF loader
import { getModelType } from './ModelTypes';

export class ModelManager {
    constructor(scene, scene3d = null) {
        this.scene = scene;
        this.instances = new Map();
        this._prototypes = new Map();
        this._nextInstanceId = 1;
        this._onChange = null;       // dirty-tracking
        // BabylonScene-обёртка — для доступа к folderManager/primitiveManager
        // и addShadowCaster (авто-регистрация моделей при спавне).
        this._scene3d = scene3d;
        this.scene3d = scene3d;        // алиас (NpcManager-style)
    }

    setScene3D(scene3d) {
        this._scene3d = scene3d;
    }

    setOnChange(cb) {
        this._onChange = cb;
    }

    /**
     * Поставить «сложную» модель — она имеет parts:[] и кладётся в папку.
     * Каждая part может быть:
     *   { kind: 'mesh', model: 'id-другой-простой-модели', dx, dy, dz, ry }
     *   { kind: 'primitive', type: 'cube', sx, sy, sz, color, dx, dy, dz, ry }
     *
     * Возвращает instanceId первой part (главной модели), чтобы выделение работало.
     */
    /**
     * Synthetic-модели не из GLB. Сейчас поддерживается только 'spawner-disk' —
     * фиолетовый диск + кольцо для спавнера зомби.
     */
    _addSyntheticInstance(modelType, x, y, z, rotationY = 0) {
        const instanceId = this._nextInstanceId++;
        const root = new TransformNode(`syn_${modelType.id}_${instanceId}`, this.scene);
        root.position = new Vector3(x, y, z);
        root.rotation = new Vector3(0, rotationY, 0);

        const clonedMeshes = [];

        if (modelType.synthetic === 'spawner-disk') {
            // Радиус 1 блок (диаметр 2)
            const disk = MeshBuilder.CreateCylinder(`spawnerDisk_${instanceId}`, {
                diameter: 2.0, height: 0.18, tessellation: 24,
            }, this.scene);
            disk.parent = root;
            disk.position.set(0, 0.09, 0);
            const diskMat = new StandardMaterial(`spawnerDiskMat_${instanceId}`, this.scene);
            diskMat.emissiveColor = new Color3(0.55, 0.1, 0.55);
            diskMat.diffuseColor = new Color3(0.3, 0.05, 0.3);
            diskMat.specularColor = new Color3(0.1, 0.1, 0.1);
            disk.material = diskMat;
            disk.isPickable = true;
            disk.metadata = { isModel: true, instanceId };
            clonedMeshes.push(disk);

            const ring = MeshBuilder.CreateTorus(`spawnerRing_${instanceId}`, {
                diameter: 1.7, thickness: 0.14, tessellation: 24,
            }, this.scene);
            ring.parent = root;
            ring.position.set(0, 0.22, 0);
            const ringMat = new StandardMaterial(`spawnerRingMat_${instanceId}`, this.scene);
            ringMat.emissiveColor = new Color3(1, 0.35, 1);
            ringMat.diffuseColor = new Color3(0.5, 0.2, 0.5);
            ring.material = ringMat;
            ring.isPickable = true;
            ring.metadata = { isModel: true, instanceId };
            clonedMeshes.push(ring);
        }

        const data = {
            instanceId,
            rootMesh: root,
            modelTypeId: modelType.id,
            x, y, z, rotationY,
            clonedMeshes,
            anchored: true, canCollide: false, visible: true, mass: 1,
            folderId: null,
            localAABB: { minX: -1, maxX: 1, minY: 0, maxY: 0.4, minZ: -1, maxZ: 1 },
            gameplay: modelType.gameplay || null,
            // Тег чтобы знать что это synthetic — для пульсации в render-loop
            _synthetic: modelType.synthetic,
        };
        this.instances.set(instanceId, data);
        // Регистрируем тик пульсации для синтетики
        this._ensureSyntheticTick();
        this._notifyChange();
        return instanceId;
    }

    /** Регистрация общего тика для пульсации synthetic-объектов. */
    _ensureSyntheticTick() {
        if (this._synTickHook) return;
        this._synTickHook = () => {
            const t = performance.now() * 0.001;
            for (const data of this.instances.values()) {
                if (data._synthetic !== 'spawner-disk') continue;
                const ring = data.clonedMeshes?.[1];
                if (!ring) continue;
                const k = 0.85 + 0.15 * Math.sin(t * 3);
                ring.scaling.x = k;
                ring.scaling.z = k;
                ring.rotation.y = t * 0.7;
            }
        };
        this.scene.registerBeforeRender(this._synTickHook);
    }

    async _addCompoundInstance(modelType, x, y, z, rotationY = 0) {
        const folderId = this._scene3d.folderManager.createFolder(modelType.name, null);
        const cosY = Math.cos(rotationY), sinY = Math.sin(rotationY);
        const place = (dx, dz) => ({ wx: x + dx * cosY + dz * sinY, wz: z - dx * sinY + dz * cosY });
        let firstId = null;
        for (const part of modelType.parts) {
            const dx = part.dx || 0, dy = part.dy || 0, dz = part.dz || 0;
            const ry = (part.ry || 0) + rotationY;
            const w = place(dx, dz);
            if (part.kind === 'mesh' && part.model) {
                // Рекурсивно ставим простую модель — она попадает в this.instances
                const id = await this.addInstance(part.model, w.wx, y + dy, w.wz, ry);
                if (id != null) {
                    this._scene3d.folderManager.assignToFolder('model', id, folderId);
                    if (firstId == null) firstId = id;
                }
            } else if (part.kind === 'primitive' && this._scene3d.primitiveManager) {
                const pid = this._scene3d.primitiveManager.addInstance(part.type || 'cube', {
                    x: w.wx, y: y + dy, z: w.wz,
                    sx: part.sx, sy: part.sy, sz: part.sz,
                    color: part.color, material: part.material,
                    rotationX: part.rx || 0, rotationY: ry, rotationZ: part.rz || 0,
                });
                if (pid != null) {
                    this._scene3d.folderManager.assignToFolder('primitive', pid, folderId);
                }
            }
        }
        return firstId;
    }

    _notifyChange() {
        if (this._onChange) this._onChange();
    }

    /**
     * Резолв modelTypeId в запись модели.
     * Для 'glb:<id>' (Фаза 5.8 — импортированная пользователем .glb)
     * строит inline-запись с data-URL из GlbLibrary. Иначе — getModelType.
     */
    _resolveModelType(modelTypeId) {
        if (typeof modelTypeId === 'string' && modelTypeId.indexOf('glb:') === 0) {
            const lib = this._scene3d && this._scene3d.glbLibrary;
            const dataUrl = lib && lib.getDataUrl(modelTypeId.slice(4));
            if (!dataUrl) return null;
            return { id: modelTypeId, name: 'GLB-модель', file: dataUrl, _isGlb: true };
        }
        // Фаза 5.6 RUBLOX_DESIGNER_PLAN.md: прямой URL — для preview моделей
        // от дизайнеров (rublox_designer_models.file_path).
        // Формат: 'url:<полный URL к .glb>' — например
        // 'url:/api-storys/assets/rublox-designer/models/1/oak_chair.glb'.
        if (typeof modelTypeId === 'string' && modelTypeId.indexOf('url:') === 0) {
            let url = modelTypeId.slice(4);
            // Если URL относительный /api-storys/... — превращаем в абсолютный.
            // Используем VITE_API_BASE если задан, иначе текущий origin.
            if (url && url.startsWith('/api-storys/')) {
                const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
                const base = env.VITE_API_BASE
                    || (typeof window !== 'undefined' ? window.location.origin : '');
                if (base) url = base + url;
            }
            return { id: modelTypeId, name: 'Designer-модель', file: url, _isGlb: true };
        }
        return getModelType(modelTypeId);
    }

    /**
     * Загрузить (или взять из кеша) GLB-модель.
     * Возвращает AssetContainer.
     */
    async _loadPrototype(modelTypeId) {
        if (this._prototypes.has(modelTypeId)) {
            const cached = this._prototypes.get(modelTypeId);
            return cached instanceof Promise ? await cached : cached;
        }

        const modelType = this._resolveModelType(modelTypeId);
        if (!modelType) {
            // eslint-disable-next-line no-console
            console.error('[ModelManager] Unknown model type:', modelTypeId);
            return null;
        }

        let rootUrl, filename, pluginExt;
        if (modelType._isGlb && modelType.file.startsWith('data:')) {
            // Импортированная .glb — file это base64 data-URL.
            // SceneLoader: rootUrl пустой, filename = data-URL, расширение явно.
            rootUrl = '';
            filename = modelType.file;
            pluginExt = '.glb';
        } else {
            // Парсим путь для SceneLoader: ему нужны (rootUrl, sceneFilename).
            // Работает для абсолютных https://... и относительных /path/file.glb.
            const lastSlash = modelType.file.lastIndexOf('/');
            rootUrl = modelType.file.substring(0, lastSlash + 1);
            filename = modelType.file.substring(lastSlash + 1);
            pluginExt = undefined;
        }

        const promise = SceneLoader.LoadAssetContainerAsync(rootUrl, filename, this.scene, null, pluginExt)
            .then(container => {
                this._prototypes.set(modelTypeId, container);
                return container;
            })
            .catch(err => {
                // eslint-disable-next-line no-console
                console.error('[ModelManager] failed to load:', modelTypeId,
                    'from', rootUrl + filename, err);
                this._prototypes.delete(modelTypeId);
                return null;
            });

        this._prototypes.set(modelTypeId, promise);
        return await promise;
    }

    /**
     * Поставить инстанс модели в (x, y, z).
     * x/y/z — мировые координаты центра модели на полу (низ модели = y).
     * rotationY — поворот вокруг вертикальной оси в радианах.
     * Возвращает instanceId.
     */
    async addInstance(modelTypeId, x, y, z, rotationY = 0) {
        const modelType = this._resolveModelType(modelTypeId);
        if (!modelType) return null;

        // Synthetic-модели: GLB не загружается, меш рисуется примитивом.
        if (modelType.synthetic) {
            return this._addSyntheticInstance(modelType, x, y, z, rotationY);
        }

        // Если модель «сложная» (parts) — создаём папку через folderManager
        // и добавляем каждую часть в неё. Возвращаем instanceId главной части.
        if (Array.isArray(modelType.parts) && modelType.parts.length > 0
            && this._scene3d?.folderManager) {
            return await this._addCompoundInstance(modelType, x, y, z, rotationY);
        }

        const proto = await this._loadPrototype(modelTypeId);
        if (!proto) return null;

        // Клонируем все меши в новый корневой узел
        const root = new TransformNode(`model_${this._nextInstanceId}`, this.scene);
        const clonedMeshes = [];

        // proto.meshes содержит __root__ и все дочерние. Используем
        // instantiateModelsToScene — Babylon-овский способ инстанцировать GLB.
        const inst = proto.instantiateModelsToScene(
            (name) => `${modelTypeId}_${this._nextInstanceId}_${name}`,
            true,
            { doNotInstantiate: false }
        );
        // КРИТИЧНО ДЛЯ FPS: Babylon при cloneAnimations=true КЛОНИРУЕТ animationGroups
        // и они автоматически могут включаться (некоторые GLB имеют autoplay).
        // На зомби-острове это означает что 6-10 моделей анимируют ВСЕ свои
        // animationGroups (idle/walk/death/attack — десятки) каждый кадр, даже
        // если рендер использует только idle. Останавливаем все клонированные
        // группы — ZombieManager / PlayerController сами стартуют нужную через
        // animations.idle.start() когда пора.
        if (inst.animationGroups) {
            for (const g of inst.animationGroups) {
                try { g.stop(); } catch (e) {}
            }
        }

        // Все root-узлы инстанса парентим к нашему TransformNode
        for (const r of inst.rootNodes) {
            r.parent = root;
            // Все mesh-чилдрены — pickable, помечаем metadata, принимают тени
            r.getChildMeshes(false).forEach(m => {
                m.isPickable = true;
                m.metadata = { isModel: true, instanceId: this._nextInstanceId };
                // Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
                if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
                    m.receiveShadows = true;
                }
                clonedMeshes.push(m);
            });
            // И сам root тоже на всякий
            if (r.getTotalVertices && r.getTotalVertices() > 0) {
                r.isPickable = true;
                r.metadata = { isModel: true, instanceId: this._nextInstanceId };
                r.receiveShadows = true;
                clonedMeshes.push(r);
            }
        }

        // ОПТИМИЗАЦИЯ убрана: попытка делить материалы между инстансами
        // ломала AssetContainer прото для других пользователей (PlayerController,
        // MultiplayerSync), которые тоже делают instantiateModelsToScene.
        // dispose() клонированного материала иногда уничтожал прото-материал
        // → последующие инстансы получали material=null → невидимы.
        // Babylon самостоятельно батчит draw calls по uniqueId материала.

        // Делаем все клонированные меши «всегда активными» — они НЕ должны
        // фильтроваться через scene.freezeActiveMeshes() / frustum culling
        // если их добавили после заморозки активных мешей.
        for (const m of clonedMeshes) {
            if (m && m.alwaysSelectAsActiveMesh !== undefined) {
                m.alwaysSelectAsActiveMesh = true;
            }
        }


        root.position = new Vector3(x, y, z);
        root.rotation = new Vector3(0, rotationY, 0);

        // Вычисляем итоговый scale.
        // Если у модели задан targetHeight — нормализуем размер так,
        // чтобы реальная высота AABB равнялась targetHeight (в блоках мира).
        // Это спасает от того что Kenney-модели имеют разные единицы (дом ~10, еда ~0.1).
        let finalScale = modelType.scale ?? 1;
        if (modelType.targetHeight && modelType.targetHeight > 0) {
            try {
                root.scaling = new Vector3(1, 1, 1);
                root.computeWorldMatrix(true);
                for (const m of clonedMeshes) {
                    if (m.computeWorldMatrix) m.computeWorldMatrix(true);
                }
                // Реальная высота по позициям вершин (без skin-аксессоров)
                let minY = Infinity, maxY = -Infinity;
                const tmp = new Vector3();
                for (const m of clonedMeshes) {
                    if (!m || typeof m.getTotalVertices !== 'function') continue;
                    if (m.getTotalVertices() <= 0) continue;
                    let positions;
                    try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
                    catch (e) { continue; }
                    if (!positions) continue;
                    const wm = m.getWorldMatrix();
                    for (let i = 0; i < positions.length; i += 3) {
                        tmp.set(positions[i], positions[i + 1], positions[i + 2]);
                        const w = Vector3.TransformCoordinates(tmp, wm);
                        if (w.y < minY) minY = w.y;
                        if (w.y > maxY) maxY = w.y;
                    }
                }
                const realHeight = maxY - minY;
                if (realHeight > 0.001 && isFinite(realHeight)) {
                    finalScale = modelType.targetHeight / realHeight;
                }
            } catch (e) { /* fallback на 1 */ }
        }
        root.scaling = new Vector3(finalScale, finalScale, finalScale);


        const instanceId = this._nextInstanceId++;
        const data = {
            instanceId,
            rootMesh: root,
            modelTypeId,
            x, y, z, rotationY,
            clonedMeshes,
            anchored: true,
            canCollide: true,
            visible: true,
            mass: 1,
            folderId: null,
            localAABB: null,
            // Gameplay-метаданные из ModelTypes (для зомби, спавнеров и т.п.)
            gameplay: modelType.gameplay || null,
        };
        this.instances.set(instanceId, data);
        // Авто-регистрация в shadow casters (Этап 4 теней). Скрипты
        // спавнящие модели через scene.spawn должны видеть тень сразу,
        // а не после ручного вызова refreshAllShadows.
        try {
            const bs = this.scene3d || this.babylonScene;
            if (bs && typeof bs.addShadowCaster === 'function' && clonedMeshes) {
                for (const m of clonedMeshes) bs.addShadowCaster(m);
            }
        } catch (e) { /* ignore */ }
        // Считаем bounding box после небольшой задержки — Babylon мог ещё не
        // обновить world-матрицы дочерних мешей сразу после instantiate.
        // Берём локальный AABB (без учёта позиции rootMesh).
        try {
            this.scene.executeWhenReady(() => {
                const inst = this.instances.get(instanceId);
                if (!inst || !inst.rootMesh) return;
                this._computeLocalAABB(inst);
                // ОПТИМИЗАЦИЯ: для статичных моделей (деревья, дома, камни)
                // — те что не зомби и не спавнеры — замораживаем world-matrix.
                // Babylon перестанет пересчитывать матрицу каждого child-меша
                // при render. Это даёт заметный выигрыш на сценах с десятками
                // GLB-объектов.
                // freeze world matrix — выполняется централизованно через
                // ModelManager.freezeStaticModels() при enterPlayMode.
                // Здесь не делаем — у редактора с гизмо это сломало бы
                // перетаскивание моделей.
            });
        } catch (e) { /* ignore */ }

        this._notifyChange();
        return instanceId;
    }

    /** Удалить инстанс по id. */
    removeInstance(instanceId) {
        const data = this.instances.get(instanceId);
        if (!data) return false;
        for (const m of data.clonedMeshes) {
            try { m.dispose(); } catch (e) { /* ignore */ }
        }
        try { data.rootMesh.dispose(); } catch (e) { /* ignore */ }
        this.instances.delete(instanceId);
        this._notifyChange();
        return true;
    }

    /** Удалить инстанс по mesh (после raycast). */
    removeInstanceByMesh(mesh) {
        if (!mesh || !mesh.metadata?.isModel) return false;
        return this.removeInstance(mesh.metadata.instanceId);
    }

    /** Сколько инстансов на сцене. */
    getInstanceCount() {
        return this.instances.size;
    }

    /**
     * Заморозить world-matrix всех статичных GLB-моделей (не зомби и не
     * спавнеров). Babylon перестаёт пересчитывать матрицы каждого
     * child-mesh каждый кадр — заметный буст на сценах с GLB-проп'ами
     * (деревья, дома, камни).
     *
     * Вызывается из BabylonScene.enterPlayMode после регистрации зомби.
     */
    freezeStaticModels() {
        for (const inst of this.instances.values()) {
            if (inst._worldMatrixFrozen) continue;
            const gp = inst.gameplay;
            if (gp?.isZombie || gp?.isZombieSpawner) continue;
            // Зомби, спавнутые во время Play (через ZombieSpawnerManager),
            // НЕ имеют gameplay.isZombie (они character-c), но точно не должны
            // замораживаться — за ними двигает зомби-менеджер.
            if (inst._spawnedAtRuntime) continue;
            if (inst.anchored === false) continue;  // unanchored = физическое тело
            if (!inst.rootMesh) continue;
            try {
                inst.rootMesh.computeWorldMatrix(true);
                inst.rootMesh.freezeWorldMatrix();
                for (const m of inst.clonedMeshes) {
                    if (m && m.computeWorldMatrix) {
                        m.computeWorldMatrix(true);
                        if (m.freezeWorldMatrix) m.freezeWorldMatrix();
                        m.doNotSyncBoundingInfo = true;
                    }
                }
                inst._worldMatrixFrozen = true;
            } catch (e) { /* ignore */ }
        }
    }

    /** Разморозить — вызывается при exitPlayMode чтобы редактор мог двигать. */
    unfreezeStaticModels() {
        for (const inst of this.instances.values()) {
            if (!inst._worldMatrixFrozen) continue;
            try {
                inst.rootMesh?.unfreezeWorldMatrix?.();
                for (const m of inst.clonedMeshes) {
                    if (m && m.unfreezeWorldMatrix) m.unfreezeWorldMatrix();
                    if (m) m.doNotSyncBoundingInfo = false;
                }
                inst._worldMatrixFrozen = false;
            } catch (e) { /* ignore */ }
        }
    }

    /** Очистить всё. */
    clear() {
        // removeInstance внутри уже вызывает _notifyChange — отключаем на время
        // массовой очистки и шлём один уведомление в конце.
        const cb = this._onChange;
        this._onChange = null;
        const had = this.instances.size > 0;
        for (const id of Array.from(this.instances.keys())) {
            this.removeInstance(id);
        }
        this._onChange = cb;
        if (had) this._notifyChange();
    }

    /** Сериализация для сохранения проекта. */
    serialize() {
        const out = [];
        for (const data of this.instances.values()) {
            // Спавнерные зомби (созданные в Play) не сохраняем
            if (data._spawnedAtRuntime) continue;
            out.push({
                type: data.modelTypeId,
                x: data.x, y: data.y, z: data.z,
                rotationY: data.rotationY,
                anchored: data.anchored !== false,
                canCollide: data.canCollide !== false,
                visible: data.visible !== false,
                mass: data.mass ?? 1,
                opacity: typeof data.opacity === 'number' ? data.opacity : 1,
                tint: data.tint || null,
                name: data.name || null,
                ...(data.folderId != null ? { folderId: data.folderId } : {}),   // папка
                // Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
                gameplayParams: data.gameplayParams || null,
            });
        }
        return out;
    }

    /**
     * Посчитать локальный AABB модели (относительно её rootMesh.position).
     * Делаем один раз — модели в этом редакторе не деформируются.
     * scale rootMesh учитывается автоматически (мы вычисляем уже после
     * того как scaling применён).
     */
    _computeLocalAABB(data) {
        if (!data.rootMesh) return;
        try {
            data.rootMesh.computeWorldMatrix(true);
            for (const m of data.clonedMeshes || []) {
                if (m.computeWorldMatrix) m.computeWorldMatrix(true);
            }

            // Считаем bbox по РЕАЛЬНЫМ позициям вершин (а не через boundingInfo,
            // которое у Kenney character-моделей ломается из-за skin/joint-аксессоров).
            let minX = Infinity, minY = Infinity, minZ = Infinity;
            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
            let foundAny = false;
            const tmp = new Vector3();
            for (const m of data.clonedMeshes || []) {
                if (!m || typeof m.getTotalVertices !== 'function') continue;
                if (m.getTotalVertices() <= 0) continue;
                if (m.isEnabled && !m.isEnabled()) continue;
                if (m.isVisible === false) continue;
                let positions;
                try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
                catch (e) { continue; }
                if (!positions || positions.length === 0) continue;
                let wm;
                try { wm = m.getWorldMatrix(); } catch (e) { continue; }
                if (!wm) continue;
                for (let i = 0; i < positions.length; i += 3) {
                    tmp.set(positions[i], positions[i + 1], positions[i + 2]);
                    const w = Vector3.TransformCoordinates(tmp, wm);
                    if (w.x < minX) minX = w.x;
                    if (w.y < minY) minY = w.y;
                    if (w.z < minZ) minZ = w.z;
                    if (w.x > maxX) maxX = w.x;
                    if (w.y > maxY) maxY = w.y;
                    if (w.z > maxZ) maxZ = w.z;
                    foundAny = true;
                }
            }
            if (!foundAny) {
                // Fallback на старую логику
                const { min, max } = data.rootMesh.getHierarchyBoundingVectors(true);
                minX = min.x; minY = min.y; minZ = min.z;
                maxX = max.x; maxY = max.y; maxZ = max.z;
            }

            const rp = data.rootMesh.position;
            // Локальные координаты относительно rootMesh.position
            minX -= rp.x; maxX -= rp.x;
            minY -= rp.y; maxY -= rp.y;
            minZ -= rp.z; maxZ -= rp.z;

            // Сжимаем AABB по горизонтали на 30% — у GLB часто широкие коллайдеры
            // (листва дерева, лапы животных), и игрок упирается в пустое место.
            const SHRINK_HORZ = 0.7;
            const cx = (minX + maxX) / 2;
            const cz = (minZ + maxZ) / 2;
            minX = cx + (minX - cx) * SHRINK_HORZ;
            maxX = cx + (maxX - cx) * SHRINK_HORZ;
            minZ = cz + (minZ - cz) * SHRINK_HORZ;
            maxZ = cz + (maxZ - cz) * SHRINK_HORZ;
            data.localAABB = { minX, maxX, minY, maxY, minZ, maxZ };
        } catch (e) {
            // Fallback: куб 1×1×1 от позиции вверх
            data.localAABB = {
                minX: -0.5, maxX: 0.5,
                minY: 0,    maxY: 1,
                minZ: -0.5, maxZ: 0.5,
            };
        }
    }

    /**
     * Получить мировой AABB модели (для физики).
     * Возвращает null если модель ещё не готова.
     */
    getInstanceAABB(instanceId) {
        const data = this.instances.get(instanceId);
        if (!data || !data.rootMesh) return null;
        if (!data.localAABB) {
            this._computeLocalAABB(data);
            if (!data.localAABB) return null;
        }
        const a = data.localAABB;
        let lminX = a.minX, lmaxX = a.maxX, lminZ = a.minZ, lmaxZ = a.maxZ;
        const rotY = data.rotationY || 0;
        if (rotY !== 0) {
            const c = Math.cos(rotY), s = Math.sin(rotY);
            const corners = [
                { x: a.minX, z: a.minZ }, { x: a.maxX, z: a.minZ },
                { x: a.maxX, z: a.maxZ }, { x: a.minX, z: a.maxZ },
            ];
            lminX = Infinity; lmaxX = -Infinity; lminZ = Infinity; lmaxZ = -Infinity;
            for (const cr of corners) {
                const rx = cr.x * c - cr.z * s;
                const rz = cr.x * s + cr.z * c;
                if (rx < lminX) lminX = rx;
                if (rx > lmaxX) lmaxX = rx;
                if (rz < lminZ) lminZ = rz;
                if (rz > lmaxZ) lmaxZ = rz;
            }
        }
        return {
            minX: data.x + lminX, maxX: data.x + lmaxX,
            minY: data.y + a.minY, maxY: data.y + a.maxY,
            minZ: data.z + lminZ, maxZ: data.z + lmaxZ,
        };
    }

    /** Установить свойство модели и применить эффект к мешам. */
    setInstanceProps(instanceId, patch) {
        const data = this.instances.get(instanceId);
        if (!data) return;
        if (patch.anchored !== undefined) data.anchored = !!patch.anchored;
        if (patch.canCollide !== undefined) data.canCollide = !!patch.canCollide;
        if (patch.mass !== undefined) {
            const m = Number(patch.mass);
            if (Number.isFinite(m) && m > 0) data.mass = m;
        }
        if (patch.visible !== undefined) {
            data.visible = !!patch.visible;
            if (data.rootMesh) {
                // setEnabled(false) скрывает всю иерархию (TransformNode + дети)
                data.rootMesh.setEnabled(data.visible);
            }
        }
        if (patch.opacity !== undefined) {
            const a = Math.max(0, Math.min(1, Number(patch.opacity)));
            if (Number.isFinite(a)) {
                data.opacity = a;
                this._applyMaterialOverrides(data);
            }
        }
        if (patch.tint !== undefined) {
            // tint = '#RRGGBB' или null/'' для сброса
            data.tint = patch.tint || null;
            this._applyMaterialOverrides(data);
        }
        if (patch.gameplayParams !== undefined) {
            data.gameplayParams = patch.gameplayParams || null;
        }
        this._notifyChange();
    }

    /**
     * Применить tint (умножение albedo) и opacity (alpha) ко всем материалам
     * меша модели. Сохраняем оригинальные значения в _origMat для возможности
     * сброса (когда tint=null и opacity=1).
     */
    _applyMaterialOverrides(data) {
        if (!data?.rootMesh) return;
        const meshes = data.rootMesh.getChildMeshes ? data.rootMesh.getChildMeshes() : [];
        const tintHex = data.tint || null;
        const alpha = (typeof data.opacity === 'number') ? data.opacity : 1;
        // Парсим hex в Color3
        let tintColor = null;
        if (tintHex && /^#[0-9a-fA-F]{6}$/.test(tintHex)) {
            const r = parseInt(tintHex.substr(1, 2), 16) / 255;
            const g = parseInt(tintHex.substr(3, 2), 16) / 255;
            const b = parseInt(tintHex.substr(5, 2), 16) / 255;
            tintColor = new Color3(r, g, b);
        }
        for (const m of meshes) {
            const mat = m.material;
            if (!mat) continue;
            // Сохраняем оригиналы первый раз
            if (!mat._kubikonOriginalAlpha) {
                mat._kubikonOriginalAlpha = (typeof mat.alpha === 'number') ? mat.alpha : 1;
            }
            // Альфа
            mat.alpha = mat._kubikonOriginalAlpha * alpha;
            mat.transparencyMode = (mat.alpha < 1) ? 2 : 0; // ALPHABLEND : OPAQUE
            // Tint (умножение базового цвета)
            // Pbr-материал: albedoColor; Standard: diffuseColor
            if (tintColor) {
                if ('albedoColor' in mat) {
                    if (!mat._kubikonOrigAlbedo) mat._kubikonOrigAlbedo = mat.albedoColor.clone();
                    mat.albedoColor = mat._kubikonOrigAlbedo.multiplyByFloats(tintColor.r, tintColor.g, tintColor.b);
                } else if ('diffuseColor' in mat) {
                    if (!mat._kubikonOrigDiffuse) mat._kubikonOrigDiffuse = mat.diffuseColor.clone();
                    mat.diffuseColor = mat._kubikonOrigDiffuse.multiplyByFloats(tintColor.r, tintColor.g, tintColor.b);
                }
            } else {
                // сброс tint
                if (mat._kubikonOrigAlbedo && 'albedoColor' in mat) mat.albedoColor = mat._kubikonOrigAlbedo.clone();
                if (mat._kubikonOrigDiffuse && 'diffuseColor' in mat) mat.diffuseColor = mat._kubikonOrigDiffuse.clone();
            }
        }
    }

    /** Восстановление из массива. */
    async loadFromArray(arr) {
        this.clear();
        // ОПТИМИЗАЦИЯ: предзагружаем ВСЕ уникальные прототипы параллельно.
        const uniqueTypes = new Set();
        for (const m of arr) {
            if (m.type && !m.synthetic) uniqueTypes.add(m.type);
        }
        // Параллельная предзагрузка — общий запрос для всех типов сразу.
        await Promise.all(
            Array.from(uniqueTypes).map(t =>
                this._loadPrototype(t).catch(() => null)
            )
        );
        // Теперь addInstance для каждого инстанса — мгновенный (proto в кэше).
        for (const m of arr) {
            const id = await this.addInstance(m.type, m.x, m.y, m.z, m.rotationY || 0);
            if (id == null) continue;
            const data = this.instances.get(id);
            if (!data) continue;
            if (m.anchored === false) data.anchored = false;
            if (m.canCollide === false) data.canCollide = false;
            if (m.visible === false) {
                data.visible = false;
                data.rootMesh?.setEnabled(false);
            }
            if (m.mass != null) data.mass = m.mass;
            if (typeof m.opacity === 'number' && m.opacity !== 1) data.opacity = m.opacity;
            if (m.tint) data.tint = m.tint;
            if (m.name) data.name = m.name;
            if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
            if (m.folderId != null) data.folderId = m.folderId;
            if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
        }
    }

    /** Полная очистка при unmount. */
    /** Снять тик пульсации спавнеров. */
    _disposeSyntheticTick() {
        if (this._synTickHook) {
            try { this.scene.unregisterBeforeRender(this._synTickHook); } catch (e) {}
            this._synTickHook = null;
        }
    }

    dispose() {
        this._disposeSyntheticTick();
        this.clear();
        // Диспозим все загруженные prototypes-контейнеры
        for (const proto of this._prototypes.values()) {
            if (proto && typeof proto.dispose === 'function') {
                try { proto.dispose(); } catch (e) { /* ignore */ }
            }
        }
        this._prototypes.clear();
    }
}
