/**
 * SmoothDecoManager — менеджер декораций для гладкого ландшафта.
 *
 * Архитектура:
 *   - Каждая модель (GLB из nature-kit) загружается ОДИН РАЗ
 *   - Создаётся ОДИН Mesh-prototype с baked geometry+material
 *   - Все инстансы — через thin-instances (1 draw call на модель)
 *
 * FPS-оптимизация:
 *   - Hard cap инстансов 30K (был 50K)
 *   - Step grid поднят (трава раз в 1.5м вместо 0.7м, цветы 2м вместо 1м)
 *   - frustum culling Babylon включён через bounding info update
 */

import {
    SceneLoader,
    Mesh,
    Matrix,
    Quaternion,
    Vector3,
} from '@babylonjs/core';

const ASSET_ROOT = '/kubikon-assets/models/nature-kit/';

/**
 * Каталог декораций — scale подобран чтобы итог был 1.5-3м.
 * Kenney модели сами по себе 0.13-0.29м, поэтому scale 7-13.
 */
// Размеры:
// - Цветы ~0.5-0.8м (до пояса персонажа)
// - Трава ~0.4-0.5м (до колен — как в Roblox)
// - Грибы ~0.3-0.6м (точечные акценты)
// Игрок ~2м — декорации заметно ниже.
export const DECO_CATALOG = {
    flower_purpleA: { file: 'flower_purpleA.glb', kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] },
    flower_purpleB: { file: 'flower_purpleB.glb', kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] },
    flower_purpleC: { file: 'flower_purpleC.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
    flower_redA:    { file: 'flower_redA.glb',    kind: 'flower', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
    flower_redB:    { file: 'flower_redB.glb',    kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] },
    flower_redC:    { file: 'flower_redC.glb',    kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] },
    flower_yellowA: { file: 'flower_yellowA.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis', 'desert'] },
    flower_yellowB: { file: 'flower_yellowB.glb', kind: 'flower', scale: 4.0, biomes: ['plain', 'forest', 'oasis', 'desert'] },
    flower_yellowC: { file: 'flower_yellowC.glb', kind: 'flower', scale: 4.5, biomes: ['plain', 'forest', 'oasis', 'desert'] },
    grass:           { file: 'grass.glb',            kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
    grass_large:     { file: 'grass_large.glb',      kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
    grass_leafs:     { file: 'grass_leafs.glb',      kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
    grass_leafsLarge:{ file: 'grass_leafsLarge.glb', kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
    mushroom_red:      { file: 'mushroom_red.glb',      kind: 'mushroom', scale: 3.0, biomes: ['forest', 'oasis'] },
    mushroom_redTall:  { file: 'mushroom_redTall.glb',  kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
    mushroom_redGroup: { file: 'mushroom_redGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
    mushroom_tan:      { file: 'mushroom_tan.glb',      kind: 'mushroom', scale: 4.0, biomes: ['forest', 'oasis'] },
    mushroom_tanTall:  { file: 'mushroom_tanTall.glb',  kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
    mushroom_tanGroup: { file: 'mushroom_tanGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
    // === Деревья — высокие 4-8м объекты для леса/равнины/гор ===
    // Лиственные: для plain/forest (зелёный лес)
    tree_default:    { file: 'tree_default.glb',    kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] },
    tree_simple:     { file: 'tree_simple.glb',     kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] },
    tree_oak:        { file: 'tree_oak.glb',        kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] },
    tree_detailed:   { file: 'tree_detailed.glb',   kind: 'tree', scale: 6.5, biomes: ['forest'] },
    tree_fat:        { file: 'tree_fat.glb',        kind: 'tree', scale: 6.0, biomes: ['forest'] },
    tree_thin:       { file: 'tree_thin.glb',       kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] },
    tree_tall:       { file: 'tree_tall.glb',       kind: 'tree', scale: 8.0, biomes: ['forest'] },
    tree_small:      { file: 'tree_small.glb',      kind: 'tree', scale: 4.5, biomes: ['plain', 'forest', 'oasis'] },
    // Осенние: микс в обычные леса для разнообразия
    tree_default_fall: { file: 'tree_default_fall.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest'] },
    tree_oak_fall:     { file: 'tree_oak_fall.glb',     kind: 'tree', scale: 7.0, biomes: ['forest'] },
    // Хвойные: для mountain/snow_peak (сосны/ели)
    tree_pineDefaultA: { file: 'tree_pineDefaultA.glb', kind: 'tree', scale: 6.5, biomes: ['forest', 'mountain'] },
    tree_pineRoundA:   { file: 'tree_pineRoundA.glb',   kind: 'tree', scale: 6.0, biomes: ['mountain'] },
    tree_pineRoundC:   { file: 'tree_pineRoundC.glb',   kind: 'tree', scale: 6.5, biomes: ['mountain'] },
    tree_pineTallA:    { file: 'tree_pineTallA.glb',    kind: 'tree', scale: 8.0, biomes: ['mountain', 'snow_peak'] },
    tree_pineSmallA:   { file: 'tree_pineSmallA.glb',   kind: 'tree', scale: 4.0, biomes: ['mountain', 'snow_peak'] },
    // Пальмы: для desert/beach (пляж/пустыня с оазисами)
    tree_palm:         { file: 'tree_palm.glb',         kind: 'tree', scale: 7.0, biomes: ['beach', 'desert', 'oasis'] },
    tree_palmShort:    { file: 'tree_palmShort.glb',    kind: 'tree', scale: 5.0, biomes: ['beach', 'desert'] },
    tree_palmBend:     { file: 'tree_palmBend.glb',     kind: 'tree', scale: 7.0, biomes: ['beach', 'oasis'] },
    // Кактусы для пустыни — вместо деревьев
    cactus_tall:       { file: 'cactus_tall.glb',       kind: 'tree', scale: 4.0, biomes: ['desert'] },
    cactus_short:      { file: 'cactus_short.glb',      kind: 'tree', scale: 3.0, biomes: ['desert'] },
};

export const DECO_KEYS = Object.keys(DECO_CATALOG);

export class SmoothDecoManager {
    constructor(scene) {
        this.scene = scene;
        /** Map<decoKey, Mesh> */
        this._protos = new Map();
        /** Map<decoKey, Float32Array> — ПОЛНЫЕ буферы (для save/load и culling source) */
        this._buffers = new Map();
        /** Map<decoKey, Float32Array> — отфильтрованные буферы (видимые в radius) */
        this._visibleBuffers = new Map();
        this._loaded = false;
        /** Distance culling: радиус видимости декораций */
        this.viewRadius = 100;
        this._cullObserver = null;
        this._lastCullCheck = 0;
    }

    async loadAll() {
        if (this._loaded) return;
        const tasks = [];
        for (const key of DECO_KEYS) {
            tasks.push(this._loadOne(key));
        }
        await Promise.all(tasks);
        this._loaded = true;
        console.log(`[SmoothDecoManager] loaded ${this._protos.size}/${DECO_KEYS.length} models`);
    }

    async _loadOne(key) {
        const def = DECO_CATALOG[key];
        if (!def) return;
        try {
            const container = await SceneLoader.LoadAssetContainerAsync(
                ASSET_ROOT, def.file, this.scene,
            );
            container.addAllToScene();
            // Берём ВСЕ меши контейнера (включая __root__ и его потомков)
            const allMeshes = container.meshes.slice();
            const geoMeshes = allMeshes.filter((m) => m.getTotalVertices && m.getTotalVertices() > 0);
            if (geoMeshes.length === 0) {
                console.warn(`[SmoothDecoManager] ${key}: no geometry`);
                return;
            }
            // === Bake вверх по иерархии ===
            // Kenney glTF имеет __root__ с rotation Y-up→Z-up (поворот -PI/2 по X
            // или просто scaling 100×). Если бэйкать только geometry-mesh с его
            // local transform, накопленный transform parent'а ПОТЕРЯЕТСЯ.
            // Поэтому форсируем computeWorldMatrix(true) на каждом — он
            // рекурсивно вычислит ИЗ ROOT'а — и потом bakeCurrentTransform.
            //
            // bakeCurrentTransformIntoVertices() применяет mesh.computeWorldMatrix()
            // — поэтому ВАЖНО что в этот момент transform-стек содержит root.
            for (const m of geoMeshes) {
                m.computeWorldMatrix(true);   // force, накапливает parent chain
                m.bakeCurrentTransformIntoVertices();
                // ВАЖНО: после bake вершины уже содержат world transform.
                // Отвязываем parent и сбрасываем local transform.
                m.parent = null;
                if (m.rotationQuaternion) m.rotationQuaternion = null;
                m.rotation.set(0, 0, 0);
                m.scaling.set(1, 1, 1);
                m.position.set(0, 0, 0);
            }
            // Уничтожаем оставшиеся вспомогательные node-ы (__root__ и т.п.)
            for (const m of allMeshes) {
                if (!geoMeshes.includes(m)) {
                    try { m.dispose(); } catch (e) {}
                }
            }

            let proto;
            if (geoMeshes.length === 1) {
                proto = geoMeshes[0];
                proto.name = `__smoothDeco_${key}`;
            } else {
                proto = Mesh.MergeMeshes(geoMeshes, true, true, undefined, false, true);
                if (!proto) return;
                proto.name = `__smoothDeco_${key}`;
            }
            // Центрируем XZ + поднимаем низ до Y=0
            proto.refreshBoundingInfo();
            const bb = proto.getBoundingInfo().boundingBox;
            const minY = bb.minimumWorld.y;
            const cx = (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5;
            const cz = (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5;
            const sizeY = bb.maximumWorld.y - bb.minimumWorld.y;
            if (Math.abs(minY) > 0.001 || Math.abs(cx) > 0.001 || Math.abs(cz) > 0.001) {
                proto.position.set(-cx, -minY, -cz);
                proto.bakeCurrentTransformIntoVertices();
                proto.position.set(0, 0, 0);
            }
            console.log(`[SmoothDecoManager] ${key}: Y=${sizeY.toFixed(2)}м × scale=${def.scale} = ${(sizeY * def.scale).toFixed(2)}м`);

            // === Фикс чёрных горизонтальных граней ===
            // Диагностика (BEFORE-лог): Kenney glTF — PBRMaterial с
            // metallic=1, roughness=1, albedo=(0.80,0.46,0.37), envInt=1.
            // То есть цвет деревьев на самом деле даётся ОТРАЖЕНИЕМ
            // окружения (металлический материал), а не albedo напрямую.
            //
            // ПРЕДЫДУЩАЯ ОШИБКА: я ставил metallic=0/roughness=1 →
            // материал из "металл-зеркало" превращался в матовый
            // диффузный, ловил весь свет сцены (hemi 0.65 + sun 0.8) и
            // ПЕРЕСВЕЧИВАЛСЯ. Цвета вымывались.
            //
            // ПРАВИЛЬНО: metallic/roughness/intensity НЕ трогаем — материал
            // остаётся таким, как задумано в glTF. Только:
            //   - backFaceCulling=false + twoSidedLighting — двусторонний
            //     рендер (страховка winding'а)
            //   - слабый emissiveColor (FILL от albedo) — «заливка» для
            //     нижних граней, чтобы не были чёрными. На металлическом
            //     материале emissive добавляется поверх отражений, поэтому
            //     FILL держим маленьким.
            const EMISSIVE_FILL = 0.06;
            const fixMat = (mat) => {
                if (!mat) return;
                mat.backFaceCulling = false;
                mat.twoSidedLighting = true;
                // emissive по albedo (или diffuse) — слабая заливка.
                const base = mat.albedoColor || mat.diffuseColor;
                if (base) {
                    mat.emissiveColor = base.scale
                        ? base.scale(EMISSIVE_FILL)
                        : { r: base.r * EMISSIVE_FILL, g: base.g * EMISSIVE_FILL, b: base.b * EMISSIVE_FILL };
                }
                // metallic / roughness / emissiveIntensity / environmentIntensity
                // НЕ меняем — оставляем как в исходном glTF.
            };
            if (proto.material) {
                // MultiMaterial → чиним все sub-материалы.
                if (proto.material.subMaterials) {
                    for (const sub of proto.material.subMaterials) fixMat(sub);
                } else {
                    fixMat(proto.material);
                }
            }

            proto.isPickable = false;
            // Prototype = живой меш для thin-instances. Babylon не cull-ит его
            // через frustum (alwaysSelect=true), все инстансы рендерятся
            // одним draw call.
            proto.alwaysSelectAsActiveMesh = true;
            // Сохраняем kind для distance culling (деревья видны издалека).
            proto._decoKind = def.kind;
            // Сохраняем реальную высоту модели (для AABB-коллайдеров деревьев).
            proto._decoSourceHeight = sizeY;
            this._protos.set(key, proto);
        } catch (e) {
            console.error(`[SmoothDecoManager] load ${key} failed:`, e);
        }
    }

    placeDecorations(opts) {
        if (!this._loaded) {
            console.warn('[SmoothDecoManager] not loaded yet');
            return { total: 0 };
        }
        const {
            sampleSurfaceY, sampleBiomeId, bbox,
            densityFlowers = 0.015, densityGrass = 0.08, densityTrees = 0.05, seed = 1337,
        } = opts;

        this.clear();

        const hash = (x, z, salt) => {
            let h = (x * 374761393 + z * 668265263 + (seed + salt) * 1442695040) | 0;
            h = Math.imul(h ^ (h >>> 13), 1274126177);
            return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
        };

        // Плотный grid. Один thin-instance buffer per модель.
        // Каждый thin-instance = 1 draw call независимо от количества инстансов.
        const STEP_FLOWERS = 1.5;
        const STEP_GRASS = 1.0;
        const STEP_MUSHROOM = 3.0;
        // Деревья — большие объекты, ставим РЕЖЕ чтобы не лес сплошной стеной.
        // step=8м для леса = 1 дерево на ~64м² при density=1.0.
        const STEP_TREE = 8.0;
        // === Per-kind caps ===
        // Один общий cap (150K) исчерпывался травой ДО деревьев на 600м карте,
        // и деревья просто не размещались. Каждый тип теперь имеет свой лимит.
        const CAP_FLOWERS = 60000;
        const CAP_GRASS = 80000;
        const CAP_MUSHROOM = 10000;
        const CAP_TREE = 15000;   // на 600м карте полно: 360000/64 × 0.5 = ~2800

        // tempBuffers: Map<decoKey, Matrix[]>
        const tempBuffers = new Map();
        let total = 0;
        // Счётчики per kind для соблюдения отдельных cap'ов.
        const kindCounts = { flower: 0, grass: 0, mushroom: 0, tree: 0 };
        const kindCaps = { flower: CAP_FLOWERS, grass: CAP_GRASS, mushroom: CAP_MUSHROOM, tree: CAP_TREE };
        // Список tree-AABB для физики (узкий цилиндр-ствол).
        // Передаётся в physics.setSmoothDecoTrees() — игрок не проходит сквозь.
        const treeColliders = [];

        const placeAt = (decoKey, kind, x, y, z, ry, scale) => {
            let arr = tempBuffers.get(decoKey);
            if (!arr) {
                arr = [];
                tempBuffers.set(decoKey, arr);
            }
            const m = Matrix.Compose(
                new Vector3(scale, scale, scale),
                Quaternion.RotationAxis(Vector3.Up(), ry),
                new Vector3(x, y, z),
            );
            arr.push(m);
            total++;
            kindCounts[kind]++;
            // Для деревьев — добавляем коллайдер: AABB до верха дерева,
            // ширина ствола около 1.5м (крона + ствол вместе).
            if (kind === 'tree') {
                const proto = this._protos.get(decoKey);
                if (proto) {
                    // Реальная высота меша = sourceHeight × scale.
                    // Для обычного дерева ~2-3м, для tree_tall до 4м.
                    const sourceH = proto._decoSourceHeight || 0.3;
                    const treeHeight = sourceH * scale;
                    // Радиус AABB: для кактусов узкий (0.5м), для остальных
                    // достаточно широкий чтобы покрыть крону (1.2-1.5м).
                    const isCactus = decoKey.startsWith('cactus_');
                    const trunkHalfW = isCactus ? 0.5 : 1.3;
                    treeColliders.push({
                        x, z,
                        baseY: y,                    // низ ствола = поверхность
                        halfW: trunkHalfW,
                        halfH: treeHeight * 0.5,    // ПОЛОВИНА реальной высоты
                        halfD: trunkHalfW,
                    });
                }
            }
        };

        const tryPlace = (kind, x, z, salt) => {
            if (kindCounts[kind] >= kindCaps[kind]) return;
            const surfY = sampleSurfaceY(x, z);
            if (surfY === null) return;
            const biomeId = sampleBiomeId(x, z);
            if (!biomeId) return;
            const candidates = DECO_KEYS.filter((k) => {
                const def = DECO_CATALOG[k];
                return def.kind === kind && def.biomes.includes(biomeId);
            });
            if (candidates.length === 0) return;
            const rChoose = hash(Math.floor(x * 17), Math.floor(z * 31), salt);
            const idx = Math.floor(rChoose * candidates.length);
            const decoKey = candidates[idx];
            const def = DECO_CATALOG[decoKey];
            const rRot = hash(Math.floor(x * 11), Math.floor(z * 13), salt + 100);
            const rScale = hash(Math.floor(x * 23), Math.floor(z * 19), salt + 200);
            const ry = rRot * Math.PI * 2;
            const scale = def.scale * (0.8 + rScale * 0.4);
            placeAt(decoKey, kind, x, surfY, z, ry, scale);
        };

        // Порядок размещения: tree → flower → mushroom → grass.
        // Деревья ПЕРВЫМИ — они самые важные визуально, нельзя чтобы их
        // вытеснил cap травы на больших картах.
        if (densityTrees > 0) {
            for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_TREE) {
                for (let x = bbox.minX; x < bbox.maxX; x += STEP_TREE) {
                    if (hash(Math.floor(x * 13), Math.floor(z * 17), 8) < densityTrees) {
                        const dx = (hash(Math.floor(x * 19), Math.floor(z), 9) - 0.5) * STEP_TREE;
                        const dz = (hash(Math.floor(x), Math.floor(z * 19), 10) - 0.5) * STEP_TREE;
                        tryPlace('tree', x + dx, z + dz, 4000);
                    }
                }
            }
        }
        if (densityFlowers > 0) {
            for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_FLOWERS) {
                for (let x = bbox.minX; x < bbox.maxX; x += STEP_FLOWERS) {
                    if (hash(Math.floor(x * 3), Math.floor(z * 5), 1) < densityFlowers) {
                        const dx = (hash(Math.floor(x * 7), Math.floor(z), 2) - 0.5) * STEP_FLOWERS;
                        const dz = (hash(Math.floor(x), Math.floor(z * 7), 3) - 0.5) * STEP_FLOWERS;
                        tryPlace('flower', x + dx, z + dz, 1000);
                    }
                }
            }
        }
        const mushroomDensity = densityFlowers * 0.3;
        if (mushroomDensity > 0) {
            for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_MUSHROOM) {
                for (let x = bbox.minX; x < bbox.maxX; x += STEP_MUSHROOM) {
                    if (hash(Math.floor(x * 11), Math.floor(z * 13), 7) < mushroomDensity) {
                        tryPlace('mushroom', x, z, 3000);
                    }
                }
            }
        }
        if (densityGrass > 0) {
            for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_GRASS) {
                for (let x = bbox.minX; x < bbox.maxX; x += STEP_GRASS) {
                    if (hash(Math.floor(x * 5), Math.floor(z * 3), 4) < densityGrass) {
                        const dx = (hash(Math.floor(x * 9), Math.floor(z), 5) - 0.5) * STEP_GRASS;
                        const dz = (hash(Math.floor(x), Math.floor(z * 9), 6) - 0.5) * STEP_GRASS;
                        tryPlace('grass', x + dx, z + dz, 2000);
                    }
                }
            }
        }

        // Сохраняем полные буферы (для культинга и save/load)
        let kindsUsed = 0;
        for (const [decoKey, matrices] of tempBuffers) {
            const proto = this._protos.get(decoKey);
            if (!proto || matrices.length === 0) continue;
            const buffer = new Float32Array(matrices.length * 16);
            for (let i = 0; i < matrices.length; i++) {
                matrices[i].copyToArray(buffer, i * 16);
            }
            this._buffers.set(decoKey, buffer);
            kindsUsed++;
        }

        // Первый pass culling сразу — иначе на 1 кадр будет всё или ничего.
        this._applyCulling();
        // Render observer обновляет видимые инстансы каждые 300мс по камере.
        this._enableCullObserver();

        console.log(`[SmoothDecoManager] placed ${total} decorations across ${kindsUsed} models (trees=${treeColliders.length}, view radius ${this.viewRadius}м)`);
        return { total, treeColliders };
    }

    /**
     * Добавить N инстансов декорации указанного kind в сфере вокруг center.
     * Используется кистями для интерактивной расстановки.
     *
     * @param {object} opts
     * @param {'flower'|'grass'|'mushroom'|'tree'} opts.kind
     * @param {{x,y,z}} opts.center             — мировые координаты центра кисти
     * @param {number} opts.radius              — радиус сферы (м)
     * @param {number} opts.count               — сколько инстансов добавить
     * @param {function} opts.sampleSurfaceY    — (x,z) → Y поверхности
     * @returns {number} реально добавлено инстансов
     */
    addBrushDeco(opts) {
        if (!this._loaded) {
            console.warn('[SmoothDecoManager] addBrushDeco: not loaded');
            return { added: 0, newTreeColliders: [] };
        }
        const { kind, center, radius, count = 5, sampleSurfaceY } = opts;
        const candidates = DECO_KEYS.filter((k) => DECO_CATALOG[k].kind === kind);
        if (candidates.length === 0) return { added: 0, newTreeColliders: [] };

        let added = 0;
        // Если посадили деревья — собираем колайдеры для physics.
        const newTreeColliders = [];
        for (let i = 0; i < count; i++) {
            const angle = Math.random() * Math.PI * 2;
            const r = Math.sqrt(Math.random()) * radius;
            const x = center.x + Math.cos(angle) * r;
            const z = center.z + Math.sin(angle) * r;
            const surfY = sampleSurfaceY ? sampleSurfaceY(x, z) : center.y;
            if (surfY === null || surfY === undefined) continue;
            const entry = candidates[Math.floor(Math.random() * candidates.length)];
            const def = DECO_CATALOG[entry];
            const ry = Math.random() * Math.PI * 2;
            const scale = def.scale * (0.8 + Math.random() * 0.4);
            const m = Matrix.Compose(
                new Vector3(scale, scale, scale),
                Quaternion.RotationAxis(Vector3.Up(), ry),
                new Vector3(x, surfY, z),
            );
            this._appendInstanceToBuffer(entry, m);
            added++;
            // Tree collider
            if (kind === 'tree') {
                const proto = this._protos.get(entry);
                if (proto) {
                    const sourceH = proto._decoSourceHeight || 0.3;
                    const treeHeight = sourceH * scale;
                    const isCactus = entry.startsWith('cactus_');
                    const trunkHalfW = isCactus ? 0.5 : 1.3;
                    newTreeColliders.push({
                        x, z,
                        baseY: surfY,
                        halfW: trunkHalfW,
                        halfH: treeHeight * 0.5,
                        halfD: trunkHalfW,
                    });
                }
            }
        }
        if (added > 0) {
            this._applyCulling();
        }
        return { added, newTreeColliders };
    }

    /**
     * Удалить все инстансы (любого kind) в сфере вокруг center.
     * @returns {number} удалено
     */
    removeBrushDecoInRadius(center, radius) {
        if (!this._loaded) return 0;
        const r2 = radius * radius;
        let removed = 0;
        for (const [decoKey, fullBuf] of this._buffers) {
            const count = fullBuf.length / 16;
            // Собираем индексы НЕ-удаляемых
            const keep = [];
            for (let i = 0; i < count; i++) {
                const dx = fullBuf[i * 16 + 12] - center.x;
                const dz = fullBuf[i * 16 + 14] - center.z;
                if (dx * dx + dz * dz > r2) {
                    keep.push(i);
                }
            }
            if (keep.length === count) continue;   // ничего не удалили в этом буфере
            removed += count - keep.length;
            // Перекомпилируем буфер только из keep'нутых
            const newBuf = new Float32Array(keep.length * 16);
            for (let j = 0; j < keep.length; j++) {
                newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16);
            }
            this._buffers.set(decoKey, newBuf);
        }
        if (removed > 0) this._applyCulling();
        return removed;
    }

    /**
     * Удалить все инстансы (любого kind) внутри прямоугольной зоны XZ.
     * Используется билд-скриптами игр чтобы расчистить площадки под
     * замок/лагерь/декор от процедурных деревьев и кустов.
     *
     * @param {number} minX
     * @param {number} minZ
     * @param {number} maxX
     * @param {number} maxZ
     * @returns {number} удалено инстансов
     */
    removeDecoInBox(minX, minZ, maxX, maxZ) {
        if (!this._loaded) return 0;
        let removed = 0;
        for (const [decoKey, fullBuf] of this._buffers) {
            const count = fullBuf.length / 16;
            const keep = [];
            for (let i = 0; i < count; i++) {
                const x = fullBuf[i * 16 + 12];
                const z = fullBuf[i * 16 + 14];
                if (x < minX || x > maxX || z < minZ || z > maxZ) {
                    keep.push(i);
                }
            }
            if (keep.length === count) continue;
            removed += count - keep.length;
            const newBuf = new Float32Array(keep.length * 16);
            for (let j = 0; j < keep.length; j++) {
                newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16);
            }
            this._buffers.set(decoKey, newBuf);
        }
        if (removed > 0) this._applyCulling();
        return removed;
    }

    /**
     * Получить ПОЛНЫЙ список tree-AABB-колайдеров на основе всех текущих
     * tree-инстансов. Используется после plant/erase чтобы физика
     * пересинхронизировала smooth-deco-trees.
     */
    getAllTreeColliders() {
        const out = [];
        for (const [decoKey, buf] of this._buffers) {
            const def = DECO_CATALOG[decoKey];
            if (!def || def.kind !== 'tree') continue;
            const proto = this._protos.get(decoKey);
            if (!proto) continue;
            const sourceH = proto._decoSourceHeight || 0.3;
            const isCactus = decoKey.startsWith('cactus_');
            const trunkHalfW = isCactus ? 0.5 : 1.3;
            const count = buf.length / 16;
            for (let i = 0; i < count; i++) {
                const off = i * 16;
                // uniform scale из ||row 0||
                const sx = Math.sqrt(buf[off]*buf[off] + buf[off+1]*buf[off+1] + buf[off+2]*buf[off+2]);
                const treeHeight = sourceH * sx;
                out.push({
                    x: buf[off + 12], z: buf[off + 14],
                    baseY: buf[off + 13],
                    halfW: trunkHalfW,
                    halfH: treeHeight * 0.5,
                    halfD: trunkHalfW,
                });
            }
        }
        return out;
    }

    /**
     * Включить/выключить пикинг thin-instances у всех прототипов.
     * Нужно для поштучного клик-выбора декораций в редакторе.
     * По умолчанию декорации isPickable=false (чтобы не мешали кистям).
     */
    setPickingEnabled(enabled) {
        for (const proto of this._protos.values()) {
            proto.isPickable = !!enabled;
            try {
                proto.thinInstanceEnablePicking = !!enabled;
            } catch (e) { /* старый Babylon — игнор */ }
        }
    }

    /**
     * Найти декорацию под точкой клика по результату raycast.
     * Babylon при thinInstanceEnablePicking возвращает pickedMesh=прототип
     * и thinInstanceIndex — но это индекс в ВИДИМОМ буфере (после culling).
     * Берём позицию из видимого буфера и ищем ближайший инстанс в полном.
     *
     * @param {Mesh} pickedMesh   — pick.pickedMesh из scene.pickWithRay
     * @param {number} thinIndex  — pick.thinInstanceIndex
     * @returns {{decoKey, fullIndex, x, y, z}|null}
     */
    findInstanceByPick(pickedMesh, thinIndex) {
        if (!pickedMesh || thinIndex == null || thinIndex < 0) return null;
        // Найти decoKey по прототипу
        let decoKey = null;
        for (const [k, proto] of this._protos) {
            if (proto === pickedMesh) { decoKey = k; break; }
        }
        if (!decoKey) return null;
        const visBuf = this._visibleBuffers.get(decoKey);
        if (!visBuf || thinIndex * 16 + 14 >= visBuf.length) return null;
        const vx = visBuf[thinIndex * 16 + 12];
        const vy = visBuf[thinIndex * 16 + 13];
        const vz = visBuf[thinIndex * 16 + 14];
        // Ищем этот же инстанс в полном буфере (по точному совпадению XЗ)
        const fullBuf = this._buffers.get(decoKey);
        if (!fullBuf) return null;
        const count = fullBuf.length / 16;
        let bestIdx = -1, bestD2 = 1e9;
        for (let i = 0; i < count; i++) {
            const dx = fullBuf[i * 16 + 12] - vx;
            const dz = fullBuf[i * 16 + 14] - vz;
            const d2 = dx * dx + dz * dz;
            if (d2 < bestD2) { bestD2 = d2; bestIdx = i; }
        }
        if (bestIdx < 0 || bestD2 > 0.01) return null;
        return { decoKey, fullIndex: bestIdx, x: vx, y: vy, z: vz };
    }

    /** Удалить один инстанс декорации по decoKey + индексу в полном буфере. */
    removeInstanceAt(decoKey, fullIndex) {
        const fullBuf = this._buffers.get(decoKey);
        if (!fullBuf) return false;
        const count = fullBuf.length / 16;
        if (fullIndex < 0 || fullIndex >= count) return false;
        const newBuf = new Float32Array((count - 1) * 16);
        let w = 0;
        for (let i = 0; i < count; i++) {
            if (i === fullIndex) continue;
            newBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16);
            w++;
        }
        this._buffers.set(decoKey, newBuf);
        this._applyCulling();
        return true;
    }

    /** Внутренний: расширить буфер decoKey'я одной новой матрицей. */
    _appendInstanceToBuffer(decoKey, matrix) {
        const old = this._buffers.get(decoKey);
        const oldLen = old ? old.length : 0;
        const newBuf = new Float32Array(oldLen + 16);
        if (old) newBuf.set(old, 0);
        matrix.copyToArray(newBuf, oldLen);
        this._buffers.set(decoKey, newBuf);
    }

    /**
     * Distance culling: для каждой модели создаём подбуфер только из инстансов
     * в radius от камеры, и применяем его через thinInstanceSetBuffer.
     * GPU рисует только эти инстансы — экономия 80-90% на больших картах.
     */
    _applyCulling() {
        const cam = this.scene.activeCamera;
        if (!cam) return;
        const camX = cam.position.x;
        const camZ = cam.position.z;
        // Per-kind view radius. Деревья БОЛЬШИЕ (5-8м), видны издалека.
        // Трава/цветы — мелкие, дальше 100м их не видно.
        const RADIUS_TREE = 400;
        const RADIUS_OTHER = this.viewRadius;
        const r2Tree = RADIUS_TREE * RADIUS_TREE;
        const r2Other = RADIUS_OTHER * RADIUS_OTHER;
        let totalVisible = 0;
        for (const [decoKey, fullBuf] of this._buffers) {
            const proto = this._protos.get(decoKey);
            if (!proto) continue;
            const isTree = proto._decoKind === 'tree';
            const r2 = isTree ? r2Tree : r2Other;
            const count = fullBuf.length / 16;
            // Подсчитываем видимые
            let visibleCount = 0;
            for (let i = 0; i < count; i++) {
                const dx = fullBuf[i * 16 + 12] - camX;
                const dz = fullBuf[i * 16 + 14] - camZ;
                if (dx * dx + dz * dz <= r2) visibleCount++;
            }
            // Создаём подбуфер. Если все видны — используем fullBuf без копии.
            let outBuf;
            if (visibleCount === count) {
                outBuf = fullBuf;
            } else if (visibleCount === 0) {
                outBuf = new Float32Array(0);
            } else {
                outBuf = new Float32Array(visibleCount * 16);
                let w = 0;
                for (let i = 0; i < count; i++) {
                    const dx = fullBuf[i * 16 + 12] - camX;
                    const dz = fullBuf[i * 16 + 14] - camZ;
                    if (dx * dx + dz * dz <= r2) {
                        outBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16);
                        w++;
                    }
                }
            }
            proto.thinInstanceSetBuffer('matrix', outBuf, 16, true);
            this._visibleBuffers.set(decoKey, outBuf);
            totalVisible += visibleCount;
        }
        return totalVisible;
    }

    _enableCullObserver() {
        if (this._cullObserver) return;
        this._cullObserver = this.scene.onBeforeRenderObservable.add(() => {
            const now = performance.now();
            // Раз в 300мс — достаточно для плавного движения камеры.
            if (now - this._lastCullCheck < 300) return;
            this._lastCullCheck = now;
            this._applyCulling();
        });
    }

    _disableCullObserver() {
        if (this._cullObserver) {
            this.scene.onBeforeRenderObservable.remove(this._cullObserver);
            this._cullObserver = null;
        }
    }

    /**
     * Сериализация ВСЕХ инстансов для save в БД.
     * Возвращает { format: 'smoothdeco-v1', items: [{k: decoKey, m: [16 floats]}, ...] }
     *
     * Каждый инстанс занимает 16 float = 64 байта + overhead JSON.
     * При 5000 декораций ~ 350KB. При 50K — 3MB. Не использовать для огромных карт.
     *
     * Для процедурно-генерированных декораций лучше использовать decoParams +
     * seed (см. _smoothDecoParams в BabylonScene) — это всего 100 байт.
     */
    serialize() {
        const items = [];
        for (const [decoKey, buf] of this._buffers) {
            if (!buf || buf.length === 0) continue;
            // Кодируем матрицы только translation + rotation Y + scale,
            // чтобы JSON был компактнее (8 чисел вместо 16).
            const count = buf.length / 16;
            for (let i = 0; i < count; i++) {
                const off = i * 16;
                // m[0..3]: row 0 (scale, rot)
                // m[12,13,14]: translation
                // Извлекаем uniform scale из ||m[0..2]|| (если scale-X)
                const sx = Math.sqrt(buf[off] * buf[off] + buf[off + 1] * buf[off + 1] + buf[off + 2] * buf[off + 2]);
                const rotY = Math.atan2(buf[off + 2] / (sx || 1), buf[off] / (sx || 1));
                items.push({
                    k: decoKey,
                    x: +buf[off + 12].toFixed(2),
                    y: +buf[off + 13].toFixed(2),
                    z: +buf[off + 14].toFixed(2),
                    r: +rotY.toFixed(3),
                    s: +sx.toFixed(2),
                });
            }
        }
        return { format: 'smoothdeco-v1', items };
    }

    /**
     * Загрузка декораций из serialize()-результата.
     * Восстанавливает все инстансы один-в-один с момента сохранения.
     * Загружает prototype'ы автоматически если не загружены.
     */
    async loadFromState(state) {
        if (!state || state.format !== 'smoothdeco-v1') {
            console.warn('[SmoothDecoManager] loadFromState: bad format', state?.format);
            return 0;
        }
        if (!this._loaded) {
            await this.loadAll();
        }
        this.clear();
        // Группируем items по decoKey, чтобы построить буфер за один проход.
        const groups = new Map();
        for (const it of (state.items || [])) {
            let arr = groups.get(it.k);
            if (!arr) { arr = []; groups.set(it.k, arr); }
            arr.push(it);
        }
        let total = 0;
        // Параллельно собираем tree-collider'ы для физики (та же логика что
        // в placeDecorations.placeAt при kind==='tree').
        const treeColliders = [];
        for (const [decoKey, arr] of groups) {
            const proto = this._protos.get(decoKey);
            if (!proto) continue;
            const def = DECO_CATALOG[decoKey];
            const buf = new Float32Array(arr.length * 16);
            for (let i = 0; i < arr.length; i++) {
                const it = arr[i];
                const m = Matrix.Compose(
                    new Vector3(it.s, it.s, it.s),
                    Quaternion.RotationAxis(Vector3.Up(), it.r || 0),
                    new Vector3(it.x, it.y, it.z),
                );
                m.copyToArray(buf, i * 16);
                if (def && def.kind === 'tree') {
                    const sourceH = proto._decoSourceHeight || 0.3;
                    const treeHeight = sourceH * it.s;
                    const isCactus = decoKey.startsWith('cactus_');
                    const trunkHalfW = isCactus ? 0.5 : 1.3;
                    treeColliders.push({
                        x: it.x, z: it.z,
                        baseY: it.y,
                        halfW: trunkHalfW,
                        halfH: treeHeight * 0.5,
                        halfD: trunkHalfW,
                    });
                }
            }
            this._buffers.set(decoKey, buf);
            total += arr.length;
        }
        this._applyCulling();
        this._enableCullObserver();
        console.log(`[SmoothDecoManager] loadFromState: ${total} instances restored (${treeColliders.length} tree colliders)`);
        return { total, treeColliders };
    }

    /** Очистить все инстансы (prototype-меши остаются). */
    clear() {
        for (const proto of this._protos.values()) {
            try { proto.thinInstanceSetBuffer('matrix', new Float32Array(0), 16, true); }
            catch (e) {}
        }
        this._buffers.clear();
        this._visibleBuffers.clear();
        this._disableCullObserver();
    }

    /** Полное удаление prototype-меш-ей. */
    dispose() {
        this._disableCullObserver();
        for (const proto of this._protos.values()) {
            try { proto.dispose(); } catch (e) {}
        }
        this._protos.clear();
        this._buffers.clear();
        this._visibleBuffers.clear();
        this._loaded = false;
    }

    getStats() {
        let total = 0;
        for (const buf of this._buffers.values()) {
            total += buf.length / 16;
        }
        return { total, kinds: this._buffers.size };
    }
}
