/**
 * ZombieManager — управление зомби-врагами в Play-режиме.
 *
 * Каждый зомби — экземпляр Kenney character-модели (берётся из existing
 * scene модели, помеченной metadata.isZombie=true, или спавнится из manager).
 *
 * AI:
 *   - WANDER: бродит к случайной точке в радиусе 8м, потом стоит 1-3с.
 *   - CHASE:  если игрок ближе DETECTION_RADIUS — идёт к нему.
 *   - ATTACK: если игрок ближе ATTACK_RANGE — наносит DAMAGE раз в ATTACK_COOLDOWN.
 *
 * Анимации: idle / walk используются из GLB, плюс мы поднимаем руки зомби
 * через override-rotation на мешах рук (как для оружия) — «зомби-поза».
 */

import { Vector3, MeshBuilder, StandardMaterial, Color3, TransformNode } from '@babylonjs/core';

const DEFAULTS = {
    hp: 50,
    speed: 2.2,           // м/с в режиме CHASE
    wanderSpeed: 1.3,     // м/с при WANDER (живее ходят)
    detectionRadius: 16,
    attackRange: 1.8,
    attackDamage: 10,
    attackCooldown: 1.0,
    wanderRadius: 14,     // длиннее отрезки — реже паузы
};

let _zombieIdSeq = 1;

// Worker-tick interval — раз в 50мс шлём playerPos и получаем target позиции.
// Между тиками главный поток lerp-ит позиции/yaw — движение остаётся плавным.
const WORKER_TICK_MS = 50;

export class ZombieManager {
    constructor(scene3d) {
        this.scene3d = scene3d;
        this.scene = scene3d.scene;
        /** @type {Map<number, object>} id → zombie state */
        this.zombies = new Map();
        this._renderHook = null;
        this._lastTickTime = performance.now() / 1000;
        this._onZombieDeath = null;
        // Карта instanceId → zombie (быстрый поиск при попадании пули)
        this._byInstanceId = new Map();

        // === Web Worker для AI ===
        // Worker ОПЦИОНАЛЕН: если creation falls — используем main-thread
        // fallback (старый код). Главное чтобы при этом игра не сломалась.
        this._worker = null;
        this._workerReady = false;
        this._workerLastTick = 0;
        // Целевые позиции от worker'а: id → {x, y, z, yaw, state, isMoving, walkPhase}
        this._aiTargets = new Map();
    }

    setOnDeath(cb) { this._onZombieDeath = cb; }

    start() {
        if (this._renderHook) return;
        this._renderHook = () => this._tick();
        this.scene.registerBeforeRender(this._renderHook);
        this._lastTickTime = performance.now() / 1000;
        // Создаём worker. Если не получилось — работаем в main-thread.
        this._initWorker();
    }

    _initWorker() {
        try {
            // Webpack 5 / CRA 5+ поддерживают new URL() для worker'ов.
            // import.meta заменяется webpack'ом на правильный URL чанка.
            // eslint-disable-next-line no-undef
            this._worker = new Worker(new URL('./ZombieAIWorker.js', import.meta.url));
            this._worker.onmessage = (e) => this._onWorkerMessage(e.data);
            this._worker.onerror = (err) => {
                // eslint-disable-next-line no-console
                console.warn('[ZombieManager] worker error:', err);
                try { this._worker.terminate(); } catch (e) {}
                this._worker = null;
                this._workerReady = false;
            };
            // Init: surface map + worldHalf
            this._sendSurfaceMap();
            this._workerReady = true;
            // eslint-disable-next-line no-console
            console.log('[ZombieManager] AI worker started');
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[ZombieManager] worker creation failed, fallback to main thread:', e);
            this._worker = null;
            this._workerReady = false;
        }
    }

    /**
     * Построить карту высоты поверхности и отправить в worker.
     *
     * Источники:
     *   1. БЛОКИ — топ-блок в каждой целой клетке (gx,gz). Шаг 1.
     *   2. ГЛАДКИЙ ЛАНДШАФТ (RobloxTerrain) — raycast по реальному мешу
     *      с шагом surfaceStep=2 (130K лучей на всю карту слишком дорого).
     *      Worker интерполирует между узлами сетки шага 2.
     *
     * Блоки ИМЕЮТ ПРИОРИТЕТ над ландшафтом (платформы замка/лагерей).
     */
    _sendSurfaceMap() {
        if (!this._worker) return;
        const surfaceMap = {};
        const worldHalf = this.scene3d?._worldHalf ?? 40;

        // 1. Гладкий ландшафт — сначала, чтобы блоки могли перезаписать.
        const SURFACE_STEP = 2;
        const phys = this.scene3d?.physics;
        if (phys && typeof phys._sampleRobloxSurface === 'function'
            && this.scene3d?._robloxTerrain?.grid) {
            const t0 = performance.now();
            let hits = 0;
            for (let gx = -worldHalf; gx <= worldHalf; gx += SURFACE_STEP) {
                for (let gz = -worldHalf; gz <= worldHalf; gz += SURFACE_STEP) {
                    const y = phys._sampleRobloxSurface(gx, gz);
                    if (y !== null && y !== undefined) {
                        surfaceMap[gx * 65537 + gz] = y;
                        hits++;
                    }
                }
            }
            console.log(`[ZombieManager] surfaceMap: гладкий ландшафт ${hits} узлов (шаг ${SURFACE_STEP}м, ${(performance.now() - t0).toFixed(0)}мс)`);
        }

        // 2. Блоки — поверх ландшафта (топ-блок в каждой целой клетке).
        const bm = this.scene3d?.blockManager;
        if (bm && bm.blocks) {
            for (const [key, mesh] of bm.blocks) {
                if (mesh?.metadata?.isWater) continue;
                const parts = key.split(',');
                const gx = parseInt(parts[0], 10);
                const gy = parseInt(parts[1], 10);
                const gz = parseInt(parts[2], 10);
                const k = gx * 65537 + gz;
                const cur = surfaceMap[k];
                const top = gy + 1;
                if (cur === undefined || top > cur) surfaceMap[k] = top;
            }
        }

        this._worker.postMessage({
            type: 'init', surfaceMap, worldHalf, surfaceStep: SURFACE_STEP,
        });
    }

    _onWorkerMessage(msg) {
        if (msg.type !== 'state') return;
        // Запоминаем target-позиции для каждого зомби — render-loop их lerp-ит.
        for (const z of msg.zombies) {
            this._aiTargets.set(z.id, z);
        }
        // События — атаки игрока, можем озвучить, и т.д.
        if (msg.events && msg.events.length) {
            for (const ev of msg.events) {
                if (ev.type === 'attack') {
                    // Доп. защита от «урон от невидимого зомби»: пропускаем
                    // атаку если меш зомби сейчас скрыт (setEnabled=false из-за
                    // distance-cull или провала под террейн). Также проверяем
                    // 3D-расстояние до игрока — если зомби на y=0 а игрок на
                    // y=10 (классический баг GLB-террейна, см. _spawnAt /
                    // surfaceMap), фактически он не дотягивается.
                    let canHit = true;
                    if (ev.id != null) {
                        const z = this.zombies.get(ev.id);
                        if (z) {
                            if (z.data?.rootMesh && z.data.rootMesh.isEnabled?.() === false) {
                                canHit = false;
                            } else if (z.data && this.scene3d?.player?._pos) {
                                const p = this.scene3d.player._pos;
                                const dx = p.x - z.data.x;
                                const dy = p.y - z.data.y;
                                const dz = p.z - z.data.z;
                                const dist3d = Math.sqrt(dx * dx + dy * dy + dz * dz);
                                // Порог 4 (был 3): игрок может стоять НА голове
                                // зомби (dy~2) — dist3d тогда до ~3.9, но атака
                                // легитимна (зомби топчут). Защита от провала
                                // под террейн (dy огромный) всё равно работает.
                                if (dist3d > 4) canHit = false;
                            }
                        }
                    }
                    if (canHit) {
                        try { this.scene3d.player?.takeDamage?.(ev.damage, 'zombie'); } catch (e) {}
                    }
                }
            }
        }
    }

    stop() {
        if (this._renderHook) {
            this.scene.unregisterBeforeRender(this._renderHook);
            this._renderHook = null;
        }
        // Возвращаем ЖИВЫХ ручных зомби на исходные позиции.
        for (const z of this.zombies.values()) {
            if (z.spawnerId == null && z.data) {
                z.data.x = z.originX;
                z.data.y = z.originY;
                z.data.z = z.originZ;
                z.data.rotationY = z.originYaw;
                if (z.data.rootMesh) {
                    z.data.rootMesh.position.set(z.originX, z.originY, z.originZ);
                    z.data.rootMesh.rotation.y = z.originYaw;
                    z.data.rootMesh.setEnabled(true);
                }
                this._resetZombiePose(z.data);
            }
            try {
                z.healthBar?.anchor?.dispose();
                z.healthBar?.bg?.dispose();
                z.healthBar?.fill?.dispose();
            } catch (e) {}
        }
        // Также нужно вернуть УБИТЫХ ручных зомби (они скрыты, но в ModelManager).
        // Их `data` есть в modelManager.instances, но они не в this.zombies.
        // Прохожусь по всем моделям и для убитых (скрытых) с gameplay.isZombie
        // возвращаем их видимость и позицию.
        const mm = this.scene3d?.modelManager;
        if (mm) {
            for (const data of mm.instances.values()) {
                if (data._spawnedAtRuntime) continue;
                if (!data.gameplay?.isZombie) continue;
                if (data.rootMesh && !data.rootMesh.isEnabled()) {
                    // Возвращаем — позицию мы не сохраняли отдельно, но их
                    // origin = текущая data.x/y/z (т.к. мёртвые не двигались)
                    data.rootMesh.setEnabled(true);
                    data.rootMesh.position.set(data.x, data.y, data.z);
                    this._resetZombiePose(data);
                }
            }
        }
        this.zombies.clear();
        this._byInstanceId.clear();
        this._aiTargets.clear();
        // Останавливаем worker — он больше не нужен в редакторе.
        if (this._worker) {
            try { this._worker.terminate(); } catch (e) {}
            this._worker = null;
            this._workerReady = false;
        }
        // Чистим debris если остались
        if (this._debris) {
            for (const d of this._debris) {
                try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
            }
            this._debris = [];
        }
    }

    /** Сбросить override-rotation на меше зомби (вернуть к стандартной анимации). */
    _resetZombiePose(data) {
        const root = data?.rootMesh;
        if (!root || !root.getChildMeshes) return;
        for (const m of root.getChildMeshes(false)) {
            const n = (m.name || '').toLowerCase();
            if (n.includes('arm') || n.includes('leg') || n.includes('torso')) {
                if (m.rotationQuaternion) m.rotationQuaternion = null;
                m.rotation.x = 0;
                m.rotation.y = 0;
                m.rotation.z = 0;
            }
        }
    }

    /**
     * Зарегистрировать существующую модель сцены как зомби.
     * Используется для зомби, заранее расставленных в редакторе с
     * metadata.isZombie=true.
     */
    registerExisting(modelInstanceId, options = {}) {
        const mm = this.scene3d.modelManager;
        const data = mm?.instances?.get(modelInstanceId);
        if (!data) return null;
        const id = _zombieIdSeq++;
        const opts = { ...DEFAULTS, ...options };
        const z = {
            id,
            instanceId: modelInstanceId,
            data,
            hp: opts.hp,
            maxHp: opts.hp,
            opts,
            state: 'wander',
            stateTime: 0,
            idlePauseUntil: null,
            wanderTarget: this._pickWanderTarget(data.x, data.z, opts.wanderRadius),
            lastAttackTime: 0,
            spawnerId: options.spawnerId ?? null,
            yaw: data.rootMesh?.rotation?.y ?? 0,
            walkPhase: Math.random() * Math.PI * 2,
            isMoving: false,
            vy: 0,
            onGround: false,
            healthBar: this._createHealthBar(),
            // Исходная позиция — для возврата при exitPlayMode (только для
            // вручную размещённых зомби; спавнерные удаляются целиком).
            originX: data.x,
            originY: data.y,
            originZ: data.z,
            originYaw: data.rotationY,
        };
        this.zombies.set(id, z);
        this._byInstanceId.set(modelInstanceId, z);
        // Регистрируем зомби в worker'е (если он есть)
        if (this._worker) {
            this._worker.postMessage({
                type: 'addZombie',
                id, x: data.x, y: data.y, z: data.z,
                opts: { ...opts },
            });
        }
        return id;
    }

    /**
     * Снимок всех живых зомби — для отправки в скрипт-воркеры.
     * Возвращает массив {id, mobType, x, y, z, hp}.
     */
    getMobsSnapshot() {
        const out = [];
        for (const z of this.zombies.values()) {
            const d = z.data;
            if (!d) continue;
            out.push({
                id: z.id,
                mobType: 'zombie',
                x: d.x, y: d.y, z: d.z,
                hp: z.hp,
            });
        }
        return out;
    }

    /**
     * Убить зомби по id. Используется скриптами для «зомби достиг цели — удалить».
     * Срабатывает как обычная смерть (с эффектами + onMobKilled).
     */
    killById(id) {
        const z = this.zombies.get(Number(id));
        if (!z) return false;
        this._killZombie(z);
        return true;
    }

    /** Нанести урон зомби (вызывается из логики попадания пули). */
    damageByMesh(mesh, amount) {
        // Ищем зомби, к которому принадлежит этот меш
        let target = null;
        for (const z of this.zombies.values()) {
            const root = z.data?.rootMesh;
            if (!root) continue;
            if (mesh === root) { target = z; break; }
            // Проверяем потомков
            if (root.getChildMeshes) {
                const children = root.getChildMeshes(false);
                if (children.includes(mesh)) { target = z; break; }
            }
        }
        if (!target) return false;
        target.hp = Math.max(0, target.hp - amount);
        if (target.hp <= 0) {
            this._killZombie(target);
        }
        return true;
    }

    _killZombie(z) {
        // Эффект распада
        this._spawnDeathDebris(z.data);
        // Сообщаем GameRuntime для скриптов через onMobKilled
        const pos = z.data ? { x: z.data.x, y: z.data.y, z: z.data.z } : { x: 0, y: 0, z: 0 };
        try {
            this.scene3d?.gameRuntime?.notifyMobKilled?.('zombie', pos);
        } catch (e) {}
        if (z.spawnerId != null) {
            // Спавнерный зомби — удаляем целиком (он временный)
            this.scene3d.modelManager?.removeInstance(z.instanceId);
        } else {
            // Ручной зомби — НЕ удаляем модель, только прячем.
            // При Stop он вернётся на исходное место.
            if (z.data?.rootMesh) z.data.rootMesh.setEnabled(false);
        }
        this._byInstanceId.delete(z.instanceId);
        this.zombies.delete(z.id);
        this._aiTargets.delete(z.id);
        // Уведомляем worker — больше не тикать этого зомби.
        if (this._worker) {
            try { this._worker.postMessage({ type: 'removeZombie', id: z.id }); } catch (e) {}
        }
        if (z.healthBar) {
            try {
                z.healthBar.anchor?.dispose();
                z.healthBar.bg?.dispose();
                z.healthBar.fill?.dispose();
            } catch (e) {}
        }
        if (this._onZombieDeath) {
            try { this._onZombieDeath(z); } catch (e) {}
        }
    }

    /**
     * Эффект «распада на куски» при смерти — Roblox-style.
     * Спавним 6-8 цветных кубов с физикой (физики у нас простой нет, поэтому
     * через ручную анимацию в _tick).
     */
    _spawnDeathDebris(data) {
        if (!data) return;
        const colors = [
            new Color3(0.7, 0.55, 0.4),  // тело/кожа
            new Color3(0.9, 0.7, 0.5),
            new Color3(0.4, 0.3, 0.2),   // одежда
            new Color3(0.5, 0.4, 0.3),
        ];
        const debris = [];
        const cx = data.x, cy = data.y + 1.0, cz = data.z;
        const count = 8;
        for (let i = 0; i < count; i++) {
            const size = 0.18 + Math.random() * 0.12;
            const cube = MeshBuilder.CreateBox(`debris_${i}`, { size }, this.scene);
            const mat = new StandardMaterial(`debrisMat_${i}`, this.scene);
            mat.diffuseColor = colors[i % colors.length];
            mat.specularColor = new Color3(0, 0, 0);
            cube.material = mat;
            cube.position.set(cx + (Math.random() - 0.5) * 0.5, cy + Math.random() * 0.5, cz + (Math.random() - 0.5) * 0.5);
            cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
            cube.isPickable = false;
            cube.alwaysSelectAsActiveMesh = true;
            debris.push({
                mesh: cube,
                mat,
                vx: (Math.random() - 0.5) * 4,
                vy: 3 + Math.random() * 3,
                vz: (Math.random() - 0.5) * 4,
                rx: (Math.random() - 0.5) * 8,
                ry: (Math.random() - 0.5) * 8,
                rz: (Math.random() - 0.5) * 8,
                age: 0,
                life: 1.5,
            });
        }
        if (!this._debris) this._debris = [];
        this._debris.push(...debris);
    }

    /** Обновить физику и фейд кусков debris. Вызывается каждый тик. */
    _tickDebris(dt) {
        if (!this._debris || this._debris.length === 0) return;
        const G = -9.8;
        const next = [];
        for (const d of this._debris) {
            d.age += dt;
            if (d.age >= d.life) {
                try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
                continue;
            }
            d.vy += G * dt;
            d.mesh.position.x += d.vx * dt;
            d.mesh.position.y += d.vy * dt;
            d.mesh.position.z += d.vz * dt;
            // Простой пол — y=0
            if (d.mesh.position.y < 0.05) {
                d.mesh.position.y = 0.05;
                d.vy *= -0.4;
                d.vx *= 0.6;
                d.vz *= 0.6;
            }
            d.mesh.rotation.x += d.rx * dt;
            d.mesh.rotation.y += d.ry * dt;
            d.mesh.rotation.z += d.rz * dt;
            // Фейд в последние 0.5с
            const fadeStart = d.life - 0.5;
            if (d.age > fadeStart) {
                const k = 1 - (d.age - fadeStart) / 0.5;
                d.mesh.visibility = Math.max(0, k);
            }
            next.push(d);
        }
        this._debris = next;
    }

    _pickWanderTarget(cx, cz, radius) {
        // Если зомби близко к краю карты — даём bias к центру, чтобы цель не была
        // зажата clamp'ом в саму границу (тогда зомби «упирается в стену»).
        const half = (this.scene3d?._worldHalf ?? 40) - 1.5;
        const distToEdge = Math.min(half - Math.abs(cx), half - Math.abs(cz));
        let bias = 0;
        if (distToEdge < radius) {
            // Угол смещаем к центру: вектор (-cx, -cz) нормализован
            bias = Math.atan2(-cx, -cz);
        }
        const a = (bias + (Math.random() - 0.5) * Math.PI);   // случайно ±90° от bias
        const r = (radius * 0.5) + Math.random() * (radius * 0.5);   // не слишком близко
        let tx = cx + Math.cos(a) * r;
        let tz = cz + Math.sin(a) * r;
        if (tx >  half) tx =  half;
        if (tx < -half) tx = -half;
        if (tz >  half) tz =  half;
        if (tz < -half) tz = -half;
        return new Vector3(tx, 0, tz);
    }

    /**
     * Хелсбар над зомби: TransformNode-якорь (билборд) + два plane'а внутри.
     * Заливка слева направо: pivot fill сдвинут к левому краю чтобы scaling.x
     * масштабировал от левого края, а не от центра.
     */
    _createHealthBar() {
        const BAR_W = 1.4, BAR_H = 0.16;
        const anchor = new TransformNode('zhpAnchor', this.scene);
        anchor.billboardMode = 7;
        anchor.setEnabled(false);

        const bg = MeshBuilder.CreatePlane('zhpBg', { width: BAR_W, height: BAR_H }, this.scene);
        const bgMat = new StandardMaterial('zhpBgMat', this.scene);
        bgMat.emissiveColor = new Color3(0.05, 0.05, 0.05);
        bgMat.disableLighting = true;
        bgMat.backFaceCulling = false;
        bg.material = bgMat;
        bg.isPickable = false;
        bg.renderingGroupId = 1;
        bg.parent = anchor;
        bg.position.z = 0.001;  // чуть позади fill

        const fill = MeshBuilder.CreatePlane('zhpFill', { width: BAR_W * 0.92, height: BAR_H * 0.7 }, this.scene);
        const fillMat = new StandardMaterial('zhpFillMat', this.scene);
        fillMat.emissiveColor = new Color3(0.95, 0.2, 0.2);
        fillMat.disableLighting = true;
        fillMat.backFaceCulling = false;
        fill.material = fillMat;
        fill.isPickable = false;
        fill.renderingGroupId = 1;
        fill.parent = anchor;

        return {
            anchor, bg, fill, bgMat, fillMat,
            barWidth: BAR_W * 0.92,
        };
    }

    _tick() {
        const now = performance.now() / 1000;
        const dt = Math.min(0.05, now - this._lastTickTime);
        this._lastTickTime = now;

        // Куски разлетающихся зомби
        this._tickDebris(dt);

        if (this.zombies.size === 0) return;
        const player = this.scene3d.player;
        const playerPos = player?._pos;
        if (!playerPos) return;

        const nowMs = now * 1000;

        if (this._workerReady && this._worker) {
            // === WORKER-РЕЖИМ: AI считается в worker'е, главный поток lerp-ит. ===
            // Раз в WORKER_TICK_MS отправляем в worker playerPos.
            if (nowMs - this._workerLastTick >= WORKER_TICK_MS) {
                const workerDt = (nowMs - this._workerLastTick) / 1000;
                this._workerLastTick = nowMs;
                this._worker.postMessage({
                    type: 'tick',
                    playerX: playerPos.x,
                    playerY: playerPos.y,
                    playerZ: playerPos.z,
                    now,
                    dt: Math.min(0.5, workerDt || WORKER_TICK_MS / 1000),
                });
            }
            // Каждый кадр: lerp mesh.position и yaw к target от worker'а,
            // обновляем pose и health-bar — это работа главного потока (рендер).
            for (const z of this.zombies.values()) {
                this._renderZombie(z, dt, playerPos);
            }
            return;
        }

        // === FALLBACK (без worker'а): старая логика с adaptive tick rate. ===
        const NEAR_DIST_SQ = 20 * 20;
        const MID_DIST_SQ = 60 * 60;
        for (const z of this.zombies.values()) {
            const data = z.data;
            if (!data) continue;
            const dx = playerPos.x - (data.x || 0);
            const dz = playerPos.z - (data.z || 0);
            const distSq = dx * dx + dz * dz;
            let interval;
            if (distSq < NEAR_DIST_SQ) interval = 0;
            else if (distSq < MID_DIST_SQ) interval = 100;
            else interval = 500;
            if (z._lastAiTickMs == null) z._lastAiTickMs = 0;
            if (interval > 0 && nowMs - z._lastAiTickMs < interval) continue;
            const zDt = z._lastAiTickMs > 0
                ? Math.min(0.5, (nowMs - z._lastAiTickMs) / 1000)
                : dt;
            z._lastAiTickMs = nowMs;
            this._tickZombie(z, zDt, playerPos, now);
        }
    }

    /**
     * Render-only обработка одного зомби в worker-режиме. AI/FSM/физика
     * считаются в worker'е, здесь только: lerp позиции к target, поворот,
     * apply pose, обновление health-bar, видимость.
     */
    _renderZombie(z, dt, playerPos) {
        const target = this._aiTargets.get(z.id);
        const data = z.data;
        if (!data || !data.rootMesh) return;

        // Lerp позиции к target от worker'а. Если target ещё не пришёл —
        // используем data.x/y/z как есть.
        if (target) {
            const prevX = data.x, prevZ = data.z;
            const LERP = Math.min(1, dt * 16);
            data.x += (target.x - data.x) * LERP;
            data.y += (target.y - data.y) * LERP;
            data.z += (target.z - data.z) * LERP;
            z.state = target.state;
            // isMoving детектим по реальному движению mesh'а в этом кадре,
            // а не по флагу из worker'а — так анимация ходьбы работает
            // даже если worker задерживается.
            const moveDxz = Math.hypot(data.x - prevX, data.z - prevZ);
            z.isMoving = moveDxz > 0.001;
            // Yaw: если двигаемся — поворачиваемся по направлению реального
            // движения mesh'а в мире (forward = -Z локально, поэтому +π).
            if (z.isMoving) {
                const dirX = data.x - prevX;
                const dirZ = data.z - prevZ;
                const targetYaw = Math.atan2(dirX, dirZ) + Math.PI;
                z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 8);
            } else if (z.state === 'attack') {
                // В режиме атаки лицом к игроку
                const dxp = playerPos.x - data.x;
                const dzp = playerPos.z - data.z;
                const targetYaw = Math.atan2(dxp, dzp) + Math.PI;
                z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
            }
            // Walk phase каждый кадр, не из worker (он отстаёт на 50мс).
            if (z.walkPhase == null) z.walkPhase = 0;
            if (z.isMoving) {
                const stepFreq = (z.state === 'chase') ? 8 : 5;
                z.walkPhase += dt * stepFreq;
            }
        }

        // Distance-cull: считаем дистанцию до игрока в main thread.
        // Видимость не зависит от worker — он мог не успеть прислать target.
        const dx = playerPos.x - data.x;
        const dz = playerPos.z - data.z;
        const distToPlayer = Math.sqrt(dx * dx + dz * dz);
        const wantVisible = distToPlayer < 80;
        if (z._lastVisible !== wantVisible) {
            z._lastVisible = wantVisible;
            try { data.rootMesh.setEnabled(wantVisible); } catch (e) {}
        }
        if (!wantVisible) return;

        const root = data.rootMesh;
        // Защита: если меш зомби кем-то заморожен (старая freezeStaticModels,
        // LOD freeze) — раз-замораживаем. Без этого position.set() ничего
        // визуально не меняет: меш стоит а хитбокс двигается → стреляешь в
        // пустоту, ловишь попадание, полоска жизней в правильном месте.
        if (root._isWorldMatrixFrozen) {
            try { root.unfreezeWorldMatrix(); } catch (e) {}
        }
        root.position.set(data.x, data.y, data.z);
        root.rotation.y = z.yaw;
        // Pose update — только близкие зомби (детали не видно издалека).
        if (distToPlayer < 30) {
            this._applyZombiePose(data, z);
        }

        // Health-bar
        const hb = z.healthBar;
        if (hb) {
            const showBar = z.hp < z.maxHp;
            hb.anchor.setEnabled(showBar);
            if (showBar) {
                hb.anchor.position.set(data.x, data.y + 2.2, data.z);
                const pct = Math.max(0, Math.min(1, z.hp / z.maxHp));
                hb.fill.scaling.x = pct;
                hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
                hb.fillMat.emissiveColor.set(
                    1 - pct * 0.6,
                    0.2 + pct * 0.7,
                    0.1
                );
            }
        }
    }

    _tickZombie(z, dt, playerPos, now) {
        const data = z.data;
        const root = data?.rootMesh;
        if (!root) return;

        const dx = playerPos.x - data.x;
        const dz = playerPos.z - data.z;
        const distToPlayer = Math.sqrt(dx * dx + dz * dz);

        // LOD: скрываем зомби дальше 80м — игрок их всё равно не видит,
        // а Babylon продолжает рисовать их меши и материалы.
        const HIDE_DIST = 80;
        const wantVisible = distToPlayer < HIDE_DIST;
        if (z._lastVisible !== wantVisible) {
            z._lastVisible = wantVisible;
            try { root.setEnabled(wantVisible); } catch (e) {}
        }
        if (!wantVisible) return;  // дальше тикать не нужно

        // FSM
        if (distToPlayer < z.opts.attackRange) {
            z.state = 'attack';
        } else if (distToPlayer < z.opts.detectionRadius) {
            z.state = 'chase';
        } else if (z.state !== 'wander') {
            z.state = 'wander';
            z.wanderTarget = this._pickWanderTarget(data.x, data.z, z.opts.wanderRadius);
            z.stateTime = 0;
            z.idlePauseUntil = null;
        }

        let moveDir = null;
        let speed = 0;

        if (z.state === 'attack') {
            // Стоит и бьёт
            if (now - z.lastAttackTime > z.opts.attackCooldown) {
                z.lastAttackTime = now;
                this.scene3d.player?.takeDamage?.(z.opts.attackDamage, 'zombie');
            }
            // Поворачиваемся к игроку (+π потому что модель смотрит в -Z)
            const targetYaw = Math.atan2(dx, dz) + Math.PI;
            z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
        } else if (z.state === 'chase') {
            const len = Math.max(0.001, distToPlayer);
            moveDir = { x: dx / len, z: dz / len };
            speed = z.opts.speed;
        } else {
            // wander: «походили — постояли — снова пошли».
            // Состояния хранятся в z.idlePauseUntil:
            //   null    → активно идём к z.wanderTarget
            //   number  → стоим до момента когда z.stateTime достигнет idlePauseUntil
            //
            // КРИТИЧНО: stateTime сбрасываем при смене фазы (idle→walk и walk→idle),
            // иначе он растёт бесконечно и условие walk_timeout ниже срабатывает сразу
            // после старта движения — зомби «застывает».
            z.stateTime += dt;

            if (z.idlePauseUntil != null) {
                // === Фаза idle — стоим ===
                if (z.stateTime >= z.idlePauseUntil) {
                    // Пауза закончилась → переходим к ходьбе, выбираем новую цель
                    z.idlePauseUntil = null;
                    z.stateTime = 0;
                    z.wanderTarget = this._pickWanderTarget(
                        data.x, data.z, z.opts.wanderRadius);
                }
                // moveDir остаётся null — стоим на месте
            } else {
                // === Фаза walk — идём к wanderTarget ===
                const tx = z.wanderTarget.x - data.x;
                const tz = z.wanderTarget.z - data.z;
                const tdist = Math.sqrt(tx * tx + tz * tz);
                const reached = tdist < 0.7;
                const tooLong = z.stateTime > 15;   // защита от зацикливания
                if (reached || tooLong) {
                    // Дошли (или таймаут) → пауза 1.5..3.0 секунды
                    z.idlePauseUntil = 1.5 + Math.random() * 1.5;
                    z.stateTime = 0;
                } else {
                    moveDir = { x: tx / tdist, z: tz / tdist };
                    speed = z.opts.wanderSpeed;
                }
            }
        }

        // Гравитация и вертикальное падение каждый кадр
        if (z.vy == null) z.vy = 0;
        z.vy += -18 * dt;
        if (z.vy < -25) z.vy = -25;
        let newY = data.y + z.vy * dt;

        // Определяем высоту поверхности под зомби
        const surfaceY = this._surfaceHeightAt(data.x, data.z);
        if (newY <= surfaceY) {
            newY = surfaceY;
            z.vy = 0;
            z.onGround = true;
        } else {
            z.onGround = false;
        }
        data.y = newY;

        // Дополнительная страховка: если зомби как-то оказался ЗА картой,
        // тянем его обратно к границе. Старые «зависшие за краем» мобы быстро
        // вернутся в игровую зону.
        const halfClamp = (this.scene3d?._worldHalf ?? 40) - 0.5;
        if (data.x >  halfClamp) data.x =  halfClamp;
        if (data.x < -halfClamp) data.x = -halfClamp;
        if (data.z >  halfClamp) data.z =  halfClamp;
        if (data.z < -halfClamp) data.z = -halfClamp;

        let stepped = false;   // получилось ли реально сдвинуться по X или Z
        if (moveDir) {
            const stepX = moveDir.x * speed * dt;
            const stepZ = moveDir.z * speed * dt;
            const half = (this.scene3d?._worldHalf ?? 40) - 1.0;

            const tryX = data.x + stepX;
            const tryZ = data.z + stepZ;
            const outX = tryX > half || tryX < -half;
            const outZ = tryZ > half || tryZ < -half;

            const targetSurface = this._surfaceHeightAt(tryX, tryZ);
            const heightDiff = targetSurface - data.y;

            if (heightDiff <= 1.05 && !outX && !outZ) {
                // Полный шаг
                data.x = tryX;
                data.z = tryZ;
                stepped = true;
                if (heightDiff > 0.05 && z.onGround) z.vy = 6;
            } else {
                // Boundary slide: пробуем по одной оси
                const onlyXok = !outX
                    && (this._surfaceHeightAt(data.x + stepX, data.z) - data.y) <= 1.05;
                const onlyZok = !outZ
                    && (this._surfaceHeightAt(data.x, data.z + stepZ) - data.y) <= 1.05;
                if (onlyXok) {
                    data.x += stepX;
                    stepped = true;
                } else if (onlyZok) {
                    data.z += stepZ;
                    stepped = true;
                }
            }

            // Не получилось сдвинуться (стена/край) — переходим в idle на 0.8..2с,
            // выбираем новую цель относительно текущей позиции (с bias к центру).
            if (!stepped && z.state === 'wander') {
                z.idlePauseUntil = 0.8 + Math.random() * 1.2;
                z.stateTime = 0;
                z.wanderTarget = this._pickWanderTarget(
                    data.x, data.z, z.opts.wanderRadius);
                moveDir = null;   // на этот кадр анимация — idle
            }
        }

        root.position.set(data.x, data.y, data.z);
        if (moveDir) {
            // Поворот к направлению движения. Kenney character смотрит в -Z
            // локально — поэтому +π чтобы лицо было по направлению хода.
            const targetYaw = Math.atan2(moveDir.x, moveDir.z) + Math.PI;
            z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
        }

        root.rotation.y = z.yaw;

        // === Поза «зомби» с анимацией ходьбы и атаки ===
        z.isMoving = !!moveDir;
        if (z.walkPhase == null) z.walkPhase = 0;
        if (z.isMoving) {
            const stepFreq = (z.state === 'chase') ? 8 : 5;  // быстрее когда гонится
            z.walkPhase += dt * stepFreq;
        }
        // Pose update — самая дорогая операция per-зомби (трогает rotations
        // 4-6 child-мешей). Пропускаем для дальних зомби (>30м) — игрок не
        // видит покачивания их рук/ног.
        if (distToPlayer < 30) {
            this._applyZombiePose(data, z);
        }

        // === Хелсбар ===
        const hb = z.healthBar;
        if (hb) {
            const showBar = z.hp < z.maxHp;
            hb.anchor.setEnabled(showBar);
            if (showBar) {
                hb.anchor.position.set(data.x, data.y + 2.2, data.z);
                const pct = Math.max(0, Math.min(1, z.hp / z.maxHp));
                hb.fill.scaling.x = pct;
                // Сдвигаем fill ВЛЕВО по локальной X: при scaling.x=1 центр fill
                // совпадает с центром bg (offset=0). При scaling=0.5 fill ужался
                // с обеих сторон, нужно сдвинуть на (1-pct) * width/2 влево
                // чтобы левый край остался на месте.
                hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
                // Цвет от красного к зелёному
                hb.fillMat.emissiveColor.set(
                    1 - pct * 0.6,
                    0.2 + pct * 0.7,
                    0.1
                );
            }
        }
    }

    /**
     * Анимация зомби: руки подняты вперёд + покачиваются, ноги шагают,
     * туловище покачивается. Время фазы = z.walkPhase, тикается в _tickZombie.
     */
    _applyZombiePose(data, z) {
        const root = data?.rootMesh;
        if (!root || !root.getChildMeshes) return;
        const phase = z?.walkPhase ?? 0;
        const isWalking = z?.isMoving;
        const isAttacking = z?.state === 'attack';
        const swing = isWalking ? Math.sin(phase) : 0;       // -1..1 для ходьбы
        const attackSwing = isAttacking
            ? Math.sin((performance.now() / 1000) * 6) * 0.4  // быстрое покачивание для атаки
            : 0;
        const meshes = root.getChildMeshes(false);
        for (const m of meshes) {
            const n = (m.name || '').toLowerCase();
            if (m.rotationQuaternion) m.rotationQuaternion = null;
            if (n.includes('arm-left')) {
                // Левая (с нашего ракурса) рука вытянута вперёд + раскачивание
                m.rotation.x = -Math.PI / 2 + swing * 0.15 + attackSwing;
                m.rotation.y = 0;
                m.rotation.z = 0.05 * swing;
            } else if (n.includes('arm-right')) {
                m.rotation.x = -Math.PI / 2 - swing * 0.15 + attackSwing;
                m.rotation.y = 0;
                m.rotation.z = -0.05 * swing;
            } else if (n.includes('leg-left')) {
                // Левая нога вперёд при положительной фазе
                m.rotation.x = swing * 0.6;
                m.rotation.y = 0;
                m.rotation.z = 0;
            } else if (n.includes('leg-right')) {
                m.rotation.x = -swing * 0.6;
                m.rotation.y = 0;
                m.rotation.z = 0;
            } else if (n.includes('torso')) {
                // Лёгкое покачивание
                m.rotation.x = swing * 0.05;
                m.rotation.y = 0;
                m.rotation.z = swing * 0.04;
            }
        }
    }

    /**
     * Высота поверхности под точкой (x, z) — Y верха самого высокого блока.
     * Сканирует столбец сверху вниз. Если блоков нет — НЕ возвращает 0
     * (это роняло зомби на базовую плиту сквозь гладкий ландшафт), а
     * берёт высоту RobloxTerrain через physics._sampleRobloxSurface.
     *
     * ОПТИМИЗАЦИЯ: кэш per (gx,gz). На зомби-острове `_tickZombie` зовёт
     * этот метод 3-4 раза за кадр на каждого зомби. Без кэша — много
     * lookup'ов по Map. Сбрасываем кэш при изменениях BlockManager.
     */
    _surfaceHeightAt(x, z) {
        const bm = this.scene3d?.blockManager;
        if (!bm) return this._robloxSurfaceFallback(x, z);
        const gx = Math.round(x);
        const gz = Math.round(z);

        // Кэш живёт на самом BlockManager — все зомби делят его.
        if (!bm._surfaceCache) bm._surfaceCache = new Map();
        if (typeof bm._surfaceCacheVersion !== 'number') bm._surfaceCacheVersion = 0;
        const key = gx * 65537 + gz;
        const cached = bm._surfaceCache.get(key);
        if (cached && cached.v === bm._surfaceCacheVersion) {
            return cached.h;
        }

        // Cache miss — сканируем столбец блоков сверху вниз.
        // Потолок поднят до 60: замок/донжон/платформы могут быть высоко.
        let height = null;
        for (let y = 60; y >= -5; y--) {
            const blockKey = `${gx},${y},${gz}`;
            const mesh = bm.blocks.get(blockKey);
            if (mesh && !mesh.metadata?.isWater) {
                height = y + 1;
                break;
            }
        }
        // Блоков в столбце нет — зомби стоит на гладком ландшафте.
        // Берём высоту реального меша RobloxTerrain (raycast в physics).
        if (height === null) {
            height = this._robloxSurfaceFallback(x, z);
        }
        bm._surfaceCache.set(key, { v: bm._surfaceCacheVersion, h: height });
        return height;
    }

    /**
     * Высота гладкого ландшафта (RobloxTerrain) под точкой — через
     * physics._sampleRobloxSurface (raycast по реальному мешу).
     * Fallback на 0 только если RobloxTerrain вообще нет.
     */
    _robloxSurfaceFallback(x, z) {
        const phys = this.scene3d?.physics;
        if (phys && typeof phys._sampleRobloxSurface === 'function') {
            const y = phys._sampleRobloxSurface(x, z);
            if (y !== null && y !== undefined) return y;
        }
        return 0;
    }

    _lerpAngle(a, b, t) {
        // Кратчайший угловой путь
        let diff = b - a;
        while (diff > Math.PI) diff -= Math.PI * 2;
        while (diff < -Math.PI) diff += Math.PI * 2;
        return a + diff * Math.min(1, t);
    }
}
