/**
 * WeaponSystem — отображение оружия в руке игрока + стрельба.
 *
 * - Загружает модель текущего оружия (из активного слота инвентаря) и крепит её
 *   к камере как «view-model» (видна в Play, держится в правом нижнем углу).
 * - Обрабатывает ЛКМ: пускает луч от камеры, ищет цель (модель/блок/примитив),
 *   спавнит трассер-эффект и звук, отдаёт callback onHit для логики урона.
 *
 * Использование:
 *   const ws = new WeaponSystem(scene3d);
 *   ws.start();                // включить view-model и обработку ЛКМ
 *   ws.equip(weaponItem);      // weaponItem из inventory
 *   ws.unequip();              // убрать модель из руки
 *   ws.stop();                 // выключить
 *
 *   ws.setOnHit((target, distance, point) => {...});
 *
 * Стрельба автоматически вызывается при нажатой ЛКМ в Play (с cooldown'ом по
 * fireRate). Для одиночных выстрелов оружие может быть с params.auto=false.
 */

import {
    Vector3, Color3, Color4,
    MeshBuilder, StandardMaterial, ParticleSystem, Texture,
} from '@babylonjs/core';
import { getModelType } from './ModelTypes';

// Базовые offsets для view-model. Могут быть переопределены через
// model.gameplay.viewModel в ModelTypes (для каждого оружия — свои).
const VIEW_MODEL_OFFSET = { x: 0.35, y: -0.28, z: 0.6 };
const HAND_OFFSET = { x: 0, y: 0, z: 0 };
const HAND_ROTATION = { x: 0, y: 0, z: 0 };
const HAND_SCALE_3RD = 1.4;
const HAND_SCALE_1ST = 0.7;

// Получить настройки view-model для оружия (с fallback на дефолты)
function _getViewModelSettings(equipped) {
    const vm = equipped?.params?.viewModel
        || equipped?.viewModel
        || (equipped?.modelTypeId
            ? getModelType(equipped.modelTypeId)?.gameplay?.viewModel
            : null);
    return {
        scale3rd: vm?.scale3rd ?? HAND_SCALE_3RD,
        scale1st: vm?.scale1st ?? HAND_SCALE_1ST,
        position3rd: vm?.position3rd ?? HAND_OFFSET,
        rotation3rd: vm?.rotation3rd ?? HAND_ROTATION,
        position1st: vm?.position1st ?? VIEW_MODEL_OFFSET,
    };
}

export class WeaponSystem {
    constructor(scene3d) {
        this.scene3d = scene3d;
        this.scene = scene3d.scene;
        this._active = false;
        this._equipped = null;     // активный item из inventory
        this._viewMeshes = [];     // меши view-model'и
        this._lastFireTime = 0;
        this._mouseDown = false;
        this._onHit = null;
        this._listeners = [];
        // Патроны: { magazine: текущий магазин, reserve: запас }
        // Хранится по slot-id (id предмета в инвентаре), чтобы не сбрасывать при смене.
        this._ammoState = new Map();
        this._reloading = false;
        this._reloadEndTime = 0;
        // Колбэк UI: ({ magazine, magazineMax, reserve, reloading, reloadProgress })
        this._onAmmoChange = null;
    }

    setOnAmmoChange(cb) { this._onAmmoChange = cb; }
    setOnHit(cb) { this._onHit = cb; }

    /**
     * Tap-to-shoot для мобилы: следующий выстрел будет нацелен в точку
     * (x, y) на экране (canvas-координаты), а не в центр камеры.
     * Применяется один раз в _fire(), потом сбрасывается.
     */
    setAimScreenPoint(x, y) {
        this._aimScreenPoint = { x, y };
    }

    /** Запустить — навешивает обработчики ЛКМ на canvas. */
    start() {
        if (this._active) return;
        this._active = true;
        const canvas = this.scene3d.canvas;
        const onDown = (e) => {
            if (e.button !== 0) return;
            // Если UI-режим курсора — не стреляем (мышь работает по GUI)
            if (this.scene3d?.player?.isUiCursorMode?.()) return;
            // Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
            // ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
            // центре экрана → используем прицел камеры (aim не задаём).
            if (document.pointerLockElement !== canvas) {
                const rect = canvas.getBoundingClientRect();
                const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
                const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
                if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
                    this.setAimScreenPoint(cx * (canvas.width / rect.width),
                                           cy * (canvas.height / rect.height));
                }
            }
            this._mouseDown = true;
            this._tryFire();
        };
        const onUp = (e) => {
            if (e.button !== 0) return;
            this._mouseDown = false;
        };
        // При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
        // авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
        const onMove = (e) => {
            if (!this._mouseDown) return;
            if (document.pointerLockElement === canvas) return;
            const rect = canvas.getBoundingClientRect();
            const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
            const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
            if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
                this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
            }
        };
        const onKey = (e) => {
            if (e.code === 'KeyR') this.reload();
        };
        canvas.addEventListener('mousedown', onDown);
        window.addEventListener('mouseup', onUp);
        window.addEventListener('mousemove', onMove);
        window.addEventListener('keydown', onKey);
        this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
        this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
        this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
        this._listeners.push({ target: window, type: 'keydown', fn: onKey });

        // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
        this._renderHook = () => this._tick();
        this.scene.registerBeforeRender(this._renderHook);

        // === DEBUG: подкрутка оружия в реальном времени из консоли ===
        // weapon3rd(scale, x, y, z, rx, ry, rz) — все опциональны
        // weaponInfo() — печатает текущее состояние
        const ws = this;
        window.weapon3rd = function(scale, x, y, z, rx, ry, rz) {
            const r = ws._viewRoot;
            if (!r) { console.log('[weapon] not equipped or not 3rd person'); return; }
            if (Number.isFinite(scale)) r.scaling.set(scale, scale, scale);
            if (Number.isFinite(x) || Number.isFinite(y) || Number.isFinite(z)) {
                r.position.set(x ?? r.position.x, y ?? r.position.y, z ?? r.position.z);
            }
            if (Number.isFinite(rx) || Number.isFinite(ry) || Number.isFinite(rz)) {
                r.rotation.set(rx ?? r.rotation.x, ry ?? r.rotation.y, rz ?? r.rotation.z);
            }
            console.log('[weapon] scale=', r.scaling.x.toFixed(2),
                'pos=', r.position.toString(),
                'rot=', r.rotation.toString());
        };
        window.weaponInfo = function() {
            const r = ws._viewRoot;
            if (!r) { console.log('[weapon] not equipped'); return; }
            console.log('[weapon] equipped=', ws._equipped?.modelTypeId,
                'parent=', r.parent?.name,
                'scale=', r.scaling.toString(),
                'pos(local)=', r.position.toString(),
                'rot=', r.rotation.toString());
            r.computeWorldMatrix(true);
            console.log('[weapon] absPos=', r.absolutePosition?.toString());
            const player = ws.scene3d?.player;
            const arm = player?._rightArmMeshes?.[0];
            if (arm) {
                arm.computeWorldMatrix(true);
                console.log('[arm] absPos=', arm.absolutePosition?.toString());
            }
            const anchor = player?.getWeaponAnchor?.();
            if (anchor) {
                anchor.computeWorldMatrix(true);
                console.log('[anchor] absPos=', anchor.absolutePosition?.toString());
            }
        };
    }

    stop() {
        if (!this._active) return;
        this._active = false;
        for (const { target, type, fn } of this._listeners) {
            try { target.removeEventListener(type, fn); } catch (e) { /* ignore */ }
        }
        this._listeners = [];
        if (this._renderHook) {
            this.scene.unregisterBeforeRender(this._renderHook);
            this._renderHook = null;
        }
        this.unequip();
        this._mouseDown = false;
    }

    /** Снарядить оружие из инвентаря (item={modelTypeId,name,params}). */
    async equip(item) {
        // Снимаем предыдущее
        this.unequip();
        if (!item || item.kind !== 'weapon' || !item.modelTypeId) return;
        this._equipped = item;
        // Инициализируем магазин если впервые экипируем это оружие
        if (!this._ammoState.has(item.id)) {
            const p = item.params || {};
            const magMax = p.magazine ?? 30;
            const reserve = p.reserve ?? 90;
            this._ammoState.set(item.id, { magazine: magMax, reserve });
        }
        this._notifyAmmoChange();
        try {
            await this._loadAndAttachViewModel(item.modelTypeId);
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[WeaponSystem] failed to load weapon model:', e);
        }
    }

    /** Получить состояние патронов текущего оружия. */
    getAmmoState() {
        if (!this._equipped) return null;
        const st = this._ammoState.get(this._equipped.id);
        if (!st) return null;
        const p = this._equipped.params || {};
        const magMax = p.magazine ?? 30;
        let reloadProgress = 0;
        if (this._reloading) {
            const dur = (p.reloadTime ?? 1.5) * 1000;
            const elapsed = dur - (this._reloadEndTime - performance.now());
            reloadProgress = Math.max(0, Math.min(1, elapsed / dur));
        }
        return {
            magazine: st.magazine,
            magazineMax: magMax,
            reserve: st.reserve,
            reloading: this._reloading,
            reloadProgress,
        };
    }

    _notifyAmmoChange() {
        if (this._onAmmoChange) {
            try { this._onAmmoChange(this.getAmmoState()); } catch (e) { /* ignore */ }
        }
    }

    /** Дёргается из render-loop каждый кадр. Пока идёт перезарядка — раз
     *  в ~50мс шлёт прогресс, чтобы полоска плавно заполнялась. */
    tick() {
        if (!this._reloading) return;
        const now = performance.now();
        if (this._lastReloadNotify && now - this._lastReloadNotify < 50) return;
        this._lastReloadNotify = now;
        this._notifyAmmoChange();
    }

    /** Запустить перезарядку. Длится params.reloadTime секунд. */
    reload() {
        if (!this._equipped || this._reloading) return;
        const p = this._equipped.params || {};
        const magMax = p.magazine ?? 30;
        const st = this._ammoState.get(this._equipped.id);
        if (!st) return;
        if (st.magazine >= magMax) return;       // уже полный
        if (st.reserve <= 0) return;              // нечем перезаряжать
        const reloadTime = p.reloadTime ?? 1.5;
        this._reloading = true;
        this._reloadEndTime = performance.now() + reloadTime * 1000;
        this._notifyAmmoChange();
        // Небольшой звук «перезарядки»
        this._playReloadSound();
        // Завершение через таймер
        setTimeout(() => {
            if (!this._reloading) return;          // была отмена/смена оружия
            const cur = this._ammoState.get(this._equipped?.id);
            if (cur) {
                const need = magMax - cur.magazine;
                const take = Math.min(need, cur.reserve);
                cur.magazine += take;
                cur.reserve -= take;
            }
            this._reloading = false;
            this._notifyAmmoChange();
        }, reloadTime * 1000);
    }

    unequip() {
        for (const m of this._viewMeshes) {
            try { m.dispose(); } catch (e) {}
        }
        this._viewMeshes = [];
        this._viewRoot = null;
        if (this._retryTimer) {
            clearTimeout(this._retryTimer);
            this._retryTimer = null;
        }
        // Отменяем перезарядку — оружие сменилось
        this._reloading = false;
        this._equipped = null;
        // Прячем вытянутую руку (если была показана)
        const player = this.scene3d?.player;
        if (player && typeof player._updateExtendedArm === 'function') {
            player._updateExtendedArm(false);
        }
        this._notifyAmmoChange();
    }

    /**
     * Загрузить модель оружия и прикрепить либо к камере (1-st person),
     * либо к корню модели игрока (3-rd person). Модель не пикается лучом.
     */
    async _loadAndAttachViewModel(modelTypeId) {
        const mm = this.scene3d.modelManager;
        if (!mm) return;
        const proto = await mm._loadPrototype(modelTypeId);
        if (!proto || !this._active || this._equipped?.modelTypeId !== modelTypeId) return;
        // Инстанцируем — instantiateModelsToScene клонирует со всеми трансформациями
        const result = proto.instantiateModelsToScene();
        const meshes = result.rootNodes.flatMap(n => [n, ...(n.getChildMeshes ? n.getChildMeshes() : [])]);
        const root = result.rootNodes[0];
        if (!root) return;

        // Все меши делаем не-пикабельными
        for (const m of meshes) {
            if (m && m.isPickable !== undefined) m.isPickable = false;
        }

        this._viewRoot = root;
        this._viewMeshes = [root, ...meshes].filter(Boolean);
        // Применяем текущий режим камеры
        this._applyAttachment();
    }

    /**
     * Прикрепить view-model к нужному носителю в зависимости от camera mode.
     * - first → к camera, рендерим поверх всего (renderingGroupId=1)
     * - third/front → к _modelRoot игрока, рендерим как обычно
     */
    _applyAttachment() {
        const root = this._viewRoot;
        if (!root) return;
        const player = this.scene3d?.player;
        const mode = player?._cameraMode || 'first';
        const camera = this.scene.activeCamera;

        const vm = _getViewModelSettings(this._equipped);

        if (mode === 'first' && camera) {
            if (player && typeof player._updateExtendedArm === 'function') {
                player._updateExtendedArm(false);
            }
            root.parent = camera;
            root.position.set(vm.position1st.x, vm.position1st.y, vm.position1st.z);
            root.rotation.set(0, Math.PI, 0);
            root.scaling.set(vm.scale1st, vm.scale1st, vm.scale1st);
            for (const m of this._viewMeshes) {
                if (m && m.renderingGroupId !== undefined) m.renderingGroupId = 1;
            }
        } else {
            if (player && typeof player._updateExtendedArm === 'function') {
                player._updateExtendedArm(true);
            }
            const anchor = player?.getWeaponAnchor?.();
            if (!anchor) {
                root.parent = null;
                for (const m of this._viewMeshes) {
                    if (m && m.setEnabled) m.setEnabled(false);
                }
                this._retryAttach();
                return;
            }
            for (const m of this._viewMeshes) {
                if (m && m.setEnabled) m.setEnabled(true);
                if (m && m.renderingGroupId !== undefined) m.renderingGroupId = 0;
            }
            root.parent = anchor;
            root.position.set(vm.position3rd.x, vm.position3rd.y, vm.position3rd.z);
            root.rotation.set(vm.rotation3rd.x, vm.rotation3rd.y, vm.rotation3rd.z);
            root.scaling.set(vm.scale3rd, vm.scale3rd, vm.scale3rd);
        }
    }

    /** Повтор попытки прицепить к модели игрока, если она ещё не загрузилась. */
    _retryAttach() {
        if (this._retryTimer) return;
        this._retryTimer = setTimeout(() => {
            this._retryTimer = null;
            if (!this._active || !this._viewRoot) return;
            this._applyAttachment();
        }, 200);
    }

    /** Колбэк из PlayerController при смене 1st/3rd. */
    onCameraModeChange(_mode) {
        this._applyAttachment();
    }

    _tick() {
        if (!this._active) return;
        // Анимация замаха меча (даже без equipped — для затухания)
        this._tickMelee();
        if (!this._equipped) return;
        const params = this._equipped.params || {};
        const isAuto = params.auto !== false;
        if (!isAuto) return;
        if (this._mouseDown) this._tryFire();
    }

    _tryFire() {
        if (!this._equipped) return;
        const params = this._equipped.params || {};
        const isMelee = params.weaponKind === 'melee'
            || this._equipped.modelTypeId?.startsWith('weapon-')
            || this._equipped.modelTypeId === 'weapon-sword'
            || this._equipped.modelTypeId === 'weapon-spear';
        const now = performance.now() / 1000;

        if (isMelee) {
            // Меч/копьё — без патронов, только cooldown
            const fireRate = Math.max(0.1, params.fireRate ?? 0.6);
            if (now - this._lastFireTime < fireRate) return;
            this._lastFireTime = now;
            this._meleeAttack();
            return;
        }

        if (this._reloading) return;
        const st = this._ammoState.get(this._equipped.id);
        if (!st) return;
        if (st.magazine <= 0) {
            if (st.reserve > 0) this.reload();
            return;
        }
        const fireRate = Math.max(0.05, params.fireRate ?? 0.18);
        if (now - this._lastFireTime < fireRate) return;
        this._lastFireTime = now;
        st.magazine -= 1;
        this._notifyAmmoChange();
        this._fire();
        if (st.magazine <= 0 && st.reserve > 0) {
            this.reload();
        }
    }

    /**
     * Удар ближнего боя.
     * Запускает анимацию взмаха (300мс), урон срабатывает в её середине.
     */
    _meleeAttack() {
        const player = this.scene3d?.player;
        const camera = this.scene.activeCamera;
        if (!player || !camera) return;

        const isSpear = this._equipped?.modelTypeId === 'weapon-spear';
        // Запускаем анимацию (тип в зависимости от оружия)
        if (player._rightArmMeshes?.[0]) {
            const meshName = (player._rightArmMeshes[0].name || '').replace(/^player_/, '');
            this._meleeAnimStart = performance.now();
            this._meleeAnimMeshName = meshName;
            this._meleeAnimType = isSpear ? 'thrust' : 'swing';
            this._meleeDamageDealt = false;          // флаг чтобы урон сработал один раз в середине
        }

        this._playMeleeSound();
    }

    /** Применить урон зомби в конусе/радиусе. Вызывается в середине анимации. */
    _applyMeleeDamage() {
        const player = this.scene3d?.player;
        const camera = this.scene.activeCamera;
        if (!player || !camera) return;
        const params = this._equipped.params || {};
        const range = params.range ?? 2.2;
        const arc = params.meleeArc ?? 1.2;
        const damage = params.damage ?? 40;

        const fwd = camera.getForwardRay(1).direction.clone();
        fwd.y = 0;
        if (fwd.lengthSquared() < 0.001) return;
        fwd.normalize();
        const px = player._pos.x;
        const py = player._pos.y;
        const pz = player._pos.z;

        const zm = this.scene3d.zombieManager;
        if (!zm || !zm.zombies) return;
        for (const z of zm.zombies.values()) {
            const dx = z.data.x - px;
            const dz = z.data.z - pz;
            const dy = z.data.y + 1 - py;
            const dist = Math.sqrt(dx * dx + dz * dz + dy * dy);
            if (dist > range) continue;
            const len = Math.sqrt(dx * dx + dz * dz) || 0.001;
            const dot = (dx / len) * fwd.x + (dz / len) * fwd.z;
            const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
            if (angle > arc / 2) continue;
            z.hp -= damage;
            if (z.hp <= 0) zm._killZombie(z);
        }
    }

    /**
     * Анимация удара. Тип:
     *   'swing' — широкий взмах сверху вниз (меч).
     *   'thrust' — выпад вперёд (копьё).
     */
    _tickMelee() {
        if (!this._meleeAnimStart) return;
        const player = this.scene3d?.player;
        if (!player) return;
        const elapsed = (performance.now() - this._meleeAnimStart) / 1000;
        const dur = 0.32;
        const k = Math.min(1, elapsed / dur);

        // Урон применяется один раз в середине анимации (k≈0.5)
        if (!this._meleeDamageDealt && k >= 0.5) {
            this._meleeDamageDealt = true;
            this._applyMeleeDamage();
        }

        if (elapsed >= dur) {
            if (player.setMeshRotationOverride && this._meleeAnimMeshName) {
                player.setMeshRotationOverride(this._meleeAnimMeshName,
                    new Vector3(-Math.PI / 2, 0, 0));
            }
            this._meleeAnimStart = null;
            return;
        }

        if (!player.setMeshRotationOverride || !this._meleeAnimMeshName) return;

        if (this._meleeAnimType === 'thrust') {
            // Копьё: рука уходит назад на 30%, затем стремительно вперёд
            // и возвращается. Реализуем через rotation.x движение.
            // k=0..0.3 — отвод назад (rotation.x от -π/2 до -π/2 - 0.6)
            // k=0.3..0.6 — резкий бросок вперёд (до -π/2 + 0.4)
            // k=0.6..1.0 — возврат
            let off;
            if (k < 0.3) {
                off = -0.6 * (k / 0.3);
            } else if (k < 0.6) {
                off = -0.6 + 1.0 * ((k - 0.3) / 0.3);
            } else {
                off = 0.4 * (1 - (k - 0.6) / 0.4);
            }
            player.setMeshRotationOverride(this._meleeAnimMeshName,
                new Vector3(-Math.PI / 2 + off, 0, 0));
        } else {
            // Меч: широкий sweep сверху → вниз (по дуге).
            // Поднимаем руку выше, потом резко опускаем.
            // k=0..0.3 — замах вверх (rotation.x от -π/2 → -π)
            // k=0.3..0.7 — резкий взмах вниз (до 0, ниже плеча)
            // k=0.7..1.0 — возврат к -π/2
            let armX;
            if (k < 0.3) {
                armX = -Math.PI / 2 - (Math.PI / 2) * (k / 0.3);  // → -π
            } else if (k < 0.7) {
                const t = (k - 0.3) / 0.4;
                armX = -Math.PI + (Math.PI) * t;  // -π → 0
            } else {
                const t = (k - 0.7) / 0.3;
                armX = 0 + (-Math.PI / 2 - 0) * t;  // 0 → -π/2
            }
            // Также добавляем небольшой Z-крен для широкого свинга
            const armZ = Math.sin(k * Math.PI) * 0.3;
            player.setMeshRotationOverride(this._meleeAnimMeshName,
                new Vector3(armX, 0, armZ));
        }
    }

    /** Звук удара меча — короткий «свист». */
    _playMeleeSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const t = ctx.currentTime;
            const dur = 0.18;
            const osc = ctx.createOscillator();
            osc.type = 'triangle';
            osc.frequency.setValueAtTime(800, t);
            osc.frequency.exponentialRampToValueAtTime(200, t + dur);
            const g = ctx.createGain();
            g.gain.setValueAtTime(0, t);
            g.gain.linearRampToValueAtTime(0.16, t + 0.02);
            g.gain.exponentialRampToValueAtTime(0.001, t + dur);
            osc.connect(g).connect(ctx.destination);
            osc.start(t);
            osc.stop(t + dur + 0.02);
        } catch (e) { /* ignore */ }
    }

    /** Один выстрел: луч, эффекты, звук, callback onHit. */
    _fire() {
        const camera = this.scene.activeCamera;
        if (!camera) return;
        const params = this._equipped?.params || {};
        const range = params.range ?? 60;
        const damage = params.damage ?? 25;
        const player = this.scene3d?.player;
        const mode = player?._cameraMode || 'first';

        // Прицеливание: по умолчанию от камеры (центр прицела). Но если
        // _aimScreenPoint задан — стреляем в точку (x,y) на экране
        // (для tap-to-shoot на мобиле). Точка применяется один раз.
        let hit = null;
        let ray;
        // aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
        // только когда курсор свободен — нет pointer-lock).
        let aim = this._aimScreenPoint;
        if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
            aim = this._holdAim;
        }
        try {
            if (aim) {
                ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
                this._aimScreenPoint = null; // одноразовая
            } else {
                ray = camera.getForwardRay(range);
            }
            hit = this.scene.pickWithRay(ray, (mesh) => {
                if (!mesh || !mesh.isPickable) return false;
                // Игнорим скрытые меши (Babylon иногда пикает disabled-mesh
                // когда родитель — TransformNode с setEnabled(false)).
                if (typeof mesh.isEnabled === 'function' && !mesh.isEnabled()) return false;
                // Спавн-маркер игрока (`isSpawn`) и любая редакторская служебная меш
                if (mesh.metadata?.isSpawn) return false;
                if (this._viewMeshes.includes(mesh)) return false;
                if (this.scene3d?.player?._modelMeshes?.includes?.(mesh)) return false;
                if (mesh.metadata?.isWater) return false;
                return true;
            });
        } catch (e) { /* ignore */ }

        const camOrigin = camera.globalPosition.clone();
        // Если был aim-ray от тапа — используем его направление, иначе камеры.
        const camFwd = (ray && ray.direction)
            ? ray.direction.clone().normalize()
            : camera.getForwardRay(range).direction.clone().normalize();
        const hitPoint = hit?.pickedPoint || camOrigin.add(camFwd.scale(range));
        const hitDistance = hit?.distance ?? range;

        // Точка вылета ТРАССЕРА = конец ствола view-model.
        // В 1-st person — _viewRoot + 0.4 по camera.forward.
        // В 3-rd person — _viewRoot + локальный forward оружия (его +Z в мире,
        // т.к. оружие повёрнуто rotation=(0,0,0) внутри якоря модели).
        let muzzlePos;
        if (this._viewRoot && this._viewRoot.getWorldMatrix) {
            this._viewRoot.computeWorldMatrix(true);
            muzzlePos = this._viewRoot.absolutePosition.clone();
            if (mode === 'first') {
                muzzlePos = muzzlePos.add(camFwd.scale(0.4));
            } else {
                // Forward оружия в мире: _viewRoot.getDirection(Vector3.Forward())
                // даёт мировое направление локального +Z. Длина ствола ~0.5.
                const weaponFwd = this._viewRoot.getDirection
                    ? this._viewRoot.getDirection(new Vector3(0, 0, 1)).normalize()
                    : camFwd;
                muzzlePos = muzzlePos.add(weaponFwd.scale(0.5));
            }
        } else {
            muzzlePos = camOrigin.add(camFwd.scale(0.6));
        }

        // Трассер — летящая «капля света» от дула к точке попадания.
        // В 1-st person дуло близко к камере и направление трассера ≈ направление
        // взгляда → визуально это полоска улетающая вдаль (не вспышка перед лицом).
        this._spawnTracer(muzzlePos, hitPoint, mode);

        // Вспышка из дула — тоже от muzzlePos
        this._spawnMuzzleFlash(muzzlePos);

        // Импакт-частицы в точке попадания
        if (hit?.pickedMesh) {
            this._spawnImpact(hitPoint);
        }
        // Звук
        this._playFireSound();

        // Колбэк попадания
        if (hit?.pickedMesh && this._onHit) {
            try {
                this._onHit({
                    mesh: hit.pickedMesh,
                    point: { x: hitPoint.x, y: hitPoint.y, z: hitPoint.z },
                    distance: hitDistance,
                    damage,
                    metadata: hit.pickedMesh.metadata || null,
                });
            } catch (e) { /* ignore */ }
        }
    }

    /**
     * Трейсер выстрела: «капля света» — сфера, летящая от дула к точке попадания.
     * За ней тянется удлинённый цилиндр-хвост (в направлении полёта).
     *
     * Сферу видно с любого ракурса (в отличие от тонкого статичного цилиндра,
     * который в 3rd-person смотрел вдоль камеры и сжимался в точку).
     * renderingGroupId=1 + alwaysSelectAsActiveMesh — рендерится поверх всего
     * и не отбрасывается octree/freeze.
     */
    _spawnTracer(start, end, mode) {
        const dir = end.subtract(start);
        const len = dir.length();
        if (len < 0.05) return;
        const dirN = dir.normalize();

        // Сфера-снаряд (видна со всех сторон, нет проблемы тонкого цилиндра вдоль взгляда)
        const PROJ_DIAMETER = mode === 'first' ? 0.10 : 0.20;
        const proj = MeshBuilder.CreateSphere('tracerProj', {
            diameter: PROJ_DIAMETER, segments: 6,
        }, this.scene);
        const mat = new StandardMaterial('tracerProjMat', this.scene);
        mat.emissiveColor = new Color3(1, 0.85, 0.2);
        mat.diffuseColor = new Color3(1, 0.85, 0.2);
        mat.disableLighting = true;
        proj.material = mat;
        proj.isPickable = false;
        proj.alwaysSelectAsActiveMesh = true;
        proj.renderingGroupId = 1;

        // Хвост — удлинённый цилиндр позади снаряда (длина 1.5м, ориентирован по dirN).
        // Тоже renderingGroupId=1, чтобы не терялся за геометрией.
        const TAIL_LEN = mode === 'first' ? 1.0 : 2.0;
        const TAIL_DIAM = mode === 'first' ? 0.06 : 0.14;
        const tail = MeshBuilder.CreateCylinder('tracerTail', {
            height: TAIL_LEN, diameter: TAIL_DIAM, tessellation: 6,
        }, this.scene);
        const tailMat = new StandardMaterial('tracerTailMat', this.scene);
        tailMat.emissiveColor = new Color3(1, 0.7, 0.1);
        tailMat.diffuseColor = new Color3(1, 0.7, 0.1);
        tailMat.disableLighting = true;
        tailMat.alpha = 0.7;
        tail.material = tailMat;
        tail.isPickable = false;
        tail.alwaysSelectAsActiveMesh = true;
        tail.renderingGroupId = 1;
        // Поворот цилиндра: в Babylon он вертикальный (Y), нужно вдоль dirN.
        const pitch = -Math.asin(dirN.y);
        const yawAngle = Math.atan2(dirN.x, dirN.z);
        tail.rotation.set(Math.PI / 2 + pitch, yawAngle, 0);

        const speed = mode === 'first' ? 140 : 100;
        const flightTime = Math.min(0.5, len / speed);
        const startTs = performance.now();
        proj.position.copyFrom(start);
        tail.position.copyFrom(start);

        const obs = this.scene.onBeforeRenderObservable.add(() => {
            const t = (performance.now() - startTs) / 1000;
            const k = Math.min(1, t / flightTime);
            const cur = start.add(dirN.scale(len * k));
            proj.position.copyFrom(cur);
            // Хвост ставим в середине между текущей позицией снаряда
            // и (cur - TAIL_LEN/2 * dirN), чтобы тянулся ПОЗАДИ снаряда.
            tail.position.copyFrom(cur.subtract(dirN.scale(TAIL_LEN * 0.5)));
            if (k >= 1) {
                this.scene.onBeforeRenderObservable.remove(obs);
                try { proj.dispose(); mat.dispose(); } catch (e) {}
                try { tail.dispose(); tailMat.dispose(); } catch (e) {}
            }
        });
    }

    /** Жёлтая вспышка частиц у дула на 100мс. */
    _spawnMuzzleFlash(origin) {
        const camera = this.scene.activeCamera;
        if (!camera || !origin) return;
        const fwd = camera.getForwardRay(1).direction.normalize();

        const ps = new ParticleSystem('muzzle', 30, this.scene);
        ps.particleTexture = new Texture('https://www.babylonjs-playground.com/textures/flare.png', this.scene);
        ps.emitter = origin;
        ps.minEmitBox = new Vector3(-0.05, -0.05, -0.05);
        ps.maxEmitBox = new Vector3(0.05, 0.05, 0.05);
        ps.color1 = new Color4(1, 0.9, 0.2, 1);
        ps.color2 = new Color4(1, 0.5, 0, 1);
        ps.colorDead = new Color4(0.1, 0, 0, 0);
        ps.minSize = 0.1;
        ps.maxSize = 0.3;
        ps.minLifeTime = 0.05;
        ps.maxLifeTime = 0.15;
        ps.emitRate = 200;
        ps.direction1 = fwd.scale(2);
        ps.direction2 = fwd.scale(4);
        ps.minEmitPower = 1;
        ps.maxEmitPower = 3;
        ps.start();
        setTimeout(() => {
            try { ps.stop(); setTimeout(() => ps.dispose(), 300); } catch (e) {}
        }, 80);
    }

    /** Искры в точке попадания. */
    _spawnImpact(point) {
        const ps = new ParticleSystem('impact', 25, this.scene);
        ps.particleTexture = new Texture('https://www.babylonjs-playground.com/textures/flare.png', this.scene);
        ps.emitter = point;
        ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1);
        ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1);
        ps.color1 = new Color4(1, 0.7, 0.2, 1);
        ps.color2 = new Color4(0.8, 0.3, 0, 1);
        ps.colorDead = new Color4(0, 0, 0, 0);
        ps.minSize = 0.08;
        ps.maxSize = 0.18;
        ps.minLifeTime = 0.15;
        ps.maxLifeTime = 0.4;
        ps.emitRate = 100;
        ps.direction1 = new Vector3(-2, -2, -2);
        ps.direction2 = new Vector3(2, 2, 2);
        ps.gravity = new Vector3(0, -5, 0);
        ps.minEmitPower = 1;
        ps.maxEmitPower = 3;
        ps.start();
        setTimeout(() => {
            try { ps.stop(); setTimeout(() => ps.dispose(), 500); } catch (e) {}
        }, 100);
    }

    /** Звук перезарядки — два щелчка с интервалом ~0.3с. */
    _playReloadSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const click = (when) => {
                const t = ctx.currentTime + when;
                const osc = ctx.createOscillator();
                osc.type = 'square';
                osc.frequency.setValueAtTime(120, t);
                osc.frequency.exponentialRampToValueAtTime(40, t + 0.08);
                const g = ctx.createGain();
                g.gain.setValueAtTime(0.18, t);
                g.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
                osc.connect(g).connect(ctx.destination);
                osc.start(t);
                osc.stop(t + 0.12);
            };
            click(0);
            click(0.4);
        } catch (e) { /* ignore */ }
    }

    /** Процедурный звук «пиу» — короткий sweep частоты. */
    _playFireSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const t = ctx.currentTime;
            const dur = 0.12;
            const osc = ctx.createOscillator();
            osc.type = 'sawtooth';
            osc.frequency.setValueAtTime(900, t);
            osc.frequency.exponentialRampToValueAtTime(150, t + dur);
            const g = ctx.createGain();
            g.gain.setValueAtTime(0, t);
            g.gain.linearRampToValueAtTime(0.18, t + 0.005);
            g.gain.exponentialRampToValueAtTime(0.001, t + dur);
            const lp = ctx.createBiquadFilter();
            lp.type = 'lowpass';
            lp.frequency.value = 2400;
            osc.connect(lp).connect(g).connect(ctx.destination);
            osc.start(t);
            osc.stop(t + dur + 0.02);
        } catch (e) { /* ignore */ }
    }
}
