"use strict";

const fs   = require('fs');
const path = require('path');

function assertString(value, field){
    if (typeof value !== 'string' || !value.trim()){
        throw new Error(field + ' is required');
    }
}

// undefined пропускаємо — хост підставить дефолт. Нижні межі збігаються з clamp'ом
// рендерера (320/120); верхня тут абсолютна, рендерер додатково ріже вьюпортом.
function assertSize(value, field, min, max){
    if (value === undefined){
        return undefined;
    }

    if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max){
        throw new Error('invalid ' + field);
    }

    return value;
}

var SLOT_LAYOUTS = [ 'fill', 'scroll' ];

// Слот — або рядок-шлях (стара форма), або дескриптор подачі {file, layout, width, height}.
// Форму маніфесту НЕ переписуємо: нормалізують споживачі (validateEntries і рендерер),
// кожен локально — інакше клієнт зі старим mcPluginClient падав би на об'єкті.
function normalizeSlot(value, field){
    var slot = (typeof value === 'string') ? { file: value } : value;

    if (!slot || typeof slot !== 'object' || Array.isArray(slot)){
        throw new Error(field + ' must be a string or object');
    }

    assertString(slot.file, field + '.file');

    var layout = slot.layout === undefined ? 'fill' : slot.layout;

    if (SLOT_LAYOUTS.indexOf(layout) === -1){
        throw new Error('invalid ' + field + '.layout');
    }

    return {
        file  : slot.file,
        layout: layout,
        width : assertSize(slot.width,  field + '.width',  320, 4096),
        height: assertSize(slot.height, field + '.height', 120, 4096)
    };
}

var DIAG_LEVELS = [ 'error', 'warn', 'info', 'debug', 'trace' ];

// Опційна секція diagnostics: рівень (за аналогією з рівнями логера) + шлях до власного логу
// плагіна. Жорстко нічого не вимагаємо (зворотна сумісність) — лише типи наявних полів.
function validateDiagnostics(diagnostics){
    if (!diagnostics || typeof diagnostics !== 'object' || Array.isArray(diagnostics)){
        throw new Error('diagnostics must be an object');
    }

    if (diagnostics.level !== undefined && DIAG_LEVELS.indexOf(diagnostics.level) === -1){
        throw new Error('invalid diagnostics.level');
    }

    if (diagnostics.logPath !== undefined){
        assertString(diagnostics.logPath, 'diagnostics.logPath');

        if (diagnostics.logPath.indexOf('@data/') !== 0 || diagnostics.logPath.split(/[\\/]/).indexOf('..') !== -1){
            throw new Error('diagnostics.logPath must be "@data/<rel>" without ".."');
        }
    }
}

// Місця, куди плагін має право покласти кнопку. Невідомий ключ — помилка, а не
// мовчазне ігнорування: інакше друкарська помилка в назві анкера виглядала б для
// автора плагіна як «кнопка не з'явилась без причини».
var ANCHOR_KEYS = [ 'composerLeft', 'composerRight', 'toolbarButtons',
                    'topMenuItems', 'clipItems', 'menuItems', 'contextMenu' ];

function validateContributions(contributions){
    var seen = {};

    if (typeof contributions !== 'object' || Array.isArray(contributions)){
        throw new Error('contributions must be an object');
    }

    Object.keys(contributions).forEach(function(anchor){
        if (ANCHOR_KEYS.indexOf(anchor) === -1){
            throw new Error('unknown contribution anchor: ' + anchor);
        }

        if (!Array.isArray(contributions[anchor])){
            throw new Error('contributions.' + anchor + ' must be an array');
        }

        contributions[anchor].forEach(function(item){
            assertString(item && item.id, anchor + '[].id');
            assertString(item && item.action, anchor + '[].action');

            // Ключ реєстру в renderer — pluginId + ':' + id, без анкера. Тому id мусить
            // бути унікальним по ВСІХ анкерах плагіна: інакше другий внесок мовчки
            // не з'явиться, бо renderContributions побачить уже зайнятий ключ.
            if (seen[item.id]) throw new Error('duplicate contribution id: ' + item.id);
            seen[item.id] = true;
        });
    });
}

function validateManifest(manifest){
    if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)){
        throw new Error('manifest must be an object');
    }

    [ 'id', 'name', 'version', 'hostApi', 'kind' ].forEach(function(field){
        assertString(manifest[field], field);
    });

    if (!/^[a-z0-9-]+$/.test(manifest.id)){
        throw new Error('invalid manifest.id');
    }

    if (manifest.hostApi !== '2'){
        throw new Error('unsupported hostApi');
    }

    // Мінімальна версія клієнта. Чотиричастинна форма — бо реальна версія збірки це
    // version + buildNumber, і білд інкрементиться незалежно від calver.
    if (manifest.minHost !== undefined){
        assertString(manifest.minHost, 'minHost');

        if (!isVersion(manifest.minHost)){
            throw new Error('invalid minHost');
        }
    }

    // Плагін оголошує, що після install/update його код підхопиться лише з новим
    // процесом клієнта (нативний модуль, який node уже завантажив, preload тощо).
    // Хост нічого не перезапускає сам — лише передає прапорець у manager row.
    if (manifest.requiresReload !== undefined && typeof manifest.requiresReload !== 'boolean'){
        throw new Error('requiresReload must be a boolean');
    }

    if ([ 'ui', 'ui+bg' ].indexOf(manifest.kind) === -1){
        throw new Error('invalid manifest.kind');
    }

    // Масив — теж object, тому Array.isArray обов'язковий: без нього entry:[] і
    // panels:[] проходили б як «плагін без панелей» замість помилки.
    if (!manifest.entry || typeof manifest.entry !== 'object' || Array.isArray(manifest.entry)){
        throw new Error('entry is required');
    }

    if (manifest.entry.panels !== undefined &&
        (!manifest.entry.panels || typeof manifest.entry.panels !== 'object' || Array.isArray(manifest.entry.panels))){
        throw new Error('entry.panels must be an object');
    }

    if (manifest.kind === 'ui+bg'){
        assertString(manifest.entry.background, 'entry.background');
    }

    // Слоти лише ВАЛІДУЄМО — повернений маніфест зберігає вихідну форму.
    // entry.background не слот, його не чіпаємо.
    if (manifest.entry.settings !== undefined){
        normalizeSlot(manifest.entry.settings, 'entry.settings');
    }

    Object.keys(manifest.entry.panels || {}).forEach(function(name){
        normalizeSlot(manifest.entry.panels[name], 'entry.panels.' + name);
    });

    if (manifest.diagnostics !== undefined){
        validateDiagnostics(manifest.diagnostics);
    }

    if (manifest.contributions !== undefined){
        validateContributions(manifest.contributions);
    }

    return JSON.parse(JSON.stringify(manifest));
}

// Довжина компонента обмежена навмисно: на ~400 цифрах parseInt дає Infinity,
// Infinity - Infinity === NaN, і порівняння двох однакових версій повертало б 1.
var VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}(\.\d{1,6})?$/;

function isVersion(value){
    return typeof value === 'string' && VERSION_RE.test(value);
}

// Покомпонентно числами, не лексикографічно: 2026.10.1 > 2026.9.30.
// Відсутній компонент читається як 0, тож 2026.6.7 === 2026.6.7.0.
function compareVersions(a, b){
    var left  = String(a).split('.');
    var right = String(b).split('.');

    for (var i = 0; i < 4; i++){
        var diff = (parseInt(left[i], 10) || 0) - (parseInt(right[i], 10) || 0);

        if (diff !== 0){
            return diff < 0 ? -1 : 1;
        }
    }

    return 0;
}

function resolveEntry(codeDir, entry){
    assertString(entry, 'entry');

    if (path.isAbsolute(entry)){
        throw new Error('absolute entry is forbidden');
    }

    var root     = path.resolve(codeDir);
    var resolved = path.resolve(root, entry);

    if (resolved !== root && resolved.indexOf(root + path.sep) !== 0){
        throw new Error('entry escapes plugin directory');
    }

    if (!fs.statSync(resolved).isFile()){
        throw new Error('entry file does not exist: ' + entry);
    }

    return resolved;
}

function validateEntries(codeDir, manifest){
    var entry = manifest.entry;

    [ manifest.icon, entry.background ].filter(Boolean).forEach(function(file){
        resolveEntry(codeDir, file);
    });

    // Нормалізуємо локально: сюди приходить і сирий маніфест (див. тести), тож без
    // цього slot.file був би undefined і перевірка виходу за межі теки виродилась би.
    if (entry.settings !== undefined){
        resolveEntry(codeDir, normalizeSlot(entry.settings, 'entry.settings').file);
    }

    Object.keys(entry.panels || {}).forEach(function(name){
        resolveEntry(codeDir, normalizeSlot(entry.panels[name], 'entry.panels.' + name).file);
    });
}

module.exports = {
    validateManifest: validateManifest,
    resolveEntry    : resolveEntry,
    validateEntries : validateEntries,
    normalizeSlot   : normalizeSlot,
    compareVersions : compareVersions,
    isVersion       : isVersion
};
