/**
 * StudioCollab — клиент совместного редактирования (Team Create) для студии.
 *
 * Подключается к Colyseus-комнате `studio` (одна на projectId) и:
 *   1. ОТПРАВЛЯЕТ операции локального юзера (add/move/delete/setColor/...) —
 *      вызывается из обёрток в KubikonEditor/менеджерах через collab.sendOp(...).
 *   2. ПРИНИМАЕТ операции других соавторов и применяет их к сцене
 *      (через applyRemoteOp → менеджеры BabylonScene), с флагом _fromRemote,
 *      чтобы не отправить их обратно (анти-эхо).
 *   3. PRESENCE: рисует курсоры/выделение/онлайн-список соавторов
 *      (через колбэки onPresence — UI-слой подписывается).
 *   4. SOFT-LOCK: select/deselect объекта; залоченный другим объект не дать
 *      двигать (onLockRejected колбэк для UI-уведомления).
 *
 * Транспорт переиспользует существующий kubikon-realtime (Colyseus 0.16).
 * В Colyseus 0.16 state-колбэки идут через getStateCallbacks(room) (см.
 * feedback_colyseus_016_api).
 */
import { Client, getStateCallbacks } from 'colyseus.js';
import { REALTIME_WS } from '../../api/API';

export class StudioCollab {
    /**
     * @param {object} scene  — BabylonScene (со всеми менеджерами)
     * @param {object} opts   — { projectId, token, collabToken?, callbacks }
     *   callbacks: {
     *     onConnected({sessionId,color,isHost}), onError(msg), onLeft(),
     *     onPresenceChange(list), onOpRejected({key,by}),
     *     onSnapshotRequest(replyFn), onRemoteSnapshot(state), onChat(msg),
     *   }
     */
    constructor(scene, opts = {}) {
        this.scene = scene;
        this.projectId = opts.projectId;
        this.token = opts.token;
        this.collabToken = opts.collabToken || null;
        this.cb = opts.callbacks || {};
        this.room = null;
        this.client = null;
        this.sessionId = null;
        this.color = '#5fd0ff';
        this.isHost = false;
        this.connected = false;
        this._applyingRemote = false;   // флаг анти-эхо
        this._cursorThrottle = 0;
        this._camThrottle = 0;
        this._destroyed = false;
    }

    /** true если сейчас применяем удалённую операцию (не слать обратно). */
    get applyingRemote() { return this._applyingRemote; }

    /**
     * Обернуть методы менеджеров, чтобы ЛЮБОЕ изменение сцены (из любого
     * UI-пути) транслировалось соавторам. Анти-эхо: при _applyingRemote
     * (мы применяем чужую операцию) обёртки молчат.
     *
     * Перехватываем минимальный набор для MVP:
     *   PrimitiveManager: addInstance, updateInstance, removeInstance
     *   ModelManager:     addInstance, removeInstance
     */
    installInterceptors() {
        const self = this;
        const pm = this.scene.primitiveManager;
        const mm = this.scene.modelManager;
        if (pm && !pm.__collabPatched) {
            pm.__collabPatched = true;
            const origAdd = pm.addInstance.bind(pm);
            pm.addInstance = function (type, opts = {}) {
                const id = origAdd(type, opts);
                if (id != null && !self._applyingRemote) {
                    // Шлём полный снимок объекта (с id), чтобы у соавтора создался
                    // ТОТ ЖЕ instanceId — иначе move-операции не совпадут.
                    const data = pm.instances?.get?.(id);
                    const def = serializePrimitive(data, id, type);
                    self.sendOp({ op: 'add', key: 'primitive:' + id, def });
                }
                return id;
            };
            const origUpd = pm.updateInstance.bind(pm);
            pm.updateInstance = function (id, patch) {
                const r = origUpd(id, patch);
                if (!self._applyingRemote && patch && typeof patch === 'object') {
                    self.sendOp(patchToOp('primitive:' + id, patch));
                }
                return r;
            };
            const origRem = pm.removeInstance.bind(pm);
            pm.removeInstance = function (id) {
                const r = origRem(id);
                if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'primitive:' + id });
                return r;
            };
        }
        // BlockManager — синхрон блоков (addBlock/removeBlock).
        const bm = this.scene.blockManager;
        if (bm && !bm.__collabPatched) {
            bm.__collabPatched = true;
            if (typeof bm.addBlock === 'function') {
                const origAddB = bm.addBlock.bind(bm);
                bm.addBlock = function (x, y, z, blockTypeId, color) {
                    const r = origAddB(x, y, z, blockTypeId, color);
                    if (!self._applyingRemote) {
                        self.sendOp({ op: 'blockAdd', x, y, z, blockTypeId, color });
                    }
                    return r;
                };
            }
            if (typeof bm.removeBlock === 'function') {
                const origRemB = bm.removeBlock.bind(bm);
                bm.removeBlock = function (x, y, z) {
                    const r = origRemB(x, y, z);
                    if (!self._applyingRemote) self.sendOp({ op: 'blockRemove', x, y, z });
                    return r;
                };
            }
        }

        // SelectionManager — берём/снимаем soft-lock при выделении.
        const sm = this.scene.selectionManager || this.scene.selection;
        if (sm && !sm.__collabPatched) {
            sm.__collabPatched = true;
            const sendSel = () => {
                if (self._applyingRemote) return;
                const s = sm._selection;
                if (s && s.type && s.id != null) {
                    self.selectObject(s.type + ':' + s.id);
                } else {
                    self.deselectObject();
                }
            };
            ['selectByMesh', 'selectPrimitiveById'].forEach((fn) => {
                if (typeof sm[fn] === 'function') {
                    const orig = sm[fn].bind(sm);
                    sm[fn] = function (...a) { const r = orig(...a); sendSel(); return r; };
                }
            });
            if (typeof sm.clear === 'function') {
                const origClear = sm.clear.bind(sm);
                sm.clear = function (...a) { const r = origClear(...a); if (!self._applyingRemote) self.deselectObject(); return r; };
            }
        }
        if (mm && !mm.__collabPatched) {
            mm.__collabPatched = true;
            const origAddM = mm.addInstance.bind(mm);
            mm.addInstance = function (modelTypeId, x, y, z, rotationY = 0) {
                const ret = origAddM(modelTypeId, x, y, z, rotationY);
                // addInstance модели async → ret это Promise<id>.
                Promise.resolve(ret).then((id) => {
                    if (id != null && !self._applyingRemote) {
                        self.sendOp({ op: 'add', key: 'model:' + id,
                            def: { __kind: 'model', modelTypeId, x, y, z, rotationY } });
                    }
                });
                return ret;
            };
            const origRemM = mm.removeInstance.bind(mm);
            mm.removeInstance = function (id) {
                const r = origRemM(id);
                if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'model:' + id });
                return r;
            };
        }

        // Скрипты — синхрон создания/редактирования (upsertScript) и удаления
        // (removeScript). Скрипты живут в scene._scripts[] ({id, code, target,
        // name}); UI зовёт scene.upsertScript / scene.removeScript.
        const sc = this.scene;
        if (sc && !sc.__collabScriptsPatched) {
            sc.__collabScriptsPatched = true;
            if (typeof sc.upsertScript === 'function') {
                const origUpsert = sc.upsertScript.bind(sc);
                sc.upsertScript = function (id, code, target, name, language) {
                    const r = origUpsert(id, code, target, name, language);
                    if (!self._applyingRemote) {
                        // id может быть сгенерён внутри upsertScript, если был null —
                        // достаём фактический из _scripts (последний с этим code).
                        let realId = id;
                        if (realId == null) {
                            const arr = sc._scripts || [];
                            realId = arr.length ? arr[arr.length - 1].id : null;
                        }
                        const rec = (sc._scripts || []).find((s) => s.id === realId);
                        if (rec) {
                            self.sendOp({
                                op: 'scriptUpsert',
                                id: rec.id,
                                code: rec.code,
                                target: rec.target ?? null,
                                name: rec.name ?? null,
                                language: rec.language ?? 'js',
                            });
                        }
                    }
                    return r;
                };
            }
            if (typeof sc.removeScript === 'function') {
                const origRemoveScript = sc.removeScript.bind(sc);
                sc.removeScript = function (id) {
                    const r = origRemoveScript(id);
                    if (!self._applyingRemote) self.sendOp({ op: 'scriptRemove', id });
                    return r;
                };
            }
        }
    }

    /** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
    uninstallInterceptors() {
        // Оставляем обёртки, но они станут no-op после dispose (connected=false).
        // Полный un-patch не нужен: sendOp молчит при !connected.
    }

    async connect() {
        this.client = new Client(REALTIME_WS);
        const joinOpts = {
            projectId: this.projectId,
            token: this.token,
        };
        if (this.collabToken) joinOpts.collabToken = this.collabToken;
        this.room = await this.client.joinOrCreate('studio', joinOpts);
        this.connected = true;
        this.sessionId = this.room.sessionId;

        const room = this.room;
        // --- сервер сообщил наши параметры ---
        room.onMessage('joined', (m) => {
            this.sessionId = m.sessionId;
            this.color = m.color;
            this.isHost = !!m.isHost;
            this.cb.onConnected?.({ sessionId: m.sessionId, color: m.color, isHost: !!m.isHost, role: m.role });
            // Новый соавтор грузит сцену из БД сам (как при обычном открытии
            // проекта), дальше догоняет live-операции. Снапшот через комнату НЕ
            // шлём — большая сцена превышает лимит payload Colyseus (RangeError).
        });

        // --- входящая операция от другого соавтора ---
        room.onMessage('op', (m) => {
            if (!m || m.from === this.sessionId) return;
            this._applyRemoteOp(m.op);
        });

        // --- наша операция отклонена (объект залочен) ---
        room.onMessage('op-rejected', (m) => { this.cb.onOpRejected?.(m); });
        room.onMessage('select-denied', (m) => { this.cb.onOpRejected?.(m); });

        // --- чат соавторов ---
        room.onMessage('chat', (m) => { this.cb.onChat?.(m); });

        // --- presence через state ---
        this._wirePresence();

        room.onLeave(() => { this.connected = false; this.cb.onLeft?.(); });
        room.onError((code, msg) => { this.cb.onError?.(msg || ('Ошибка ' + code)); });

        return this.room;
    }

    _wirePresence() {
        const $ = getStateCallbacks(this.room);
        const state = this.room.state;
        const emit = () => {
            if (this._destroyed) return;
            const list = [];
            const col = state && state.collaborators;
            if (col && typeof col.forEach === 'function') {
                col.forEach((c, sid) => {
                    list.push({
                        sessionId: sid,
                        me: sid === this.sessionId,
                        username: c.username,
                        color: c.color,
                        cursor: { x: c.cursorX, y: c.cursorY, z: c.cursorZ },
                        cam: { x: c.camX, y: c.camY, z: c.camZ },
                        selectedKey: c.selectedKey,
                        isHost: sid === state.hostSessionId,
                    });
                });
            }
            this.cb.onPresenceChange?.(list);
        };
        // Подписки оборачиваем в try — в Colyseus 0.16 API колбэков для
        // MapSchema(string) может отличаться от MapSchema(Schema); не валим коннект.
        try {
            $(state).collaborators.onAdd((c, _sid) => {
                try { $(c).onChange(() => emit()); } catch (e) { /* ignore */ }
                emit();
            });
            $(state).collaborators.onRemove(() => emit());
        } catch (e) { /* ignore */ }
        try {
            $(state).locks.onAdd(() => emit());
            $(state).locks.onRemove(() => emit());
        } catch (e) { /* ignore */ }
        emit();
    }

    // ============ ИСХОДЯЩИЕ ============

    /** Отправить операцию (если подключены и это не эхо удалённой).
     *  Для непрерывных op (move/rotate/scale) — throttle ~30/сек по ключу,
     *  чтобы драг гизмо (60fps) не залил комнату. add/delete/setColor — сразу. */
    sendOp(op) {
        if (!this.connected || this._applyingRemote) return;
        const cont = op && (op.op === 'move' || op.op === 'rotate' || op.op === 'scale');
        if (cont) {
            const now = Date.now();
            if (!this._opThrottle) this._opThrottle = {};
            const tk = op.op + ':' + op.key;
            if (now - (this._opThrottle[tk] || 0) < 33) {
                // запомним последнее значение, дошлём в _flushPending через rAF
                this._pendingOps = this._pendingOps || {};
                this._pendingOps[tk] = op;
                this._scheduleFlush();
                return;
            }
            this._opThrottle[tk] = now;
        }
        try { this.room.send('op', op); } catch (e) { /* ignore */ }
    }

    /** Догнать «зажатые» throttle-операции (последнее значение драга — важно
     *  чтобы финальная позиция точно дошла, а не оборвалась на середине). */
    _scheduleFlush() {
        if (this._flushScheduled) return;
        this._flushScheduled = true;
        const run = () => {
            this._flushScheduled = false;
            const pend = this._pendingOps; this._pendingOps = null;
            if (!pend || !this.connected) return;
            const now = Date.now();
            for (const tk in pend) {
                const op = pend[tk];
                this._opThrottle[tk] = now;
                try { this.room.send('op', op); } catch (e) { /* ignore */ }
            }
        };
        if (typeof requestAnimationFrame === 'function') setTimeout(run, 40);
        else setTimeout(run, 40);
    }

    /** Курсор-точка под мышью (throttle ~20/сек). */
    sendCursor(x, y, z) {
        if (!this.connected) return;
        const now = Date.now();
        if (now - this._cursorThrottle < 50) return;
        this._cursorThrottle = now;
        try { this.room.send('cursor', { x, y, z }); } catch (e) { /* ignore */ }
    }

    sendCamera(x, y, z) {
        if (!this.connected) return;
        const now = Date.now();
        if (now - this._camThrottle < 200) return;
        this._camThrottle = now;
        try { this.room.send('camera', { x, y, z }); } catch (e) { /* ignore */ }
    }

    /** Выделить объект (берёт soft-lock). key = 'primitive:42' / 'model:m3'. */
    selectObject(key) {
        if (!this.connected) return;
        try { this.room.send('select', { key: key || '' }); } catch (e) { /* ignore */ }
    }
    deselectObject() {
        if (!this.connected) return;
        try { this.room.send('deselect', {}); } catch (e) { /* ignore */ }
    }

    sendChat(text) {
        if (!this.connected) return;
        try { this.room.send('chat', { text }); } catch (e) { /* ignore */ }
    }

    // ============ ВХОДЯЩИЕ → СЦЕНА ============

    /**
     * Применить операцию другого соавтора к локальной сцене.
     * Оборачиваем во флаг _applyingRemote, чтобы обёртки менеджеров НЕ
     * отправили её обратно в комнату (анти-эхо).
     */
    _applyRemoteOp(op) {
        if (!op || typeof op !== 'object') return;
        this._applyingRemote = true;
        try {
            applyRemoteOp(this.scene, op);
        } catch (e) {
            // не валим соединение из-за одной кривой операции
            // eslint-disable-next-line no-console
            console.warn('[StudioCollab] applyRemoteOp failed:', op?.op, e);
        } finally {
            this._applyingRemote = false;
        }
    }

    dispose() {
        this._destroyed = true;
        this.connected = false;
        try { this.room?.leave(true); } catch (e) { /* ignore */ }
        this.room = null;
        this.client = null;
    }
}

/**
 * Применить удалённую операцию к сцене через менеджеры BabylonScene.
 * Каждый тип op маппится на метод менеджера. Если метода нет — тихо
 * пропускаем (фича-парность движков: на этапе 1 поддерживаем базовый набор).
 *
 * Поддерживаемые операции (этап 1):
 *   add        { kind:'primitive'|'model', def }      — создать объект
 *   move       { key, x, y, z }                       — переместить
 *   rotate     { key, rx, ry, rz }                    — повернуть
 *   scale      { key, sx, sy, sz }                    — масштаб
 *   setColor   { key, color }                         — цвет
 *   setProp    { key, prop, value }                   — произвольное свойство
 *   delete     { key }                                — удалить
 */
export function applyRemoteOp(scene, op) {
    const t = op.op;
    const pm = scene.primitiveManager;
    const mm = scene.modelManager;
    const bm = scene.blockManager;
    const { kind, id } = parseKey(op.key);

    switch (t) {
        case 'blockAdd':
            bm?.addBlock?.(op.x, op.y, op.z, op.blockTypeId, op.color);
            return;
        case 'blockRemove':
            bm?.removeBlock?.(op.x, op.y, op.z);
            return;
        case 'scriptUpsert':
            // Создание/редактирование скрипта у соавтора. _applyingRemote уже
            // выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
            // эхо обратно. _onSceneChange внутри обновит React-панели.
            scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null, op.language ?? undefined);
            scene._onCollabScriptsChange?.();
            return;
        case 'scriptRemove':
            scene.removeScript?.(op.id);
            scene._onCollabScriptsChange?.();
            return;
    }
    switch (t) {
        case 'add': {
            if (op.def?.__kind === 'primitive' && pm?.addInstance) {
                // addInstance(type, opts) — opts со всеми полями (id восстановим по instanceId).
                pm.addInstance(op.def.type, op.def);
            } else if (op.def?.__kind === 'model' && mm?.addInstance) {
                // ModelManager.addInstance(modelTypeId, x, y, z, rotationY) — позиционно.
                const d = op.def;
                mm.addInstance(d.modelTypeId || d.type, d.x, d.y, d.z, d.rotationY || 0);
            }
            break;
        }
        case 'move': {
            if (kind === 'primitive') pm?.updateInstance?.(id, { x: op.x, y: op.y, z: op.z });
            else if (kind === 'model') _moveModel(mm, id, op.x, op.y, op.z);
            break;
        }
        case 'rotate': {
            if (kind === 'primitive') {
                pm?.updateInstance?.(id, { rotationX: op.rx, rotationY: op.ry, rotationZ: op.rz });
            } else if (kind === 'model') {
                _rotateModel(mm, id, op.ry);
            }
            break;
        }
        case 'scale': {
            if (kind === 'primitive') pm?.updateInstance?.(id, { sx: op.sx, sy: op.sy, sz: op.sz });
            break;
        }
        case 'setColor': {
            if (kind === 'primitive') pm?.updateInstance?.(id, { color: op.color });
            break;
        }
        case 'setProp': {
            if (kind === 'primitive') { const patch = {}; patch[op.prop] = op.value; pm?.updateInstance?.(id, patch); }
            break;
        }
        case 'delete': {
            if (kind === 'primitive') pm?.removeInstance?.(id);
            else if (kind === 'model') mm?.removeInstance?.(id);
            break;
        }
        default:
            // неизвестная операция — пропускаем (вперёд-совместимость)
            break;
    }
}

/** Переместить модель (нет updateInstance — двигаем rootMesh напрямую). */
function _moveModel(mm, id, x, y, z) {
    const inst = mm?.instances?.get?.(id);
    if (inst?.rootMesh?.position) {
        inst.rootMesh.position.set(x, y, z);
        if (inst.data) { inst.data.x = x; inst.data.y = y; inst.data.z = z; }
        inst.x = x; inst.y = y; inst.z = z;
    }
}
/** Повернуть модель вокруг Y. */
function _rotateModel(mm, id, ry) {
    const inst = mm?.instances?.get?.(id);
    if (inst?.rootMesh && typeof ry === 'number') {
        inst.rootMesh.rotation = inst.rootMesh.rotation || { x: 0, y: 0, z: 0 };
        inst.rootMesh.rotation.y = ry;
        if (inst.data) inst.data.rotationY = ry;
        inst.rotationY = ry;
    }
}

/** Снимок примитива для операции 'add' (с instanceId, чтобы id совпал у всех). */
function serializePrimitive(data, id, type) {
    if (!data) return { __kind: 'primitive', type, instanceId: id, id };
    return {
        __kind: 'primitive',
        type: data.type || type,
        id, instanceId: id,
        x: data.x, y: data.y, z: data.z,
        sx: data.sx, sy: data.sy, sz: data.sz,
        rotationX: data.rotationX, rotationY: data.rotationY, rotationZ: data.rotationZ,
        color: data.color, material: data.material,
        name: data.name, anchored: data.anchored, canCollide: data.canCollide,
        visible: data.visible, mass: data.mass, studDensity: data.studDensity,
    };
}

/** Превратить patch updateInstance в конкретную операцию (move/rotate/scale/setColor/setProp). */
function patchToOp(key, patch) {
    if ('x' in patch || 'y' in patch || 'z' in patch) {
        return { op: 'move', key, x: patch.x, y: patch.y, z: patch.z };
    }
    if ('rotationX' in patch || 'rotationY' in patch || 'rotationZ' in patch) {
        return { op: 'rotate', key, rx: patch.rotationX, ry: patch.rotationY, rz: patch.rotationZ };
    }
    if ('sx' in patch || 'sy' in patch || 'sz' in patch) {
        return { op: 'scale', key, sx: patch.sx, sy: patch.sy, sz: patch.sz };
    }
    if ('color' in patch) {
        return { op: 'setColor', key, color: patch.color };
    }
    // прочее — generic setProp (берём первый ключ)
    const k = Object.keys(patch)[0];
    return { op: 'setProp', key, prop: k, value: patch[k] };
}

/** 'primitive:42' → {kind:'primitive', id:42}; 'model:m3' → {kind:'model', id:'m3'}. */
function parseKey(key) {
    if (typeof key !== 'string' || !key.includes(':')) return { kind: '', id: null };
    const i = key.indexOf(':');
    const kind = key.slice(0, i);
    let id = key.slice(i + 1);
    if (kind === 'primitive') { const n = Number(id); if (Number.isFinite(n)) id = n; }
    return { kind, id };
}
