// vscode_ext_test.js — behavioral test for the Klammertext VS Code
// extension (doc/edit/vscode/extension.js) OUTSIDE VS Code.
//
// The `vscode` module is stubbed with just enough API for activation, and
// the extension then talks to the REAL language server it spawned — so this
// exercises the whole chain: extension glue -> hand-rolled LSP client ->
// klammertext_ls.py -> shared core.  Checks: activation + handshake,
// publishDiagnostics reaching the diagnostic collection, Format Document
// equalling the indent fixtures, documentHighlight pairs, and the
// alignTable command round-tripping through workspace/applyEdit.
//
// Runs under any Node >= 16 — including VS Code's own Electron binary
// (ELECTRON_RUN_AS_NODE=1 code vscode_ext_test.js EXT_DIR FIXTURE_DIR).
// Prints PASS/FAIL lines; exits nonzero on any failure.

'use strict';

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

const extDir = path.resolve(process.argv[2]);
const fixDir = path.resolve(process.argv[3]);

let passed = 0, failed = 0;
function check(name, ok, detail) {
    if (ok) { passed++; console.log('PASS ' + name); }
    else { failed++; console.log('FAIL ' + name + ' ' + (detail || '')); }
}

// --- the vscode stub -------------------------------------------------------

class Position {
    constructor(line, character) { this.line = line; this.character = character; }
}
class Range {
    constructor(a, b, c, d) {
        if (typeof a === 'number') { this.start = new Position(a, b); this.end = new Position(c, d); }
        else { this.start = a; this.end = b; }
    }
}
class Selection extends Range {
    constructor(a, b) { super(a, b); this.active = b; }
}
class TextEdit {
    constructor(range, newText) { this.range = range; this.newText = newText; }
}
class Diagnostic {
    constructor(range, message, severity) { this.range = range; this.message = message; this.severity = severity; }
}
class Location {
    constructor(uri, range) { this.uri = uri; this.range = range; }
}
class DocumentHighlight {
    constructor(range) { this.range = range; }
}
class WorkspaceEdit {
    constructor() { this.edits = []; }
    replace(uri, range, newText) { this.edits.push({ uri, range, newText }); }
}

const listeners = { open: [], change: [], close: [], selection: [] };
const decorationTypes = [];              // in creation order: match, mismatch
const providers = {};
const commands = {};
const collections = {};
let appliedEdits = [];
let statusMessages = [];

const fakeDoc = {
    uri: { toString: () => 'file:///ext_test.kt' },
    languageId: 'klammertext',
    version: 1,
    text: '@i abc\n',
    getText() { return this.text; },
};

const vscodeStub = {
    Position, Range, Selection, TextEdit, Diagnostic, Location,
    DocumentHighlight, WorkspaceEdit,
    Uri: { parse: (s) => ({ toString: () => s }) },
    DiagnosticSeverity: { Error: 0, Warning: 1 },
    workspace: {
        getConfiguration: () => ({ get: (k) => (k === 'pythonPath' ? 'python3' : '') }),
        textDocuments: [fakeDoc],
        onDidOpenTextDocument: (fn) => { listeners.open.push(fn); return { dispose() {} }; },
        onDidChangeTextDocument: (fn) => { listeners.change.push(fn); return { dispose() {} }; },
        onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; },
        applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); },
    },
    ThemeColor: class ThemeColor {
        constructor(id) { this.id = id; }
    },
    window: {
        createOutputChannel: () => ({ append() {}, dispose() {} }),
        showWarningMessage: (m) => { statusMessages.push(m); },
        setStatusBarMessage: (m) => { statusMessages.push(m); },
        createTextEditorDecorationType: (opts) => {
            const t = { opts, dispose() {} };
            decorationTypes.push(t);
            return t;
        },
        onDidChangeTextEditorSelection: (fn) => { listeners.selection.push(fn); return { dispose() {} }; },
        onDidChangeActiveTextEditor: () => ({ dispose() {} }),
        activeTextEditor: null,
    },
    languages: {
        createDiagnosticCollection: (name) => {
            const c = {
                store: new Map(),
                set(uri, diags) { this.store.set(uri.toString(), diags); },
                dispose() {},
            };
            collections[name] = c;
            return c;
        },
        registerDocumentFormattingEditProvider: (lang, p) => { providers.format = p; return { dispose() {} }; },
        registerDocumentRangeFormattingEditProvider: (lang, p) => { providers.rangeFormat = p; return { dispose() {} }; },
        registerDocumentHighlightProvider: (lang, p) => { providers.highlight = p; return { dispose() {} }; },
        registerDefinitionProvider: (lang, p) => { providers.definition = p; return { dispose() {} }; },
    },
    commands: {
        registerCommand: (name, fn) => { commands[name] = fn; return { dispose() {} }; },
    },
};

const realResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
    if (request === 'vscode') return 'vscode';
    return realResolve.call(this, request, ...rest);
};
require.cache.vscode = { id: 'vscode', filename: 'vscode', loaded: true, exports: vscodeStub };

// --- helpers ---------------------------------------------------------------

function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

async function waitFor(pred, what, ms = 8000) {
    const t0 = Date.now();
    while (Date.now() - t0 < ms) {
        if (pred()) return true;
        await sleep(25);
    }
    throw new Error('timeout waiting for ' + what);
}

function applyEditsToText(text, edits) {
    // Convert {line, character} ranges to offsets (fixtures are ASCII).
    const lineStart = [0];
    for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStart.push(i + 1);
    const off = (p) => lineStart[p.line] + p.character;
    const resolved = edits.map((e) => ({ a: off(e.range.start), b: off(e.range.end), t: e.newText }));
    resolved.sort((x, y) => y.a - x.a);
    for (const e of resolved) text = text.slice(0, e.a) + e.t + text.slice(e.b);
    return text;
}

function setDocText(text) {
    fakeDoc.text = text;
    fakeDoc.version++;
    listeners.change.forEach((fn) => fn({ document: fakeDoc }));
}

// --- the test --------------------------------------------------------------

async function main() {
    const ext = require(path.join(extDir, 'extension.js'));
    const context = { extensionPath: extDir, subscriptions: [] };
    ext.activate(context);

    // Activation opens the (unbalanced) preloaded document; diagnostics
    // arrive from the real server.
    await waitFor(() => (collections.klammertext
                         && (collections.klammertext.store.get('file:///ext_test.kt') || []).length > 0),
                  'diagnostics');
    const diags = collections.klammertext.store.get('file:///ext_test.kt');
    check('activation + publishDiagnostics',
          diags.length === 1 && /never closed/.test(diags[0].message),
          JSON.stringify(diags.map((d) => d.message)));

    // Format Document == every indent fixture's expected file.
    for (const f of ['indent_list', 'indent_document', 'indent_table',
                     'indent_untouched', 'indent_defs', 'indent_escapes',
                     'indent_named_close']) {
        const src = fs.readFileSync(path.join(fixDir, f + '.kt'), 'utf8');
        const exp = fs.readFileSync(path.join(fixDir, f + '_expected.kt'), 'utf8');
        setDocText(src);
        const edits = await providers.format.provideDocumentFormattingEdits(fakeDoc);
        check('format ' + f, applyEditsToText(src, edits) === exp);
    }

    // Balanced text clears the diagnostics.
    setDocText('@i abc @\n');
    await waitFor(() => (collections.klammertext.store.get('file:///ext_test.kt') || []).length === 0,
                  'diagnostics cleared');
    check('diagnostics cleared on balanced text', true);

    // documentHighlight: the delimiter pair.
    const hl = await providers.highlight.provideDocumentHighlights(fakeDoc, new Position(0, 0));
    check('documentHighlight pair', hl.length === 2, JSON.stringify(hl));

    // jumpToMatch moves the cursor to the close.
    vscodeStub.window.activeTextEditor = {
        document: fakeDoc,
        selection: new Selection(new Position(0, 0), new Position(0, 0)),
        revealRange() {},
    };
    await commands['klammertext.jumpToMatch']();
    const sel = vscodeStub.window.activeTextEditor.selection;
    check('jumpToMatch cursor at close', sel.active.character === 7,
          JSON.stringify(sel));

    // live decorations: a bare close highlights its pair from any position.
    const [matchType, mismatchType] = decorationTypes;
    const editor = vscodeStub.window.activeTextEditor;
    editor.decorations = new Map();
    editor.setDecorations = (type, ranges) => editor.decorations.set(type, ranges);
    editor.selection = new Selection(new Position(0, 7), new Position(0, 7));
    listeners.selection.forEach((fn) => fn({ textEditor: editor }));
    await waitFor(() => (editor.decorations.get(matchType) || []).length === 2,
                  'match decorations');
    check('decorations: bare close boxes the pair', true);

    // mismatch: red decoration type, match type cleared
    setDocText('@ol x ul@\n');
    editor.selection = new Selection(new Position(0, 0), new Position(0, 0));
    listeners.selection.forEach((fn) => fn({ textEditor: editor }));
    await waitFor(() => (editor.decorations.get(mismatchType) || []).length === 2,
                  'mismatch decorations');
    check('decorations: mismatch in the red type',
          (editor.decorations.get(matchType) || []).length === 0,
          JSON.stringify([...editor.decorations.values()]));

    // alignTable round-trips through workspace/applyEdit.
    const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8');
    const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8');
    setDocText(src);
    const barOffset = src.indexOf('|');
    const barLine = src.slice(0, barOffset).split('\n').length - 1;
    const barCol = barOffset - (src.lastIndexOf('\n', barOffset - 1) + 1);
    vscodeStub.window.activeTextEditor.selection =
        new Selection(new Position(barLine, barCol), new Position(barLine, barCol));
    appliedEdits = [];
    await commands['klammertext.alignTable']();
    await waitFor(() => appliedEdits.length > 0, 'applyEdit');
    const edits = appliedEdits[0].edits.map((e) => ({ range: e.range, newText: e.newText }));
    check('alignTable via applyEdit', applyEditsToText(src, edits) === exp);

    ext.deactivate();
    console.log('vscode_ext_test: %d passed, %d failed', passed, failed);
    process.exit(failed ? 1 : 0);
}

main().catch((err) => {
    console.error('vscode_ext_test: ' + err.stack);
    process.exit(1);
});
