/**
 * DynamicsManager — простая физика для unanchored-объектов.
 *
 * Активен ТОЛЬКО в Play-режиме. На каждом тике:
 *   1. Для каждого объекта с anchored=false применяет гравитацию (vy -= g*dt).
 *   2. Двигает по AABB-collision с полом, anchored-блоками и anchored-примитивами.
 *   3. Останавливает падение при ударе об пол / другой объект.
 *
 * Объекты бывают трёх типов: block | model | primitive.
 * У каждого свой root-mesh (Mesh для блоков и примитивов, TransformNode для моделей).
 *
 * Push от игрока: PlayerController вызывает applyPushFromPlayer() при пересечении
 * AABB игрока с объектом — задаёт горизонтальную скорость в направлении движения.
 *
 * NB: это упрощённая физика без вращения/трения/масс.
 * AABB вычисляется по `mesh.getBoundingInfo().boundingBox` если возможно,
 * иначе fallback к sx×sy×sz для примитивов и 1×1×1 для блоков.
 */

import { buildOBBAxes, aabbIntersectsOBB } from './OBBCollision';

const GRAVITY = -25;          // м/с² (как в PlayerController, но чуть мягче)
const MAX_FALL_SPEED = -40;
const FRICTION = 2.5;         // линейное трение по горизонтали (1/с)
const FLOOR_HALF = 40;        // граница baseplate
const MIN_MASS = 0.1;
const REFERENCE_MASS = 1;     // масса при которой targetSpeed = playerSpeed
// Игрок физически НЕ может пересечь AABB объекта (физика блокирует), поэтому
// для регистрации «касания» добавляем небольшой запас к границам.
const TOUCH_PADDING = 0.2;
// Сила удара (м/с дополнительной скорости при mass=1).
const PUSH_BOOST_BASE = 8;
const PUSH_LIFT = 1.5;        // подскок при толчке (делится на массу)
const MAX_PUSH_SPEED = 30;    // ограничение скорости после удара
// Падение «башни»: если объект высокий (sy/min(sx,sz) > порог), он
// нестабилен — толчок от игрока запускает наклон, и дальше угловая гравитация
// сама роняет объект (как маятник наоборот, чем больше угол — тем быстрее падает).
const TOPPLE_RATIO = 1.5;       // sy / min(sx,sz) > 1.5 → склонен падать
const TOPPLE_KICK = 2.0;        // начальный угловой импульс при ударе об пол (рад/с)
const TOPPLE_GRAVITY = 9;       // коэффициент угловой гравитации — чем выше, тем быстрее падает
const TOPPLE_KICK_FROM_PUSH = 1.5; // угловая скорость от прямого толчка игроком (рад/с)
const FALLEN_ANGLE = Math.PI / 2 - 0.1;  // угол при котором считаем «упал на бок»
// Включить подробные логи в консоли (window.__KUBIKON_PHYS_LOG = true).
const isLogEnabled = () => (typeof window !== 'undefined' && window.__KUBIKON_PHYS_LOG === true);

export class DynamicsManager {
    constructor(scene3d) {
        this.scene3d = scene3d;
        // Map id → bodyData
        // body: { kind, ref, mesh, halfW, halfH, halfD, vx, vy, vz, onGround }
        this.bodies = new Map();
        this._nextId = 1;
        this._enabled = false;
    }

    /** Включить — собирает все unanchored объекты. */
    start() {
        this.bodies.clear();
        const s = this.scene3d;
        const log = isLogEnabled();
        // Блоки
        if (s.blockManager) {
            for (const mesh of s.blockManager.blocks.values()) {
                if (mesh.metadata?.anchored === false) {
                    const m = mesh.metadata.mass ?? 1;
                    this._addBody('block', mesh.metadata, mesh, 0.5, 0.5, 0.5, m, true);
                }
            }
        }
        // Примитивы
        if (s.primitiveManager) {
            for (const data of s.primitiveManager.instances.values()) {
                if (data.anchored === false) {
                    const hw = (data.sx || 1) / 2;
                    const hh = (data.sy || 1) / 2;
                    const hd = (data.sz || 1) / 2;
                    const collide = data.canCollide !== false
                        && data.type !== 'trigger' && data.type !== 'checkpoint';
                    this._addBody('primitive', data, data.mesh, hw, hh, hd, data.mass ?? 1, collide);
                }
            }
        }
        // Модели
        if (s.modelManager) {
            for (const data of s.modelManager.instances.values()) {
                if (data.anchored === false && data.rootMesh) {
                    // Берём ИСТИННЫЙ bounding box модели (из getHierarchyBoundingVectors).
                    // localAABB — относительно rootMesh.position. Он МОЖЕТ быть несимметричным
                    // (например, дерево: y=0..3, x=-0.5..0.5). Поэтому halfW/halfH/halfD
                    // считаем как полудиагонали по осям, а саму позицию body
                    // запоминаем со смещением offsetX/Y/Z от rootMesh.position до центра AABB.
                    let local = data.localAABB;
                    if (!local && s.modelManager._computeLocalAABB) {
                        s.modelManager._computeLocalAABB(data);
                        local = data.localAABB;
                    }
                    let hw, hh, hd, offX, offY, offZ;
                    if (local) {
                        hw = (local.maxX - local.minX) / 2;
                        hh = (local.maxY - local.minY) / 2;
                        hd = (local.maxZ - local.minZ) / 2;
                        offX = (local.maxX + local.minX) / 2;
                        offY = (local.maxY + local.minY) / 2;
                        offZ = (local.maxZ + local.minZ) / 2;
                    } else {
                        const bb = this._estimateModelAABB(data);
                        hw = bb.hw; hh = bb.hh; hd = bb.hd;
                        offX = 0; offY = bb.hh; offZ = 0;   // модель y=низ → центр выше на hh
                    }
                    const id = this._nextId++;
                    const px = data.rootMesh.position.x;
                    const py = data.rootMesh.position.y;
                    const pz = data.rootMesh.position.z;
                    this.bodies.set(id, {
                        id, kind: 'model', ref: data, mesh: data.rootMesh,
                        halfW: hw, halfH: hh, halfD: hd,
                        origHalfW: hw, origHalfH: hh, origHalfD: hd,
                        // Позиция body — это ЦЕНТР AABB. mesh.position смещена на offset.
                        x: px + offX, y: py + offY, z: pz + offZ,
                        offsetX: offX, offsetY: offY, offsetZ: offZ,
                        vx: 0, vy: 0, vz: 0,
                        omegaX: 0, omegaZ: 0,
                        tiltX: data.rootMesh.rotation?.x || 0,
                        tiltZ: data.rootMesh.rotation?.z || 0,
                        initialRotX: data.rootMesh.rotation?.x || 0,
                        initialRotZ: data.rootMesh.rotation?.z || 0,
                        onGround: false,
                        wasOnGround: false,
                        mass: Math.max(MIN_MASS, (data.mass ?? 1)),
                        canCollide: data.canCollide !== false,
                        _hasToppled: false,
                        _justPushed: false,
                        _toppleAxis: null,
                    });
                }
            }
        }
        this._enabled = true;
        // eslint-disable-next-line no-console
        console.info('[Dynamics] Play started. Unanchored bodies:', this.bodies.size,
            '— подробные логи: window.__KUBIKON_PHYS_LOG = true');
        if (log) {
            // eslint-disable-next-line no-console
            console.log('[Dynamics] bodies:',
                Array.from(this.bodies.values()).map(b => ({
                    kind: b.kind, mass: b.mass, canCollide: b.canCollide,
                    pos: [b.x.toFixed(2), b.y.toFixed(2), b.z.toFixed(2)],
                    half: [b.halfW, b.halfH, b.halfD],
                })));
        }
    }

    stop() {
        this._enabled = false;
        this.bodies.clear();
    }

    isEnabled() { return this._enabled; }

    /**
     * Зарегистрировать unanchored-примитив в физике НА ЛЕТУ.
     * start() собирает unanchored-объекты один раз при входе в Play —
     * примитив, заспавненный скриптом во время Play (game.scene.spawn
     * с anchored:false), туда не попадает и не падает. Этот метод
     * добавляет его в физику сразу после спавна.
     */
    registerPrimitive(data) {
        if (!this._enabled || !data || data.anchored !== false) return;
        const hw = (data.sx || 1) / 2;
        const hh = (data.sy || 1) / 2;
        const hd = (data.sz || 1) / 2;
        const collide = data.canCollide !== false
            && data.type !== 'trigger' && data.type !== 'checkpoint';
        this._addBody('primitive', data, data.mesh, hw, hh, hd, data.mass ?? 1, collide);
    }

    /**
     * Задать скорость/импульс телу по его инстанс-объекту (ref).
     * mode='set' — задать скорость, mode='impulse' — добавить к текущей.
     * Возвращает true если тело найдено. Работает только для unanchored
     * объектов (у anchored нет body в DynamicsManager).
     */
    applyToInstance(instanceData, vx, vy, vz, mode = 'set') {
        if (!instanceData) return false;
        for (const body of this.bodies.values()) {
            if (body.ref === instanceData) {
                if (mode === 'impulse') {
                    body.vx += vx; body.vy += vy; body.vz += vz;
                } else {
                    body.vx = vx; body.vy = vy; body.vz = vz;
                }
                body.onGround = false;  // оторвать от земли чтобы полетел
                return true;
            }
        }
        return false;
    }

    _addBody(kind, ref, mesh, hw, hh, hd, mass, canCollide = true) {
        const id = this._nextId++;
        const px = mesh.position.x;
        const py = mesh.position.y;
        const pz = mesh.position.z;
        // Запоминаем исходный rotation чтобы корректно его восстановить при stop
        const initialRotX = mesh.rotation?.x || 0;
        const initialRotZ = mesh.rotation?.z || 0;
        this.bodies.set(id, {
            id, kind, ref, mesh,
            halfW: hw, halfH: hh, halfD: hd,
            // Исходные размеры (до наклона) — нужны чтобы пересчитать halfH
            // когда объект поворачивается на бок.
            origHalfW: hw, origHalfH: hh, origHalfD: hd,
            x: px, y: py, z: pz,
            vx: 0, vy: 0, vz: 0,
            // Угловые скорости для падения «башни» (только X/Z — Y уже есть для моделей)
            omegaX: 0, omegaZ: 0,
            // Накопленный угол наклона (для отрисовки + ограничения максимума)
            tiltX: initialRotX, tiltZ: initialRotZ,
            initialRotX, initialRotZ,
            onGround: false,
            wasOnGround: false,
            mass: Math.max(MIN_MASS, mass || 1),
            canCollide,
            _hasToppled: false,    // упало ли уже на бок (один раз триггерим torque)
            _justPushed: false,     // флаг: в этом кадре был push от игрока
            _toppleAxis: null,       // 'X' | 'Z' — выбранная ось падения (фиксируется при kick)
        });
    }

    _estimateModelAABB(data) {
        // Приближённый AABB модели — из rootMesh.scaling и стандартного куба 1×1
        const s = data.rootMesh?.scaling?.x ?? 1;
        return { hw: 0.5 * s, hh: 0.5 * s, hd: 0.5 * s };
    }

    /**
     * Пересчитать halfW/halfH/halfD body исходя из его наклона.
     * При повороте на 90° по оси Z — halfH меняется местами с halfW.
     * При повороте на 90° по оси X — halfH меняется местами с halfD.
     * Это упрощённая модель (точная требовала бы OBB) — но достаточно чтобы
     * объект корректно ложился на пол при падении.
     */
    _updateEffectiveHalfSize(body) {
        const tiltX = Math.abs(body.tiltX - body.initialRotX);
        const tiltZ = Math.abs(body.tiltZ - body.initialRotZ);
        const ohw = body.origHalfW;
        const ohh = body.origHalfH;
        const ohd = body.origHalfD;
        // Поворот по Z меняет halfH ↔ halfW.
        // Поворот по X меняет halfH ↔ halfD.
        // sin/cos интерполируют между двумя крайними состояниями.
        const cz = Math.abs(Math.cos(tiltZ));
        const sz = Math.abs(Math.sin(tiltZ));
        const cx = Math.abs(Math.cos(tiltX));
        const sx = Math.abs(Math.sin(tiltX));

        // halfH (по мировой Y): уменьшается когда любой из tilt-ов > 0.
        // Берём сумму вклада: vertical = projection of original H + horizontal axes.
        const newHalfH = ohh * cx * cz + ohw * sz + ohd * sx;
        const newHalfW = ohw * cz + ohh * sz;
        const newHalfD = ohd * cx + ohh * sx;
        body.halfH = newHalfH;
        body.halfW = newHalfW;
        body.halfD = newHalfD;
    }

    /**
     * Тик физики. dt в секундах. Должен вызываться из render-loop в Play-режиме.
     */
    tick(dt) {
        if (!this._enabled) return;
        if (dt <= 0) return;
        if (dt > 0.1) dt = 0.1;  // ограничиваем dt на случай зависания

        for (const body of this.bodies.values()) {
            body.wasOnGround = body.onGround;

            // Пересчёт эффективных halfW/H/D исходя из текущего наклона.
            // Когда объект поворачивается на бок, его «высота» по мировой Y
            // уменьшается до halfW или halfD, а «ширина» по мировой X/Z растёт
            // до origHalfH. Это упрощённая AABB — точная требовала бы OBB.
            this._updateEffectiveHalfSize(body);

            // Гравитация
            body.vy += GRAVITY * dt;
            if (body.vy < MAX_FALL_SPEED) body.vy = MAX_FALL_SPEED;

            // Линейное трение по горизонтали (только когда на земле и НЕ было push'а
            // в этом кадре — иначе тяжёлый объект «дрожит» между push и трением).
            if (body.onGround && !body._justPushed) {
                const damp = Math.max(0, 1 - FRICTION * dt);
                body.vx *= damp;
                body.vz *= damp;
            }
            body._justPushed = false;     // сбрасываем флаг для следующего кадра

            // Движение с коллизиями
            const dx = body.vx * dt;
            const dy = body.vy * dt;
            const dz = body.vz * dt;
            this._moveBody(body, dx, dy, dz);

            // === Ангулярная динамика — «башня падает на бок» ===
            // ВАЖНО: используем origHalf*, а не текущие halfH (которые уменьшаются
            // при наклоне). Иначе как только стена слегка наклонится, ratio упадёт
            // ниже порога и угловая гравитация выключится.
            const minOrigHorz = Math.min(body.origHalfW, body.origHalfD);
            const ratio = body.origHalfH / Math.max(0.01, minOrigHorz);
            const isToppleProne = ratio > TOPPLE_RATIO;

            // Текущий относительный наклон (от исходной ориентации)
            const relTiltX = body.tiltX - body.initialRotX;
            const relTiltZ = body.tiltZ - body.initialRotZ;
            const totalTilt = Math.sqrt(relTiltX * relTiltX + relTiltZ * relTiltZ);

            // 1) При первом ударе об пол с горизонтальной скоростью — даём начальный kick
            //    ТОЛЬКО по одной оси (главной по направлению движения), чтобы стена
            //    падала плашмя, а не крутилась вокруг своей вертикальной оси.
            const justLanded = body.onGround && !body.wasOnGround;
            if (justLanded && !body._hasToppled && isToppleProne) {
                const horzSpeed = Math.sqrt(body.vx * body.vx + body.vz * body.vz);
                if (horzSpeed > 0.5) {
                    if (Math.abs(body.vx) >= Math.abs(body.vz)) {
                        body.omegaZ += -Math.sign(body.vx) * TOPPLE_KICK;
                        body._toppleAxis = 'Z';
                    } else {
                        body.omegaX +=  Math.sign(body.vz) * TOPPLE_KICK;
                        body._toppleAxis = 'X';
                    }
                    body._hasToppled = true;
                }
            }

            // 2) Угловая гравитация — продолжаем падение по той же оси что и kick.
            //    Без диагональных направлений: стена должна падать плашмя.
            if (isToppleProne && totalTilt < FALLEN_ANGLE && totalTilt > 0.001) {
                const angAcc = TOPPLE_GRAVITY * Math.sin(totalTilt) / (1 + body.mass * 0.1);
                if (body._toppleAxis === 'Z') {
                    // Падение продолжается по Z, направление из текущего tiltZ
                    const sign = Math.sign(relTiltZ) || (body.omegaZ >= 0 ? 1 : -1);
                    body.omegaZ += sign * angAcc * dt;
                } else if (body._toppleAxis === 'X') {
                    const sign = Math.sign(relTiltX) || (body.omegaX >= 0 ? 1 : -1);
                    body.omegaX += sign * angAcc * dt;
                } else {
                    // Если ось не определена (например, kick'а не было — толкание в воздухе),
                    // выбираем по большему уже накопленному tilt.
                    if (Math.abs(relTiltZ) >= Math.abs(relTiltX)) {
                        body.omegaZ += Math.sign(relTiltZ || 1) * angAcc * dt;
                    } else {
                        body.omegaX += Math.sign(relTiltX || 1) * angAcc * dt;
                    }
                }
            }

            // 3) Интегрируем вращение
            if (body.omegaX !== 0 || body.omegaZ !== 0 || totalTilt > 0.001) {
                body.tiltX += body.omegaX * dt;
                body.tiltZ += body.omegaZ * dt;
                // Ограничиваем угол ±90° (объект ложится на бок)
                const LIMIT = Math.PI / 2;
                if (body.tiltX - body.initialRotX > LIMIT) {
                    body.tiltX = body.initialRotX + LIMIT;
                    body.omegaX = 0;
                }
                if (body.tiltX - body.initialRotX < -LIMIT) {
                    body.tiltX = body.initialRotX - LIMIT;
                    body.omegaX = 0;
                }
                if (body.tiltZ - body.initialRotZ > LIMIT) {
                    body.tiltZ = body.initialRotZ + LIMIT;
                    body.omegaZ = 0;
                }
                if (body.tiltZ - body.initialRotZ < -LIMIT) {
                    body.tiltZ = body.initialRotZ - LIMIT;
                    body.omegaZ = 0;
                }
                // Затухание — слабое, чтобы не глушить падение, но останавливать
                // когда объект уже упал на бок.
                const damp = totalTilt >= FALLEN_ANGLE ? 0.5 : 0.95;
                body.omegaX *= Math.pow(damp, dt * 60);
                body.omegaZ *= Math.pow(damp, dt * 60);
            }

            // Применяем к меш
            this._applyToMesh(body);

            // Если тело пересекается с игроком — выталкиваем игрока, а не наоборот.
            // Это решает «застревание» когда стена падает на игрока.
            this._pushPlayerOutOfBody(body);
        }
    }

    /**
     * Если AABB игрока пересекается с AABB body — выталкиваем игрока в сторону
     * минимального проникновения. Срабатывает каждый кадр пока есть пересечение,
     * поэтому игрок постепенно «вылезает» из упавшего объекта.
     */
    _pushPlayerOutOfBody(body) {
        const player = this.scene3d.player;
        if (!player || !player._pos) return;
        const px = player._pos.x, py = player._pos.y, pz = player._pos.z;
        const phw = player.HALF_W, phh = player.HALF_H, phd = player.HALF_D;
        const dx = px - body.x;
        const dy = py - body.y;
        const dz = pz - body.z;
        const ovx = body.halfW + phw - Math.abs(dx);
        const ovy = body.halfH + phh - Math.abs(dy);
        const ovz = body.halfD + phd - Math.abs(dz);
        if (ovx <= 0 || ovy <= 0 || ovz <= 0) return;     // нет пересечения

        // Минимальное проникновение → выталкиваем по этой оси
        if (ovx <= ovy && ovx <= ovz) {
            player._pos.x += dx >= 0 ? ovx : -ovx;
        } else if (ovy <= ovx && ovy <= ovz) {
            // По Y — если игрок выше тела, ставим его на верх; если ниже, опускаем
            if (dy >= 0) {
                player._pos.y += ovy;
                if (player._vy < 0) player._vy = 0;
            } else {
                player._pos.y -= ovy;
                if (player._vy > 0) player._vy = 0;
            }
        } else {
            player._pos.z += dz >= 0 ? ovz : -ovz;
        }
    }

    /** Двигаем body на (dx, dy, dz) с проверкой столкновений. */
    _moveBody(body, dx, dy, dz) {
        // По X
        if (dx !== 0) {
            const nx = body.x + dx;
            if (!this._collides(body, nx, body.y, body.z)) {
                body.x = nx;
            } else {
                body.vx = 0;
            }
        }
        // По Z
        if (dz !== 0) {
            const nz = body.z + dz;
            if (!this._collides(body, body.x, body.y, nz)) {
                body.z = nz;
            } else {
                body.vz = 0;
            }
        }
        // По Y
        body.onGround = false;
        if (dy !== 0) {
            const ny = body.y + dy;
            if (!this._collides(body, body.x, ny, body.z)) {
                body.y = ny;
            } else {
                if (dy < 0) body.onGround = true;   // ударились об пол/блок снизу
                body.vy = 0;
            }
        }

        // Граница baseplate — если упал ниже -50, удаляем тело (объект провалился)
        if (body.y < -50) {
            this._destroyBody(body);
        }
    }

    /** Проверка пересечения body в позиции (x,y,z) с миром. */
    _collides(body, x, y, z) {
        // Тело без коллизии проходит сквозь всё — но всё равно падает под гравитацией.
        // Останавливаем такое тело только при выходе за границы (внизу).
        if (!body.canCollide) return false;

        const hw = body.halfW, hh = body.halfH, hd = body.halfD;
        // Пол baseplate
        const onBP = Math.abs(x) <= FLOOR_HALF && Math.abs(z) <= FLOOR_HALF;
        if (onBP && y - hh < 0) return true;

        // Блоки (anchored или нет — все блоки твёрдые)
        const bm = this.scene3d.blockManager;
        if (bm) {
            const gxMin = Math.floor(x - hw + 0.5 + 0.001);
            const gxMax = Math.floor(x + hw + 0.5 - 0.001);
            const gzMin = Math.floor(z - hd + 0.5 + 0.001);
            const gzMax = Math.floor(z + hd + 0.5 - 0.001);
            const gyMin = Math.floor(y - hh + 0.001);
            const gyMax = Math.floor(y + hh - 0.001);
            for (let gx = gxMin; gx <= gxMax; gx++)
                for (let gy = gyMin; gy <= gyMax; gy++)
                    for (let gz = gzMin; gz <= gzMax; gz++) {
                        // Не сталкиваемся со своим телом если это блок
                        if (body.kind === 'block'
                            && body.ref?.gridX === gx && body.ref?.gridY === gy && body.ref?.gridZ === gz) continue;
                        if (bm.hasBlock(gx, gy, gz)) return true;
                    }
        }

        // Anchored примитивы (geometry, canCollide)
        const pm = this.scene3d.primitiveManager;
        if (pm) {
            for (const data of pm.instances.values()) {
                if (data === body.ref) continue;
                if (data.anchored === false) continue;       // только статика проверяется здесь
                if (data.canCollide === false) continue;
                if (data.type === 'trigger' || data.type === 'checkpoint') continue;
                const phw = (data.sx || 1) / 2;
                const phh = (data.sy || 1) / 2;
                const phd = (data.sz || 1) / 2;
                const rx = data.mesh?.rotation?.x || 0;
                const ry = data.mesh?.rotation?.y || 0;
                const rz = data.mesh?.rotation?.z || 0;
                if (rx === 0 && ry === 0 && rz === 0) {
                    if (Math.abs(x - data.x) < hw + phw
                     && Math.abs(y - data.y) < hh + phh
                     && Math.abs(z - data.z) < hd + phd) return true;
                } else {
                    const axes = buildOBBAxes(rx, ry, rz);
                    if (aabbIntersectsOBB(
                        x, y, z, hw, hh, hd,
                        data.x, data.y, data.z, phw, phh, phd, axes
                    )) return true;
                }
            }
        }
        // Anchored модели с canCollide
        const mm = this.scene3d.modelManager;
        if (mm) {
            for (const data of mm.instances.values()) {
                if (data === body.ref) continue;
                if (data.anchored === false) continue;
                if (data.canCollide === false) continue;
                const local = data.localAABB;
                if (!local) continue;
                const minX = data.x + local.minX, maxX = data.x + local.maxX;
                const minY = data.y + local.minY, maxY = data.y + local.maxY;
                const minZ = data.z + local.minZ, maxZ = data.z + local.maxZ;
                if (x + hw > minX && x - hw < maxX
                 && y + hh > minY && y - hh < maxY
                 && z + hd > minZ && z - hd < maxZ) return true;
            }
        }
        return false;
    }

    /** Применить позицию body к его mesh. */
    _applyToMesh(body) {
        if (!body.mesh) return;
        // У моделей body.x/y/z — это центр AABB, а mesh.position сдвинута
        // на offsetX/Y/Z (для дерева: рут на земле, AABB в воздухе).
        // У блоков и примитивов offset = 0.
        const ox = body.offsetX || 0;
        const oy = body.offsetY || 0;
        const oz = body.offsetZ || 0;
        body.mesh.position.x = body.x - ox;
        body.mesh.position.y = body.y - oy;
        body.mesh.position.z = body.z - oz;
        // Применяем наклон (для эффекта «башня падает»)
        if (body.mesh.rotation) {
            body.mesh.rotation.x = body.tiltX;
            body.mesh.rotation.z = body.tiltZ;
        }
        // Обновляем data для серилизации
        if (body.kind === 'primitive') {
            body.ref.x = body.x; body.ref.y = body.y; body.ref.z = body.z;
        } else if (body.kind === 'model') {
            body.ref.x = body.mesh.position.x;
            body.ref.y = body.mesh.position.y;
            body.ref.z = body.mesh.position.z;
        }
    }

    _destroyBody(body) {
        try { body.mesh?.setEnabled(false); } catch (e) { /* ignore */ }
        this.bodies.delete(body.id);
    }

    /**
     * Игрок касается unanchored body. Применяет импульс с учётом массы:
     *   dv = base / mass — лёгкое летит далеко, тяжёлое почти не двигается.
     *
     * Направление толчка (по приоритету):
     *   1) направление желаемого движения игрока (WASD→forward, в сторону);
     *   2) направление взгляда камеры если игрок стоит на месте;
     *   3) от центра игрока к центру объекта (fallback).
     *
     * playerVx/Vz — горизонтальная скорость игрока в этом тике (м/с).
     * forwardX/Z — куда смотрит камера (для случая когда игрок упёрся и vx=vz≈0).
     */
    /**
     * Толкание unanchored body игроком — kinematic-подход.
     *
     * Идея: вместо накапливающегося импульса (который вызывает колебания)
     * УСТАНАВЛИВАЕМ скорость объекта в направлении движения игрока на
     * `max(playerSpeed, текущая_скорость_по_направлению + boost/mass)`.
     *
     * Это значит:
     *   - объект всегда движется не медленнее игрока → игрок не наезжает;
     *   - лёгкий объект (mass < 1) получает большой boost → улетает быстро;
     *   - тяжёлый (mass > 1) едет почти со скоростью игрока → медленно толкает.
     *
     * playerVx/Vz — желаемая скорость игрока (от WASD); используется как
     *               направление если real-движение заблокировано.
     * forwardX/Z  — направление взгляда (fallback если игрок стоит).
     * playerRealVx/Vz — реальное движение игрока в этом кадре.
     */
    applyPushFromPlayer(playerX, playerY, playerZ, playerHW, playerHH, playerHD,
                        playerVx = 0, playerVz = 0, forwardX = 0, forwardZ = 0,
                        playerRealVx = 0, playerRealVz = 0) {
        const log = isLogEnabled();
        const desiredSpeed = Math.sqrt(playerVx * playerVx + playerVz * playerVz);
        const realSpeed = Math.sqrt(playerRealVx * playerRealVx + playerRealVz * playerRealVz);
        const fwdLen = Math.sqrt(forwardX * forwardX + forwardZ * forwardZ);

        // Направление толчка — приоритет:
        //   1) "желаемое" движение от WASD (если игрок жмёт клавиши);
        //   2) реальное движение (если просто скользит);
        //   3) направление взгляда камеры.
        let nx = 0, nz = 0;
        let referenceSpeed = 0;       // эталон — скорость с которой идёт игрок
        if (desiredSpeed > 0.1) {
            nx = playerVx / desiredSpeed;
            nz = playerVz / desiredSpeed;
            referenceSpeed = Math.max(desiredSpeed, realSpeed);
        } else if (realSpeed > 0.1) {
            nx = playerRealVx / realSpeed;
            nz = playerRealVz / realSpeed;
            referenceSpeed = realSpeed;
        } else if (fwdLen > 0.01) {
            nx = forwardX / fwdLen;
            nz = forwardZ / fwdLen;
            referenceSpeed = 0;        // игрок стоит — толчок только за счёт boost
        } else {
            return;                     // совсем нет направления
        }

        // Если игрок не делает ничего (не движется и WASD не жмёт) — не толкаем.
        // Это убирает «случайные» push'ы при просто стоянии рядом.
        if (desiredSpeed < 0.1 && realSpeed < 0.1) {
            return;
        }

        for (const body of this.bodies.values()) {
            if (!body.canCollide) continue;
            // Зона касания шире AABB на TOUCH_PADDING — физика игрока всегда
            // останавливает его на границе, формального пересечения нет.
            const hw = body.halfW + playerHW + TOUCH_PADDING;
            const hh = body.halfH + playerHH + TOUCH_PADDING;
            const hd = body.halfD + playerHD + TOUCH_PADDING;
            const dx = body.x - playerX;
            const dy = body.y - playerY;
            const dz = body.z - playerZ;
            if (Math.abs(dx) >= hw || Math.abs(dy) >= hh || Math.abs(dz) >= hd) continue;

            // Толкаем только если объект ВПЕРЕДИ игрока (по направлению движения).
            // Скалярное произведение (dx, dz) и (nx, nz) должно быть положительным.
            const dot = dx * nx + dz * nz;
            if (dot < -0.05) continue;     // объект сзади — не толкаем

            // Помечаем — игрок касается тела в этом кадре (отключит трение)
            body._justPushed = true;

            // Целевая скорость объекта в направлении (nx, nz):
            //   target = (playerSpeed + boost) / mass
            // - mass=0.1 → target ≈ (5+8)/0.1 = 130 → улетает с MAX_PUSH_SPEED
            // - mass=1   → target = (5+8)/1  = 13 → катится быстрее игрока
            // - mass=10  → target = (5+8)/10 = 1.3 → еле ползёт, игрок упирается
            // - mass=100 → target ≈ 0.13 → почти неподвижен
            const invMass = 1 / Math.max(MIN_MASS, body.mass);
            const targetSpeed = (referenceSpeed + PUSH_BOOST_BASE) * invMass;

            // Текущая скорость объекта в направлении (nx, nz)
            const currentDirSpeed = body.vx * nx + body.vz * nz;

            // Поднимаем скорость по направлению до targetSpeed (не уменьшаем)
            if (currentDirSpeed < targetSpeed) {
                const delta = targetSpeed - currentDirSpeed;
                body.vx += nx * delta;
                body.vz += nz * delta;
                body._justPushed = true;     // отключает трение в этом кадре

                // Если объект высокий — толчок наверху даёт ему угловой импульс
                // (как пинаешь шкаф снизу — он наклоняется вперёд).
                // Игрок толкает примерно посередине объекта по высоте; если
                // верх объекта существенно выше игрока — это плечо для torque.
                const minHorz = Math.min(body.halfW, body.halfD);
                const ratio = body.halfH / Math.max(0.01, minHorz);
                if (ratio > TOPPLE_RATIO && body.onGround) {
                    // Чем тяжелее — тем меньше угловой импульс.
                    // Применяется на каждый кадр касания — постепенно раскачивает.
                    const angKick = TOPPLE_KICK_FROM_PUSH / Math.max(MIN_MASS, body.mass);
                    // Толкаем только по ОДНОЙ оси (большей по направлению push'а),
                    // чтобы стена падала плашмя, а не крутилась диагонально.
                    if (Math.abs(nx) >= Math.abs(nz)) {
                        body.omegaZ += -Math.sign(nx) * angKick * 0.5;
                        if (!body._toppleAxis) body._toppleAxis = 'Z';
                    } else {
                        body.omegaX +=  Math.sign(nz) * angKick * 0.5;
                        if (!body._toppleAxis) body._toppleAxis = 'X';
                    }
                }

                // Ограничение максимальной горизонтальной скорости
                const sp = Math.sqrt(body.vx * body.vx + body.vz * body.vz);
                if (sp > MAX_PUSH_SPEED) {
                    body.vx *= MAX_PUSH_SPEED / sp;
                    body.vz *= MAX_PUSH_SPEED / sp;
                }

                // Подскок (только при первом касании когда объект почти стоял)
                if (currentDirSpeed < 0.5) {
                    const lift = PUSH_LIFT * invMass;
                    if (body.vy < lift) body.vy = lift;
                }

                if (log) {
                    // eslint-disable-next-line no-console
                    console.log('[Dynamics] PUSH', body.kind,
                        'mass=' + body.mass.toFixed(2),
                        'ref=' + referenceSpeed.toFixed(2),
                        'target=' + targetSpeed.toFixed(2),
                        'cur=' + currentDirSpeed.toFixed(2),
                        'newV=[' + body.vx.toFixed(2) + ',' + body.vz.toFixed(2) + ']');
                }
            }
        }
    }

    /** Получить все unanchored bodies (для debug). */
    getBodies() {
        return Array.from(this.bodies.values());
    }

    dispose() {
        this.bodies.clear();
        this._enabled = false;
    }
}
