/**
 * ModelThumbnails — рендер маленького превью каждой модели через offscreen
 * Babylon-engine. Кеш в localStorage.
 *
 * Использование:
 *   import { getModelThumbnail } from './ModelThumbnails';
 *   const url = await getModelThumbnail('food-apple');
 *   // url — data:image/png;base64,...
 */

import {
    Engine, Scene, ArcRotateCamera, HemisphericLight, DirectionalLight,
    SceneLoader, Vector3, Color3, Color4, VertexBuffer, TransformNode, Quaternion,
} from '@babylonjs/core';
import { getModelType } from './ModelTypes';

// v20 — инвалидируем кеш после исправления абсолютных URL в opensource-студии
// (старый IS_DEV=false слал /kubikon-assets/* на minecraftia → HTML вместо GLB).
const CACHE_PREFIX = 'kubikonThumb:v20:';

// Удаляем устаревшие версии превью при первой инициализации модуля
try {
    const stale = [];
    for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (k && /^kubikonThumb:v\d+:/.test(k) && !k.startsWith(CACHE_PREFIX)) {
            stale.push(k);
        }
    }
    stale.forEach(k => localStorage.removeItem(k));
} catch (e) { /* ignore */ }
const SIZE = 128;          // px стороны квадратной картинки
const QUALITY = 0.6;       // JPEG quality (меньше = быстрее toDataURL и меньше размер кеша)

// Лимит чтобы не упереться в localStorage 5MB.
// 128×128 jpeg ~5KB → 1000 моделей ~5MB. Контролируем количество записей.
const MAX_CACHE_ENTRIES = 800;

let _engine = null;
let _scene = null;
let _camera = null;
let _canvas = null;
let _logShown = false;

function ensureEngine() {
    if (_engine) return;
    _canvas = document.createElement('canvas');
    _canvas.width = SIZE;
    _canvas.height = SIZE;
    _engine = new Engine(_canvas, true, { preserveDrawingBuffer: true, alpha: true });
    _scene = new Scene(_engine);
    // Белый фон превью — миниатюры пишутся в JPEG (не поддерживает прозрачность),
    // прозрачный clearColor превращался в чёрный при экспорте. Белый фон
    // делает все модели читаемыми и единообразными. 2026-05-27.
    _scene.clearColor = new Color4(1, 1, 1, 1);
    // Камера — изометрия 3/4 (как в Roblox toolbox)
    _camera = new ArcRotateCamera('cam', Math.PI / 4, Math.PI / 3, 5,
        new Vector3(0, 0.5, 0), _scene);
    if (_camera) _camera._kubikonThumbKeep = true;
    _camera.minZ = 0.01;
    _camera.maxZ = 1000;
    _camera.fov = 0.7;
    // Свет — мощный hemi сверху + два direction'а, чтобы не было чёрных моделей
    const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), _scene);
    hemi.intensity = 1.0;
    hemi.groundColor = new Color3(0.6, 0.6, 0.7);
    const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), _scene);
    sun.intensity = 0.7;
    sun.diffuse = new Color3(1, 0.95, 0.85);
    const fill = new DirectionalLight('fill', new Vector3(0.5, -0.3, 0.5), _scene);
    fill.intensity = 0.4;
    fill.diffuse = new Color3(0.85, 0.9, 1);
}

/** Подобрать камеру по видимому AABB модели. */
function frameToBounds(meshes, modelType) {
    let aabb = visibleAABB(meshes, true);
    if (!aabb) aabb = visibleAABB(meshes, false);   // fallback: все меши с вершинами
    if (!aabb) {
        _camera.target = new Vector3(0, 0.5, 0);
        _camera.radius = 5;
        _camera.alpha = Math.PI / 4;
        _camera.beta = Math.PI / 3.2;
        return;
    }
    const { minX, minY, minZ, maxX, maxY, maxZ } = aabb;
    const cx = (minX + maxX) / 2;
    const cy = (minY + maxY) / 2;
    const cz = (minZ + maxZ) / 2;
    const sx = maxX - minX, sy = maxY - minY, sz = maxZ - minZ;
    const horzMax = Math.max(sx, sz);
    const verticality = sy / Math.max(0.01, horzMax);

    // Beta: 0 = сверху, π/2 = горизонталь.
    let beta;
    if (verticality > 1.5)      beta = (78 * Math.PI) / 180;
    else if (verticality < 0.4) beta = (42 * Math.PI) / 180;
    else                         beta = (62 * Math.PI) / 180;

    // Override через window для отладки
    if (typeof window !== 'undefined' && window.__kubikonCharBeta != null && cat === 'Персонажи') {
        beta = window.__kubikonCharBeta;
    }

    // Alpha (yaw) — выбираем так, чтобы видеть «длинную» сторону объекта
    // и лицевую часть (для персонажей).
    // Для прямоугольных объектов (sx >> sz или sz >> sx) ставим камеру так,
    // чтобы смотреть на длинную сторону под небольшим углом.
    let alpha;
    const cat = modelType?.category;
    if (cat === 'Персонажи') {
        // Стандартный 3/4 ракурс. Сама модель повёрнута через wrapper.
        alpha = Math.PI / 4;
    } else if (sx > sz * 1.5) {
        // Объект вытянут по X — смотрим со стороны Z (лицевая длинная сторона видна).
        alpha = Math.PI / 2 + Math.PI / 8;
    } else if (sz > sx * 1.5) {
        // Объект вытянут по Z — смотрим со стороны X.
        alpha = Math.PI / 8;
    } else {
        // Квадратные / симметричные — стандартный 3/4
        alpha = Math.PI / 4;
    }
    _camera.alpha = alpha;
    _camera.beta = beta;

    // Радиус. ArcRotateCamera смотрит с расстояния radius на target.
    // Diagonal по всем 3 осям — гарантированный размер шара охватывающего модель.
    const diag = Math.sqrt(sx * sx + sy * sy + sz * sz);
    const fov = _camera.fov;
    // Чтобы шар диаметра diag вписывался в FOV: radius = (diag/2) / sin(fov/2)
    const fitR = (diag / 2) / Math.sin(fov / 2);
    _camera.target = new Vector3(cx, cy, cz);
    _camera.radius = Math.max(1.5, fitR * 1.25);   // +25% воздуха
}

/**
 * Считает реальный bbox по позициям вершин в МИРОВЫХ координатах.
 * Это даёт точный размер геометрии, игнорируя skeleton bones и helper-аксессоры
 * которые ломают boundingInfo у Kenney character-моделей.
 */
function visibleAABB(meshes, strict = true) {
    let minX = Infinity, minY = Infinity, minZ = Infinity;
    let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
    let found = false;
    for (const m of meshes) {
        if (!m || typeof m.getTotalVertices !== 'function') continue;
        if (m.getTotalVertices() <= 0) continue;
        if (strict) {
            if (m.isEnabled && !m.isEnabled()) continue;
            if (m.isVisible === false) continue;
        }
        if (m.computeWorldMatrix) m.computeWorldMatrix(true);

        // Получаем позиции вершин в локальных координатах
        let positions;
        try {
            positions = m.getVerticesData(VertexBuffer.PositionKind);
        } catch (e) { continue; }
        if (!positions || positions.length === 0) continue;

        // Получаем мировую матрицу меша
        let wm;
        try { wm = m.getWorldMatrix(); } catch (e) { continue; }
        if (!wm) continue;

        // Применяем матрицу к каждой позиции
        const tmpV = new Vector3();
        for (let i = 0; i < positions.length; i += 3) {
            tmpV.set(positions[i], positions[i + 1], positions[i + 2]);
            const w = Vector3.TransformCoordinates(tmpV, wm);
            if (w.x < minX) minX = w.x;
            if (w.y < minY) minY = w.y;
            if (w.z < minZ) minZ = w.z;
            if (w.x > maxX) maxX = w.x;
            if (w.y > maxY) maxY = w.y;
            if (w.z > maxZ) maxZ = w.z;
            found = true;
        }
    }
    if (!found) return null;
    return { minX, minY, minZ, maxX, maxY, maxZ };
}

async function renderOne(modelType) {
    ensureEngine();
    // Убеждаемся что в сцене нет мусора от предыдущих рендеров.
    // Удаляем все mesh кроме гизмо/камеры/освещения.
    for (const m of [..._scene.meshes]) {
        if (m && !m._kubikonThumbKeep) {
            try { m.dispose(); } catch (e) { /* ignore */ }
        }
    }
    for (const tn of [..._scene.transformNodes]) {
        if (tn && !tn._kubikonThumbKeep) {
            try { tn.dispose(); } catch (e) { /* ignore */ }
        }
    }

    const lastSlash = modelType.file.lastIndexOf('/');
    const rootUrl = modelType.file.substring(0, lastSlash + 1);
    const filename = modelType.file.substring(lastSlash + 1);
    const container = await SceneLoader.LoadAssetContainerAsync(rootUrl, filename, _scene);
    container.addAllToScene();

    // Оборачиваем все root-узлы в TransformNode-обёртку — её можно безопасно
    // поворачивать (анимации модели затрагивают вложенные узлы, но не обёртку).
    // ВАЖНО: glTF-импортёр Babylon добавляет __root__ ноду с rotationQuaternion,
    // которая «отзеркаливает» сцену из right-handed glTF в left-handed Babylon.
    // Этот quaternion имеет приоритет над Эйлером, поэтому wrapper.rotation
    // не работает как ожидалось. Сбрасываем rotationQuaternion на root-нодах,
    // обнуляем их собственный поворот, и только после этого reparent в wrapper.
    const wrapper = new TransformNode('thumbWrapper', _scene);
    for (const r of container.rootNodes) {
        try {
            if (r.rotationQuaternion) {
                r.rotationQuaternion = null;
            }
            if (r.rotation && r.rotation.set) {
                r.rotation.set(0, 0, 0);
            }
            if (r.computeWorldMatrix) r.computeWorldMatrix(true);
            r.parent = wrapper;
        } catch (e) { /* ignore */ }
    }

    // Применяем targetHeight используя "видимый" bbox (без helper-узлов)
    if (modelType.targetHeight && modelType.targetHeight > 0) {
        for (const r of container.rootNodes) {
            if (r.scaling) {
                r.scaling.set(1, 1, 1);
                if (r.computeWorldMatrix) r.computeWorldMatrix(true);
            }
        }
        let aabb = visibleAABB(container.meshes, true);
        if (!aabb) aabb = visibleAABB(container.meshes, false);
        if (aabb) {
            const realH = aabb.maxY - aabb.minY;
            if (realH > 0.001) {
                const sc = modelType.targetHeight / realH;
                for (const r of container.rootNodes) {
                    if (r.scaling) r.scaling.set(sc, sc, sc);
                }
            }
        }
    }

    // Для персонажей — после сброса glTF-импортёра модель смотрит лицом в -Z.
    // Камера на alpha=π/4 находится в направлении +X+Z от target, смотрит в -X-Z.
    // Чтобы лицо встало к камере, разворачиваем модель на yaw = π/4 (равно alpha).
    // Через rotationQuaternion — он имеет приоритет над Эйлером.
    if (modelType.category === 'Персонажи') {
        const yaw = Math.PI / 4;
        const cy = Math.cos(yaw / 2), sy = Math.sin(yaw / 2);
        wrapper.rotationQuaternion = new Quaternion(0, sy, 0, cy);
        wrapper.rotation.set(0, 0, 0);
        wrapper.computeWorldMatrix(true);
    }

    // Если у модели есть анимации (idle и т.п.), запускаем первую и проигрываем
    // несколько кадров. У Kenney mini-characters в T-pose AABB раздут — после
    // применения skinning размеры становятся правильными.
    if (container.animationGroups && container.animationGroups.length > 0) {
        const ag = container.animationGroups[0];
        try {
            ag.start(true);
            _scene.render();
            await new Promise(res => requestAnimationFrame(res));
            _scene.render();
        } catch (e) { /* ignore */ }
    }
    // Обновляем bbox мешей с учётом скелетной анимации
    for (const m of container.meshes) {
        if (m.refreshBoundingInfo) {
            try { m.refreshBoundingInfo({ applySkeleton: true, applyMorph: true }); }
            catch (e) {
                try { m.refreshBoundingInfo(true); } catch (e2) { /* ignore */ }
            }
        }
    }

    frameToBounds(container.meshes, modelType);
    _scene.render();
    await new Promise(res => requestAnimationFrame(res));
    _scene.render();

    let isEmpty = _canvasIsEmpty();
    // Если результат пустой — пробуем ещё раз с задержкой (текстуры могли не загрузиться)
    if (isEmpty) {
        await new Promise(res => setTimeout(res, 150));
        _scene.render();
        await new Promise(res => requestAnimationFrame(res));
        _scene.render();
        isEmpty = _canvasIsEmpty();
    }

    const dataUrl = _canvas.toDataURL('image/jpeg', QUALITY);
    container.removeAllFromScene();
    container.dispose();
    return { dataUrl, isEmpty };
}

/**
 * Проверка через копию canvas в 2D — есть ли не-фоновые пиксели в центре.
 * Это даёт нам понять, действительно ли модель попала в кадр.
 */
let _checkCanvas = null;
let _checkCtx = null;
function _canvasIsEmpty() {
    try {
        if (!_checkCanvas) {
            _checkCanvas = document.createElement('canvas');
            _checkCanvas.width = 64;
            _checkCanvas.height = 64;
            _checkCtx = _checkCanvas.getContext('2d');
        }
        // Копируем центр 64×64 webgl-канваса в 2d
        _checkCtx.clearRect(0, 0, 64, 64);
        _checkCtx.drawImage(
            _canvas,
            (SIZE - 64) / 2, (SIZE - 64) / 2, 64, 64,   // src
            0, 0, 64, 64                                  // dst
        );
        const data = _checkCtx.getImageData(0, 0, 64, 64).data;
        // Бэкграунд: 0.92*255=235, 0.94*255=240, 0.97*255=247
        let nonBg = 0;
        for (let i = 0; i < data.length; i += 4) {
            const r = data[i], g = data[i + 1], b = data[i + 2];
            if (Math.abs(r - 235) > 20 || Math.abs(g - 240) > 20 || Math.abs(b - 247) > 20) {
                nonBg++;
            }
        }
        return nonBg < (64 * 64 * 0.03);
    } catch (e) {
        return false;
    }
}

const _inflight = new Map();         // id → { promise, cancelled }

// LIFO-стек задач. Последняя добавленная — первая выполняется (актуальные
// видимые карточки получают превью раньше, чем те что юзер пролистал).
// Каждая задача имеет флаг cancelled — если карточка ушла из viewport до того
// как рендер начался, мы её пропускаем без работы.
const _stack = [];
let _processing = false;

function _enqueue(modelId, task) {
    const item = { modelId, task, cancelled: false, resolve: null, reject: null };
    const promise = new Promise((res, rej) => {
        item.resolve = res;
        item.reject = rej;
    });
    item.promise = promise;
    _stack.push(item);
    _inflight.set(modelId, item);
    _kickProcessor();
    return promise;
}

/** Отменить задачу если она ещё не начала выполняться. */
export function cancelThumbnailRequest(modelId) {
    const item = _inflight.get(modelId);
    if (!item) return;
    item.cancelled = true;
    _inflight.delete(modelId);
}

async function _kickProcessor() {
    if (_processing) return;
    _processing = true;
    try {
        while (_stack.length) {
            // LIFO — берём последнюю добавленную задачу
            const item = _stack.pop();
            if (!item || item.cancelled) {
                if (item) item.resolve(null);
                continue;
            }
            try {
                const result = await item.task();
                item.resolve(result);
            } catch (e) {
                item.reject(e);
            } finally {
                _inflight.delete(item.modelId);
            }
            // Микропауза чтобы UI не блокировался
            await new Promise(res => setTimeout(res, 0));
        }
    } finally {
        _processing = false;
    }
}

/**
 * Получить превью модели как dataURL (jpeg).
 * Только lazy — никаких prefetch'ей. Каждый карточка-компонент сам зовёт
 * это когда попадает в viewport, и cancelThumbnailRequest при выходе.
 */
export async function getModelThumbnail(modelId) {
    const key = CACHE_PREFIX + modelId;
    try {
        const cached = localStorage.getItem(key);
        if (cached) return cached;
    } catch (e) { /* ignore */ }

    // Если уже в очереди — возвращаем тот же промис.
    const existing = _inflight.get(modelId);
    if (existing) {
        existing.cancelled = false;     // пере-активируем если ранее отменяли
        return existing.promise;
    }

    const modelType = getModelType(modelId);
    if (!modelType || !modelType.file) return null;

    return _enqueue(modelId, async () => {
        try {
            const result = await renderOne(modelType);
            // Если рендер вернул пустой превью — не кешируем, дадим возможность
            // повторить попытку при следующем запросе.
            if (!result || result.isEmpty) return null;
            const dataUrl = result.dataUrl;
            try {
                _trimCacheIfNeeded();
                localStorage.setItem(key, dataUrl);
            } catch (e) {
                _trimCache();
                try { localStorage.setItem(key, dataUrl); } catch (e2) { /* ignore */ }
            }
            return dataUrl;
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[Thumb] render failed:', modelId, e?.message);
            return null;
        }
    });
}

function _trimCacheIfNeeded() {
    let count = 0;
    for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (k && k.startsWith(CACHE_PREFIX)) count++;
    }
    if (count >= MAX_CACHE_ENTRIES) _trimCache();
}

function _trimCache() {
    // Удаляем половину наших ключей (FIFO порядок не гарантирован, но это
    // приблизительно работает).
    const keys = [];
    for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (k && k.startsWith(CACHE_PREFIX)) keys.push(k);
    }
    const toRemove = keys.slice(0, Math.floor(keys.length / 2));
    for (const k of toRemove) {
        try { localStorage.removeItem(k); } catch (e) { /* ignore */ }
    }
}

/** Очистить весь кеш превью (для отладки). Вызывать из консоли при необходимости. */
export function clearAllThumbnails() {
    const keys = [];
    for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (k && k.startsWith(CACHE_PREFIX)) keys.push(k);
    }
    keys.forEach(k => localStorage.removeItem(k));
    return keys.length;
}
