/**
 * UserModelManager — менеджер пользовательских воксельных моделей в сцене проекта.
 *
 * Этап 5 редактора моделей: постановка собственных voxel-моделей пользователя
 * (созданных через ModelEditorScreen) в сцену игры.
 *
 * Архитектура (упрощённый baseline-путь; greedy meshing — позже):
 *   - Каждый инстанс = один объединённый Mesh, собранный из всех voxel-кубов модели
 *   - Internal-face culling: грани между двумя соседними voxels не рисуем
 *   - Vertex-color per face: цвет/текстура запекается в материале на грань
 *   - Кэш model_data по userModelId: грузим из API один раз
 *
 * Формат внутри model_data:
 *   { version:2, size:N, voxels:[{x,y,z,c:'#RRGGBB'} | {x,y,z,t:'grass'}] }
 *
 * Использование:
 *   const mgr = new UserModelManager(scene);
 *   mgr.setApi({ getUserModel });        // подключение API-функции
 *   const instId = await mgr.addInstance('user:42', x, y, z, rotationY);
 *   mgr.removeInstance(instId);
 *
 * Изменения в БД хранилище:
 *   model_data → JSON с voxels
 *   thumbnail_b64 → preview для Toolbox
 *
 * При сохранении проекта (project_data) пользовательские инстансы
 * сериализуются как { type: 'user:42', x, y, z, ry } — id используется
 * для повторной загрузки model_data при открытии проекта.
 */
import {
    Mesh, VertexData, StandardMaterial, Texture, Color3, Color4, Vector3,
    TransformNode, VertexBuffer,
} from '@babylonjs/core';
import { decodeVoxelModel } from './voxelModelCodec';

/** Размер voxel-кубика модели в мире (метров).
 *  Должен совпадать с VOXEL_SIZE редактора моделей. */
const VOXEL_SIZE = 0.0625;

/** Базовый URL текстур — для voxel'ов с texture-id. */
const TEX_BASE = '/kubikon-assets/textures';

/** Каталог текстур — id → URL. Должен совпадать с VOXEL_TEXTURES в редакторе. */
const TEXTURES = {
    grass:      `${TEX_BASE}/grass_top.png`,
    stone:      `${TEX_BASE}/greystone.png`,
    dirt:       `${TEX_BASE}/dirt.png`,
    sand:       `${TEX_BASE}/sand.png`,
    snow:       `${TEX_BASE}/snow.png`,
    wood:       `${TEX_BASE}/wood.png`,
    leaves:     `${TEX_BASE}/leaves.png`,
    trunk:      `${TEX_BASE}/trunk_side.png`,
    water:      `${TEX_BASE}/water.png`,
    ice:        `${TEX_BASE}/ice.png`,
    gravel:     `${TEX_BASE}/gravel_dirt.png`,
    brick_red:  `${TEX_BASE}/brick_red.png`,
    brick_grey: `${TEX_BASE}/brick_grey.png`,
};

/** Конвертация '#RRGGBB' → Color4(0..1, 1). */
function hexToColor4(hex) {
    if (!hex || typeof hex !== 'string') return new Color4(1, 1, 1, 1);
    const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
    if (!m) return new Color4(1, 1, 1, 1);
    const n = parseInt(m[1], 16);
    return new Color4(
        ((n >> 16) & 0xff) / 255,
        ((n >> 8) & 0xff) / 255,
        (n & 0xff) / 255,
        1,
    );
}

/** Префикс id пользовательских моделей. */
export const USER_MODEL_PREFIX = 'user:';

/** Извлечь numeric id из 'user:42'. */
export function parseUserModelId(modelTypeId) {
    if (typeof modelTypeId !== 'string') return null;
    if (!modelTypeId.startsWith(USER_MODEL_PREFIX)) return null;
    const n = parseInt(modelTypeId.slice(USER_MODEL_PREFIX.length), 10);
    return isNaN(n) ? null : n;
}

/**
 * Описание граней куба для face-culling алгоритма.
 * Для каждой стороны: смещение к соседу + 4 vertex-positions грани (CCW от +нормали)
 * + нормаль для shading + shade — запечённый множитель яркости грани.
 *
 * shade — лёгкий запечённый множитель яркости грани (Minecraft-style):
 * верх самый светлый, бока чуть темнее, низ ещё темнее. Значения близки
 * к 1.0 — это даёт МЯГКИЙ объём; основной контраст даёт освещение сцены.
 * Слишком тёмные значения (0.5 и ниже) при включённом освещении делали
 * грани почти чёрными — поэтому диапазон сжат к светлому.
 */
const FACES = [
    // +X (бок)
    { dx: 1, dy: 0, dz: 0, n: [1, 0, 0], shade: 0.90,
      verts: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] },
    // -X (бок)
    { dx: -1, dy: 0, dz: 0, n: [-1, 0, 0], shade: 0.90,
      verts: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]] },
    // +Y (верх — самый светлый)
    { dx: 0, dy: 1, dz: 0, n: [0, 1, 0], shade: 1.00,
      verts: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]] },
    // -Y (низ — чуть темнее)
    { dx: 0, dy: -1, dz: 0, n: [0, -1, 0], shade: 0.78,
      verts: [[0,0,1],[0,0,0],[1,0,0],[1,0,1]] },
    // +Z (бок — чуть темнее X для разницы граней)
    { dx: 0, dy: 0, dz: 1, n: [0, 0, 1], shade: 0.84,
      verts: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]] },
    // -Z (бок)
    { dx: 0, dy: 0, dz: -1, n: [0, 0, -1], shade: 0.84,
      verts: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]] },
];

/**
 * UV-координаты для грани куба (стандартный quad 0,0 → 1,0 → 1,1 → 0,1).
 * Используется только для voxels с текстурой.
 */
const FACE_UVS = [[0,0],[1,0],[1,1],[0,1]];

/**
 * Скомпилировать voxels из model_data в готовые VertexData для Babylon.
 * Возвращает Map<materialKey, { positions, indices, normals, colors, uvs }>.
 *
 * materialKey:
 *   'solid' — voxels с цветом (используется vertex color)
 *   'tex:<id>' — voxels с текстурой <id>
 *
 * Каждая materialKey → отдельный submesh с отдельным StandardMaterial.
 */
export function compileVoxelModel(modelData) {
    // Кодек читает v3 (компактный), v2 и v1 — единая точка разбора формата.
    const decoded = decodeVoxelModel(modelData);
    if (!decoded || decoded.voxels.length === 0) return null;
    const data = { size: decoded.size, voxels: decoded.voxels };

    // Карта occupancy — для быстрого face-culling
    const occ = new Map();   // 'x,y,z' → voxel{c|t}
    for (const v of data.voxels) {
        if (typeof v.x !== 'number') continue;
        occ.set(`${v.x | 0},${v.y | 0},${v.z | 0}`, v);
    }

    // Группируем faces по material — на каждый материал отдельный mesh
    // (нельзя смешивать textured/solid faces в один меш с одним материалом).
    const buckets = new Map();   // materialKey → { positions[], indices[], normals[], colors[], uvs[] }
    const getBucket = (key) => {
        let b = buckets.get(key);
        if (!b) {
            b = { positions: [], indices: [], normals: [], colors: [], uvs: [] };
            buckets.set(key, b);
        }
        return b;
    };

    const S = VOXEL_SIZE;
    for (const v of data.voxels) {
        const x = v.x | 0, y = v.y | 0, z = v.z | 0;
        const matKey = v.t ? `tex:${v.t}` : 'solid';
        const color = v.t ? null : hexToColor4(v.c || '#ffffff');
        const bucket = getBucket(matKey);

        for (const face of FACES) {
            // Face culling: если сосед в направлении нормали есть — грань скрыта.
            const nx = x + face.dx, ny = y + face.dy, nz = z + face.dz;
            if (occ.has(`${nx},${ny},${nz}`)) continue;

            const baseIdx = bucket.positions.length / 3;
            const sh = face.shade;
            for (let i = 0; i < 4; i++) {
                const vx = face.verts[i][0] + x;
                const vy = face.verts[i][1] + y;
                const vz = face.verts[i][2] + z;
                bucket.positions.push(vx * S, vy * S, vz * S);
                bucket.normals.push(face.n[0], face.n[1], face.n[2]);
                bucket.uvs.push(FACE_UVS[i][0], FACE_UVS[i][1]);
                if (color) {
                    // Запекаем face-shading прямо в vertex color.
                    bucket.colors.push(color.r * sh, color.g * sh, color.b * sh, 1);
                } else {
                    // Текстурные voxels: vertex color = серый по shade,
                    // материал домножит текстуру на него (useVertexColors).
                    bucket.colors.push(sh, sh, sh, 1);
                }
            }
            // Два треугольника на quad
            bucket.indices.push(baseIdx, baseIdx + 1, baseIdx + 2);
            bucket.indices.push(baseIdx, baseIdx + 2, baseIdx + 3);
        }
    }
    return buckets;
}

/**
 * Построить воксельную маску коллизий из model_data.
 *
 * Маска — это компактная occupancy-сетка по воксельным индексам модели.
 * Используется PhysicsAABB для narrow-фазы коллизии: вместо одного грубого
 * AABB-параллелепипеда проверяется пересечение игрока с конкретными
 * занятыми вокселями. Это позволяет ходить по «дорожке» арочного моста,
 * заходить в проёмы и т.п. — там где раньше был сплошной короб.
 *
 * Производительность:
 *   - Строится ОДИН раз на модель (кэшируется в _dataCache рядом с data).
 *   - Хранится как Uint8Array (1 байт/воксель) — для модели 64³ это 256 КБ,
 *     для типовой 32³ — 32 КБ. Доступ к ячейке O(1) по индексу.
 *   - narrow-тест в физике итерирует ТОЛЬКО воксели в пределах AABB игрока
 *     (обычно 1-3 слоя на касании), не всю модель.
 *
 * @returns {object|null} { sx, sy, sz, minX, minY, minZ, grid:Uint8Array, count }
 *   sx/sy/sz — размеры сетки в вокселях; minX/Y/Z — индекс нижнего угла bbox;
 *   grid[idx] = 1 если воксель занят; count — число занятых вокселей.
 */
export function buildVoxelMask(modelData) {
    // Кодек читает v3 (компактный), v2 и v1.
    const decoded = decodeVoxelModel(modelData);
    if (!decoded || decoded.voxels.length === 0) return null;
    const data = { size: decoded.size, voxels: decoded.voxels };

    // Границы по занятым вокселям.
    let minX = Infinity, minY = Infinity, minZ = Infinity;
    let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
    for (const v of data.voxels) {
        if (typeof v.x !== 'number') continue;
        const x = v.x | 0, y = v.y | 0, z = v.z | 0;
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
        if (z < minZ) minZ = z;
        if (z > maxZ) maxZ = z;
    }
    if (!isFinite(minX)) return null;

    const sx = maxX - minX + 1;
    const sy = maxY - minY + 1;
    const sz = maxZ - minZ + 1;
    // Защита от абсурдных размеров (битый model_data).
    if (sx <= 0 || sy <= 0 || sz <= 0 || sx * sy * sz > 4_000_000) return null;

    const grid = new Uint8Array(sx * sy * sz);
    let count = 0;
    for (const v of data.voxels) {
        if (typeof v.x !== 'number') continue;
        const x = (v.x | 0) - minX;
        const y = (v.y | 0) - minY;
        const z = (v.z | 0) - minZ;
        const idx = (y * sz + z) * sx + x;
        if (grid[idx] === 0) {
            grid[idx] = 1;
            count++;
        }
    }
    return { sx, sy, sz, minX, minY, minZ, grid, count };
}


/**
 * UserModelManager — управляет инстансами пользовательских моделей в сцене.
 */
export class UserModelManager {
    constructor(scene) {
        this.scene = scene;
        this._api = null;
        // Кэш прото-данных (model_data из API) по userModelId.
        this._dataCache = new Map();   // number → modelDataString
        // Кэш воксельных масок коллизий по userModelId. Маска одинакова для
        // всех инстансов модели — строим один раз, переиспользуем.
        this._maskCache = new Map();   // number → { sx,sy,sz,minX,minY,minZ,grid,count }
        // Активные инстансы. id → { rootNode, userModelId, meshes:[Mesh] }
        this.instances = new Map();
        this._nextInstanceId = 1;
        // Кэш материалов — переиспользуем StandardMaterial между инстансами.
        this._matCache = new Map();    // matKey → StandardMaterial
    }

    /** Получить (с кэшем) воксельную маску коллизий для userModelId.
     *  Строится один раз из model_data, переиспользуется всеми инстансами. */
    _getVoxelMask(userModelId, modelData) {
        if (this._maskCache.has(userModelId)) {
            return this._maskCache.get(userModelId);
        }
        const mask = buildVoxelMask(modelData);
        this._maskCache.set(userModelId, mask);   // кэшируем и null (битый data)
        return mask;
    }

    setApi(api) {
        this._api = api;
    }

    /** Получить (с кэшем) model_data для userModelId. */
    async _loadModelData(userModelId, currentUserId = null) {
        if (this._dataCache.has(userModelId)) {
            return this._dataCache.get(userModelId);
        }
        if (!this._api || !this._api.getUserModel) {
            console.warn('[UserModelManager] API not configured');
            return null;
        }
        try {
            const res = await this._api.getUserModel(userModelId, currentUserId);
            const data = res.data?.model_data;
            if (!data) return null;
            this._dataCache.set(userModelId, data);
            return data;
        } catch (err) {
            console.warn('[UserModelManager] failed to load model', userModelId, err);
            return null;
        }
    }

    /** Получить (с кэшем) материал по matKey.
     *
     *  ЯРКОСТЬ И ОБЪЁМ:
     *  Объём кубиков задаётся запечённым face-shading в vertex colors
     *  (см. FACES[].shade — верх светлее, бока темнее). Материал —
     *  ОБЫЧНЫЙ StandardMaterial с useVertexColors: vertex colors красят
     *  diffuse, освещение сцены даёт картинку. Дополнительно небольшой
     *  emissiveColor «подсвечивает» модель, чтобы цвета были яркие и не
     *  тусклые. НЕ используем disableLighting — он отключает vertex
     *  colors целиком (они часть lighting pipeline) → модель чернеет.
     */
    _getMaterial(matKey) {
        let m = this._matCache.get(matKey);
        if (m) return m;
        m = new StandardMaterial(`userModel_${matKey}`, this.scene);
        m.specularColor = new Color3(0, 0, 0);
        // ВАЖНО: двусторонний рендер — winding треугольников нашего
        // ручного VertexData может оказаться "обратным" в left-handed сцене.
        m.backFaceCulling = false;
        m.twoSidedLighting = true;
        if (matKey === 'solid') {
            // diffuse=white → vertex colors (с запечённым face-shading)
            // полностью определяют цвет под освещением сцены.
            m.diffuseColor = new Color3(1, 1, 1);
            // Лёгкая эмиссия — поднимает яркость, цвета не выглядят тускло.
            m.emissiveColor = new Color3(0.32, 0.32, 0.32);
        } else if (matKey.startsWith('tex:')) {
            const texId = matKey.slice(4);
            const url = TEXTURES[texId];
            m.diffuseColor = new Color3(1, 1, 1);
            m.emissiveColor = new Color3(0.32, 0.32, 0.32);
            if (url) {
                try {
                    const t = new Texture(url, this.scene);
                    t.updateSamplingMode(Texture.NEAREST_NEAREST_MIPNEAREST);
                    m.diffuseTexture = t;
                    // Эмиссия по той же текстуре — подсветка не «вымывает»
                    // рисунок, а равномерно поднимает яркость.
                    m.emissiveTexture = t;
                } catch (e) {
                    m.diffuseColor = new Color3(1, 0.4, 0.8);   // fallback magenta
                }
            } else {
                m.diffuseColor = new Color3(1, 0.4, 0.8);
            }
        }
        this._matCache.set(matKey, m);
        return m;
    }

    /**
     * Поставить инстанс пользовательской модели в сцену.
     * @param {string} userModelTypeId — 'user:42'
     * @param {number} x,y,z — мировые координаты НИЖНЕЙ-СЕВЕРО-ЗАПАДНОЙ точки voxel-bbox
     * @param {number} rotationY — поворот вокруг Y (радианы)
     * @param {object} options
     * @param {number} options.currentUserId — для приватных моделей нужен (auth-check)
     * @returns {Promise<number|null>} instanceId
     */
    async addInstance(userModelTypeId, x, y, z, rotationY = 0, options = {}) {
        const userModelId = parseUserModelId(userModelTypeId);
        if (userModelId == null) {
            console.warn('[UserModelManager] invalid id:', userModelTypeId);
            return null;
        }
        const modelData = await this._loadModelData(userModelId, options.currentUserId);
        if (!modelData) return null;

        const buckets = compileVoxelModel(modelData);
        if (!buckets || buckets.size === 0) return null;

        // forceInstanceId — явный id из serialize (нужен чтобы target-скрипты
        // могли стабильно ссылаться на конкретный userModel-инстанс, напр.
        // животных). Если занят/не задан — берём авто-инкремент.
        let instanceId;
        if (Number.isFinite(options.forceInstanceId)
            && !this.instances.has(options.forceInstanceId)) {
            instanceId = options.forceInstanceId;
            if (instanceId >= this._nextInstanceId) {
                this._nextInstanceId = instanceId + 1;
            }
        } else {
            instanceId = this._nextInstanceId++;
        }
        const rootName = `userModel_${userModelId}_${instanceId}`;
        const root = new TransformNode(rootName, this.scene);
        root.position = new Vector3(x, y, z);
        root.rotation = new Vector3(0, rotationY, 0);

        const meshes = [];
        for (const [matKey, geom] of buckets) {
            const mesh = new Mesh(`${rootName}_${matKey}`, this.scene);
            const vd = new VertexData();
            vd.positions = geom.positions;
            vd.indices = geom.indices;
            vd.normals = geom.normals;
            vd.colors = geom.colors;
            vd.uvs = geom.uvs;
            vd.applyToMesh(mesh, false);
            mesh.material = this._getMaterial(matKey);
            // Включаем per-vertex color для StandardMaterial
            mesh.hasVertexAlpha = false;
            mesh.useVertexColors = true;
            mesh.isPickable = true;
            mesh.metadata = {
                isUserModel: true,
                userModelId,
                instanceId,
            };
            mesh.parent = root;
            meshes.push(mesh);
        }

        // Воксельная маска коллизий (кэш по userModelId — одна на модель).
        // Используется PhysicsAABB для narrow-фазы: точная коллизия по
        // занятым вокселям вместо одного грубого AABB-короба.
        const voxelMask = this._getVoxelMask(userModelId, modelData);

        const data = {
            instanceId,
            userModelId,
            userModelTypeId,
            rootNode: root,
            meshes,
            x, y, z, rotationY,
            // Свойства из Inspector — по умолчанию: сталкивается, видим, заякорен.
            canCollide: options.canCollide !== false,
            visible: options.visible !== false,
            anchored: options.anchored !== false,
            mass: typeof options.mass === 'number' ? options.mass : 1,
            scale: typeof options.scale === 'number' ? options.scale : 1,
            // localAABB — кэшируется ниже через _computeLocalAABB.
            localAABB: null,
            // voxelMask — общая для всех инстансов модели (ссылка на кэш).
            voxelMask,
        };
        this.instances.set(instanceId, data);
        // Вычисляем локальный AABB через вершины (для PhysicsAABB).
        this._computeLocalAABB(data);
        // Применяем visible/scale из options если они пришли при load.
        if (options.visible === false) {
            try { root.setEnabled(false); } catch (e) {}
            for (const m of meshes) { try { m.setEnabled(false); } catch (e) {} }
        }
        if (typeof options.scale === 'number' && options.scale !== 1) {
            root.scaling.x = options.scale;
            root.scaling.y = options.scale;
            root.scaling.z = options.scale;
        }
        try { this._onChange?.(); } catch (e) {}
        return instanceId;
    }

    /** Посчитать локальный AABB модели через прямой обход вершин.
     *  data.localAABB = {minX, maxX, minY, maxY, minZ, maxZ} в локальных
     *  координатах (без position/rotation/scale).
     *  Используется PhysicsAABB._aabbIntersectsUserModel для коллизий. */
    _computeLocalAABB(data) {
        let minX = Infinity, maxX = -Infinity;
        let minY = Infinity, maxY = -Infinity;
        let minZ = Infinity, maxZ = -Infinity;
        for (const m of data.meshes) {
            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;
            // Vertices в локальных координатах меша. Меш парентится к rootNode,
            // позиция meша = (0,0,0) внутри rootNode, поэтому local AABB меша
            // = local AABB модели по этому материалу.
            for (let i = 0; i < positions.length; i += 3) {
                const x = positions[i], y = positions[i + 1], z = positions[i + 2];
                if (x < minX) minX = x;
                if (x > maxX) maxX = x;
                if (y < minY) minY = y;
                if (y > maxY) maxY = y;
                if (z < minZ) minZ = z;
                if (z > maxZ) maxZ = z;
            }
        }
        if (isFinite(minX) && isFinite(maxX)) {
            data.localAABB = { minX, maxX, minY, maxY, minZ, maxZ };
        } else {
            // Fallback 1×1×1
            data.localAABB = { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 };
        }
    }

    /** Удалить инстанс. */
    removeInstance(instanceId) {
        const inst = this.instances.get(instanceId);
        if (!inst) return;
        for (const m of inst.meshes) {
            try { m.dispose(); } catch (e) {}
        }
        try { inst.rootNode.dispose(); } catch (e) {}
        this.instances.delete(instanceId);
        // Уведомляем onChange — внешний код (BabylonScene) дёрнет
        // physics.setSpatialDirty() + onSceneChange.
        try { this._onChange?.(); } catch (e) {}
    }

    /** Подписка на изменения (BabylonScene использует для setSpatialDirty / markDirty). */
    setOnChange(cb) { this._onChange = cb; }

    /** Инвалидировать модель после её редактирования.
     *  Сбрасывает кэш model_data → следующий addInstance возьмёт свежие данные.
     *  Если опция rebuild=true (по умолчанию) — пересоздаёт ВСЕ существующие
     *  инстансы этой модели в сцене с новой геометрией.
     *
     *  @param {number} userModelId — id из БД (без 'user:'-префикса)
     *  @param {object} options
     *  @param {boolean} options.rebuild — пересоздать существующие инстансы (default true)
     *  @param {number} options.currentUserId — для приватных моделей
     *  @returns {Promise<number>} количество пересозданных инстансов
     */
    async invalidateModel(userModelId, options = {}) {
        const { rebuild = true, currentUserId = null } = options;
        // Сбрасываем кэш data — следующий addInstance загрузит свежие данные.
        // Маску коллизий тоже сбрасываем — модель изменилась, форма другая.
        this._dataCache.delete(userModelId);
        this._maskCache.delete(userModelId);
        if (!rebuild) return 0;

        // Собираем существующие инстансы ЭТОЙ модели — нужно запомнить их
        // позицию/поворот/свойства, удалить, заново создать с новой геометрией.
        const matches = [];
        for (const inst of this.instances.values()) {
            if (inst.userModelId === userModelId) {
                matches.push({
                    instanceId: inst.instanceId,
                    userModelTypeId: inst.userModelTypeId,
                    x: inst.x, y: inst.y, z: inst.z,
                    rotationY: inst.rotationY,
                    scale: inst.scale,
                    canCollide: inst.canCollide,
                    visible: inst.visible,
                    anchored: inst.anchored,
                    mass: inst.mass,
                });
            }
        }
        if (matches.length === 0) return 0;

        // Удаляем старые инстансы (это вызовет _onChange после каждого).
        // Сохранять выделение бесполезно — instanceId сменится.
        for (const m of matches) {
            this.removeInstance(m.instanceId);
        }

        // Создаём заново — каждый получит новый instanceId.
        // _loadModelData теперь подтянет model_data заново (кэш сброшен).
        let rebuilt = 0;
        for (const m of matches) {
            const newId = await this.addInstance(
                m.userModelTypeId, m.x, m.y, m.z, m.rotationY,
                {
                    currentUserId,
                    scale: m.scale,
                    canCollide: m.canCollide,
                    visible: m.visible,
                    anchored: m.anchored,
                    mass: m.mass,
                },
            );
            if (newId != null) rebuilt++;
        }
        return rebuilt;
    }

    /** Сериализация инстансов для сохранения в project_data. */
    serialize() {
        const arr = [];
        for (const inst of this.instances.values()) {
            arr.push({
                type: inst.userModelTypeId,
                x: inst.x, y: inst.y, z: inst.z,
                ry: inst.rotationY,
                scale: inst.scale ?? 1,
                canCollide: inst.canCollide !== false,
                visible: inst.visible !== false,
                anchored: inst.anchored !== false,
                mass: inst.mass ?? 1,
                // instanceId — чтобы target-скрипты могли стабильно ссылаться
                // на конкретный инстанс после перезагрузки.
                instanceId: inst.instanceId,
                ...(inst.folderId != null ? { folderId: inst.folderId } : {}),
            });
        }
        return arr;
    }

    /** Восстановить инстансы из serialize-формата.
     *
     *  ОПТИМИЗАЦИЯ ЗАГРУЗКИ: раньше инстансы грузились строго последовательно
     *  (await addInstance в цикле) — N моделей = N HTTP-запросов один за
     *  другим, на старте игры это давало секунды задержки.
     *
     *  Теперь — две фазы:
     *    1. PREFETCH: собираем уникальные userModelId, грузим их model_data
     *       пачками с ОГРАНИЧЕННЫМ параллелизмом (PREFETCH_CONCURRENCY).
     *       ВАЖНО: нельзя грузить все разом через Promise.all — браузер
     *       лимитирует ~6 соединений к домену, и 17 параллельных запросов
     *       забивают пул → другие запросы (напр. открытие модели в
     *       редакторе) висят до таймаута. Пачки по 4 — быстро и безопасно.
     *    2. BUILD: создаём инстансы из уже-прогретого кэша — addInstance не
     *       делает сеть (данные в _dataCache), только строит меши.
     */
    async loadFromArray(arr, options = {}) {
        if (!Array.isArray(arr)) return 0;

        // --- Фаза 1: prefetch model_data пачками (bounded concurrency) ---
        const PREFETCH_CONCURRENCY = 4;
        const uniqueIds = [];
        const seenIds = new Set();
        for (const item of arr) {
            const uid = parseUserModelId(item.type);
            if (uid != null && !seenIds.has(uid)) {
                seenIds.add(uid);
                uniqueIds.push(uid);
            }
        }
        for (let i = 0; i < uniqueIds.length; i += PREFETCH_CONCURRENCY) {
            const batch = uniqueIds.slice(i, i + PREFETCH_CONCURRENCY);
            await Promise.all(
                batch.map((uid) =>
                    this._loadModelData(uid, options.currentUserId)
                        .catch((e) => {
                            console.warn('[UserModelManager] prefetch failed', uid, e);
                            return null;
                        }),
                ),
            );
        }

        // --- Фаза 2: создание инстансов (model_data уже в кэше) ---
        let loaded = 0;
        for (const item of arr) {
            try {
                const id = await this.addInstance(
                    item.type, item.x, item.y, item.z, item.ry || 0,
                    {
                        ...options,
                        scale: item.scale,
                        canCollide: item.canCollide,
                        visible: item.visible,
                        anchored: item.anchored,
                        mass: item.mass,
                        forceInstanceId: item.instanceId,
                    },
                );
                if (id != null) {
                    loaded++;
                    if (item.folderId != null) {
                        const inst = this.instances.get(id);
                        if (inst) inst.folderId = item.folderId;
                    }
                }
            } catch (e) {
                console.warn('[UserModelManager] failed to load instance', item, e);
            }
        }
        return loaded;
    }

    /** Полная очистка — все инстансы убраны, кэш сброшен. */
    clear() {
        for (const inst of this.instances.values()) {
            for (const m of inst.meshes) { try { m.dispose(); } catch (e) {} }
            try { inst.rootNode.dispose(); } catch (e) {}
        }
        this.instances.clear();
    }

    /** Количество активных инстансов. */
    getInstanceCount() {
        return this.instances.size;
    }
}
