Source: editors/ztree.js

const fs = require('fs-extra');
const path = require('path');
const { readTreatment, decode } = require('../dialects/ztree/ztt.js');
const ztext = require('../dialects/ztree/ztext.js');
const lang = require('../dialects/ztree/lang.js');
const { fail } = require('./util.js');

/*
 * z-Tree treatments as the z-Tree admin's editor works on them: z-Tree's text format (File →
 * Export → Treatment… in z-Tree; File → Import… reads it back), as a tree of nodes in the order
 * the file has them:
 *
 *     {kind: 'stage', name: 'Market', quoted: true, items: [
 *         {key: 'startwaitforall', value: {raw: 'FALSE'}},
 *         {key: 'timeout', value: {raw: '60'}},
 *         {node: {kind: 'program', name: null, items: [{key: 'table', value: {raw: 'subjects'}}, {code: 'x = 1;\n'}]}},
 *         ...]}
 *
 * Values are {string} (a quoted text) or {raw} (an expression, a name, TRUE, 10%); a do block is
 * {code}, its lines without the export's indentation; a list of texts (a scale's) is {text}.
 * Written again, a treatment z-Tree exported is the same, byte for byte. jtree runs treatments in
 * this format as they are (see dialects/ztree/ztext.js), and that reader checks every one written.
 *
 * z-Tree's binary files (.ztt) are read by dialects/ztree/ztt.js into what jtree runs; fromDescription
 * makes a text treatment of that, which the editor saves beside the binary file (a .txt).
 */

// --- Windows-1252, as z-Tree writes its files -----------------------------------------------------

const CP1252 = '€\u0081‚ƒ„…†‡ˆ‰Š‹Œ\u008dŽ\u008f\u0090‘’“”•–—˜™š›œ\u009džŸ';

/** text in Windows-1252 (characters it does not have as ?). */
function encode(text) {
    const out = Buffer.alloc(text.length);
    let n = 0;
    for (const ch of text) {
        const c = ch.codePointAt(0);
        const at = CP1252.indexOf(ch);
        out[n++] = at >= 0 ? 0x80 + at : c < 0x100 ? c : 0x3f;
    }
    return out.subarray(0, n);
}

// --- Reading -------------------------------------------------------------------------------------

/** The text (a treatment's or questionnaires' export) as its root node. */
function parse(text) {
    let i = 0;
    const n = text.length;
    const fail_ = (message) => fail(message + ' (line ' + text.slice(0, i).split('\n').length + ')');
    const space = () => {
        for (;;) {
            while (i < n && /\s/.test(text[i])) i++;
            if (text.startsWith('//', i)) { while (i < n && text[i] !== '\n') i++; continue; }
            if (text.startsWith('/*', i)) { const e = text.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
            return;
        }
    };
    const string = () => {
        let out = '';
        i++;
        while (i < n && text[i] !== '"') {
            if (text[i] === '\r' && text[i + 1] === '\n') { i++; continue; }
            if (text[i] === '\n') {
                // The export indents a text's next line to the depth of its entry.
                out += '\n';
                i++;
                while (text[i] === '\t') i++;
                continue;
            }
            if (text[i] === '\\' && i + 1 < n) {
                const c = text[i + 1];
                out += { n: '\n', r: '\r', t: '\t' }[c] || c;
                i += 2;
            } else out += text[i++];
        }
        if (i >= n) fail_('a text does not end');
        i++;
        return out;
    };
    const word = () => {
        const m = /^[A-Za-z_]\w*/.exec(text.slice(i, i + 200));
        if (m == null) fail_('expected a name');
        i += m[0].length;
        return m[0];
    };
    const value = () => {
        const eq = i - 1;
        space();
        if (text[i] === '"') {
            let s = string();
            space();
            while (text[i] === '"') { s += string(); space(); }
            if (text[i] !== ';') fail_('expected ; after a text');
            i++;
            return { string: s };
        }
        const start = i;
        let depth = 0;
        while (i < n) {
            const c = text[i];
            if (c === '"') { string(); continue; }
            if (c === '(' || c === '[') depth++;
            else if (c === ')' || c === ']') depth--;
            else if (c === ';' && depth <= 0) break;
            i++;
        }
        if (i >= n) fail_('expected ;');
        const all = text.slice(start, i);
        const raw = all.trim();
        i++;
        // Spacing other than z-Tree's usual (key = value;) is kept, to write the value as it was.
        const out = { raw };
        const pre = text.slice(eq + 1, start) + all.slice(0, all.length - all.trimStart().length);
        const post = all.slice(all.trimEnd().length);
        if (pre !== ' ') out.pre = pre;
        if (post !== '') out.post = post;
        return out;
    };
    const code = (depth) => {
        const start = ++i;
        let level = 1;
        while (i < n) {
            const c = text[i];
            if (c === '"') { string(); continue; }
            if (text.startsWith('//', i)) { while (i < n && text[i] !== '\n') i++; continue; }
            if (text.startsWith('/*', i)) { const e = text.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
            if (c === '{') level++;
            else if (c === '}' && --level === 0) break;
            i++;
        }
        if (i >= n) fail_('a do block does not end');
        const body = text.slice(start, i);
        i++;
        return unindent(body, depth + 1);
    };
    const block = (kind, name, quoted, depth) => {
        const node = { kind, name, quoted, items: [] };
        for (;;) {
            space();
            if (i >= n) fail_('the block ' + kind + ' does not end');
            if (text[i] === '}') { i++; return node; }
            if (text[i] === '"') {
                node.items.push({ text: string() });
                space();
                if (text[i] === ';') i++;
                continue;
            }
            const key = word();
            space();
            if (text[i] === '=') {
                i++;
                node.items.push({ key, value: value() });
                continue;
            }
            let childName = null;
            let childQuoted = false;
            if (text[i] === '"') { childName = string(); childQuoted = true; } else if (/[\w-]/.test(text[i])) {
                const m = /^[\w.-]+/.exec(text.slice(i));
                childName = m[0];
                i += m[0].length;
            }
            space();
            if (text[i] !== '{') fail_('expected { or = after ' + key);
            if (key === 'do') {
                node.items.push({ code: code(depth + 1) });
                continue;
            }
            i++;
            node.items.push({ node: block(key, childName, childQuoted, depth + 1) });
        }
    };
    if (text.charCodeAt(0) === 0xfeff) i++;
    space();
    const kind = word();
    space();
    let name = null;
    let quoted = false;
    if (text[i] === '"') { name = string(); quoted = true; }
    space();
    if (text[i] !== '{') fail_('expected {');
    i++;
    const root = block(kind, name, quoted, 0);
    root.bom = text.charCodeAt(0) === 0xfeff;
    if (/\n/.test(text) && !/\r\n/.test(text)) root.eol = '\n';
    root.trailing = text.slice(i);
    return root;
}

/** A do block's body as code: the lines without the depth's tabs the export puts before each. */
function unindent(body, depth) {
    const lines = body.replace(/\r\n/g, '\n').split('\n');
    // The export puts the code on the lines after do{, and the closing brace on a line of its own.
    if (lines.length >= 2 && lines[0].trim() === '' && /^\t*$/.test(lines[lines.length - 1])) {
        const tabs = '\t'.repeat(depth);
        const inner = lines.slice(1, -1);
        return inner.length ? inner.map((l) => (l.startsWith(tabs) ? l.slice(tabs.length) : l.replace(/^\t+/, ''))).join('\n') + '\n' : '';
    }
    return body.replace(/\r\n/g, '\n').replace(/^\n/, '');
}

// --- Writing -------------------------------------------------------------------------------------

/**
 * A text as z-Tree writes it: \\ and \" escaped; a line break (\n) as a line break, the next line
 * indented to depth; \r\n (which z-Tree writes escaped, as in a radio item's format) escaped.
 */
const quote = (s, depth, eol) => '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r\n/g, '\\r\\n').replace(/\r/g, '\\r')
    .replace(/\n/g, (eol || '\r\n') + '\t'.repeat(depth)) + '"';

/** The node as z-Tree's text format (\r\n line ends, a tab a level, as z-Tree writes it). */
function write(root) {
    const out = [];
    // Line ends as the file had them (\r\n, z-Tree's, if it is new).
    const eol = root.eol || '\r\n';
    const quote_ = (text, depth) => quote(text, depth, eol);
    const node = (b, depth) => {
        const tabs = '\t'.repeat(depth);
        const name = b.name == null || b.name === '' && !b.quoted ? '' : ' ' + (b.quoted ? quote_(b.name, depth) : b.name);
        // A list of texts (a scale's) follows the brace on its line, as z-Tree writes it.
        const texts = b.items.length && b.items.every((item) => item.text != null);
        out.push(tabs + b.kind + name + '{' + (texts ? b.items.map((item) => quote_(item.text, depth + 1) + ';').join('') : ''));
        for (const item of texts ? [] : b.items) {
            const t = '\t'.repeat(depth + 1);
            if (item.node) node(item.node, depth + 1);
            else if (item.code != null) {
                const code = String(item.code).replace(/\r\n/g, '\n');
                const lines = code === '' ? [] : code.replace(/\n$/, '').split('\n');
                out.push(t + 'do{');
                for (const l of lines) out.push('\t'.repeat(depth + 2) + l);
                out.push(t + '}');
            } else if (item.text != null) out.push(t + quote_(item.text, depth + 1) + ';');
            else if (item.key) out.push(t + item.key + ' =' + (item.value.string != null ? ' ' : '') + (item.value.string != null ? quote_(item.value.string, depth + 1) : (item.value.pre != null ? item.value.pre : ' ') + item.value.raw + (item.value.post || '')) + ';');
        }
        out.push(tabs + '}');
    };
    node(root, 0);
    return (root.bom ? '' : '') + out.join(eol) + (root.trailing != null ? root.trailing : eol);
}

// --- Binary treatments as text -------------------------------------------------------------------

const entry = (key, v) => ({ key, value: typeof v === 'string' && !/^(TRUE|FALSE)$/.test(v) ? { string: v } : { raw: String(v) } });
const raw = (key, v) => ({ key, value: { raw: String(v) } });
const flag = (key, v) => raw(key, v ? 'TRUE' : 'FALSE');
const child = (kind, name, items, quoted = true) => ({ node: { kind, name, quoted: name != null && quoted, items } });

const BOX_KINDS = {
    standard: 'standardbox', header: 'headerbox', help: 'helpbox', container: 'containerbox', contractCreation: 'makecontractbox',
    contractList: 'selectcontractbox', history: 'historybox', grid: 'gridbox', chat: 'chatterbox', calculator: 'calulatorbox', plot: 'plotbox',
};
const PLOT_KINDS = { point: 'plotpoint', line: 'plotline', rect: 'plotrect', pie: 'plotpie', text: 'plottext', axis: 'plotaxis', graph: 'plotgraph', input: 'plotinput' };

/** What z-Tree's binary format has that this does not write, as notes for whoever saves it. */
function programItems(p) {
    const items = [raw('table', p.table || 'globals')];
    if (p.owner) items.push(raw('ownervariable', p.owner));
    if (p.condition) items.push(raw('condition', p.condition));
    items.push({ code: p.code || '' });
    return child('program', null, items);
}

function plotItem(it, notes) {
    const kind = PLOT_KINDS[it.kind];
    if (!kind) { notes.add('a plot item of an unknown kind'); return null; }
    const items = [];
    const s = (key, v) => { if (v != null && v !== '') items.push(raw(key, v)); };
    if (it.condition) items.push(raw('displaycondition', it.condition));
    switch (it.kind) {
        case 'point': s('x', it.x); s('y', it.y); s('size', it.size); s('numedges', it.corners); s('startat', it.rotation); items.push(flag('isstar', it.spokes)); s('linecolor', it.lineColor); s('linewidth', it.lineWidth); s('fillcolor', it.fillColor); break;
        case 'line': s('x1', it.x1); s('y1', it.y1); s('x2', it.x2); s('y2', it.y2); s('arrowsize1', it.arrowStart); s('arrowsize2', it.arrowEnd); s('arrowwidth', it.arrowWidth); s('linecolor', it.lineColor); s('linewidth', it.lineWidth); items.push(flag('arrowclosed', it.arrowFilled)); break;
        case 'rect': s('left', it.xLeft); s('right', it.xRight); s('x', it.xCenter); s('width', it.width); s('bottom', it.yBottom); s('top', it.yTop); s('y', it.yCenter); s('height', it.height); s('linecolor', it.lineColor); s('linewidth', it.lineWidth); s('fillcolor', it.fillColor); break;
        case 'pie': s('x', it.x); s('y', it.y); s('xradius', it.rx); s('yradius', it.ry); s('anglestart', it.angleStart); s('anglesize', it.angleEnd); s('linecolor', it.lineColor); s('linewidth', it.lineWidth); s('fillcolor', it.fillColor); break;
        case 'text': items.push(entry('text', it.text || '')); s('x', it.x); s('y', it.y); s('horizontalalignment', ['LEFT', 'CENTER', 'RIGHT'][it.hAlign] || 'CENTER'); s('verticalalignment', { 0: 'TOP', 2: 'CENTER', 3: 'BOTTOM' }[it.vAlign] || 'CENTER'); s('textcolor', it.color); s('fontsize', it.fontSize); break;
        case 'axis': s('axis', it.horizontal ? 'X' : 'Y'); s('position', it.position); s('from', it.from); s('to', it.to); s('tickdistance', it.tickStep); s('datalabeldistance', it.labelStep); if (it.labelLayout) items.push(entry('datalabelformat', it.labelLayout)); if (it.caption) items.push(entry('caption', it.caption)); s('linecolor', it.lineColor); s('linewidth', it.lineWidth); break;
        case 'graph': s('table', it.table); if (it.graphCondition) items.push(raw('condition', it.graphCondition)); s('connectioncolor', it.lineColor); s('connectionwidth', it.lineWidth); for (const g of it.items || []) { const c = plotItem(g, notes); if (c) items.push(c); } break;
        case 'input': s('action', ['NEW', 'SELECT', 'DRAG'][it.action] || 'NEW'); s('table', it.table); s('xvariable', it.xVar); s('yvariable', it.yVar); s('dragsnapx0', it.x0Var); s('dragsnapy0', it.y0Var); s('dragsnapx', it.x1Var); s('dragsnapy', it.y1Var); items.push(flag('leavestage', it.leaveStage)); for (const p of it.programs || []) items.push(programItems(p)); break;
        default:
    }
    for (const input of it.kind === 'input' ? [] : it.inputs || []) { const c = plotItem(input, notes); if (c) items.push(c); }
    return child(kind, it.name || '', items);
}

function boxItems(box, notes) {
    const kind = BOX_KINDS[box.type];
    if (!kind) { notes.add('a ' + box.type + ' box (written as a standard box)'); }
    const items = [flag('hasframe', box.frame !== false)];
    for (const k of ['width', 'height', 'left', 'right', 'top', 'bottom']) if (box[k]) items.push(raw(k, box[k]));
    const o = box.options || [];
    if (o[1]) items.push(flag('cutleft', true));
    if (o[3]) items.push(flag('cutright', true));
    if (o[5]) items.push(flag('cuttop', true));
    if (o[7]) items.push(flag('cutbottom', true));
    if (box.condition) items.push(raw('displaycondition', box.condition));
    switch (box.type) {
        case 'header':
            if (box.texts) {
                items.push(entry('periodtext', box.texts.period || 'Period'), entry('periodoftext', box.texts.of || 'of'),
                    entry('practiceperiodprefix', box.texts.trial || ''), entry('timestr', box.texts.time || ''), entry('pleasedecidetext', box.texts.timeout || ''));
            }
            break;
        case 'help': items.push(entry('label', box.title || ''), entry('help', box.text || '')); break;
        case 'contractList':
            items.push(raw('table', box.table || 'contracts'));
            if (box.tableCondition) items.push(raw('condition', box.tableCondition));
            if (box.sorting) items.push(entry('sortfields', box.sorting));
            if (box.owner) items.push(raw('ownervariable', box.owner));
            break;
        case 'contractCreation': items.push(raw('table', box.table || 'contracts')); break;
        case 'chat':
            items.push(raw('table', box.table || 'contracts'));
            if (box.variable) items.push(raw('inputvariable', box.variable));
            if (box.condition2) items.push(raw('condition', box.condition2));
            if (box.outputs && box.outputs[0]) items.push(entry('outputtext', box.outputs[0]));
            break;
        case 'plot':
            if (box.range) for (const [k, key] of [['xLeft', 'xleft'], ['xRight', 'xright'], ['yBottom', 'ybottom'], ['yTop', 'ytop']]) if (box.range[k] !== '' && box.range[k] != null) items.push(raw(key, box.range[k]));
            items.push(flag('maintainaspectratio', box.keepAspect));
            for (const it of box.plotItems || []) { const c = plotItem(it, notes); if (c) items.push(c); }
            break;
        default:
    }
    for (const it of box.items || []) {
        const fields = [entry('label', it.label || '')];
        if (it.variable) fields.push(raw('variable', it.variable));
        if (it.layout) fields.push(entry('format', it.layout));
        fields.push(flag('input', it.input));
        if (it.input) { if (it.min !== '' && it.min != null) fields.push(raw('min', it.min)); if (it.max !== '' && it.max != null) fields.push(raw('max', it.max)); }
        items.push(child('item', null, fields));
    }
    for (const p of box.programs || []) items.push(programItems(p));
    for (const b of box.buttons || []) {
        const fields = [];
        if (b.leaveStage === true) fields.push(flag('terminatestage', true), flag('donotterminatestage', false));
        else if (b.leaveStage === false) fields.push(flag('terminatestage', false), flag('donotterminatestage', true));
        for (const c of b.checkers || []) fields.push(child('checker', null, [raw('condition', c.condition || 'TRUE'), entry('message', c.message || '')]));
        for (const p of b.programs || []) fields.push(programItems(p));
        items.push(child('button', b.name || 'OK', fields));
    }
    for (const b of box.boxes || []) items.push(boxItems(b, notes));
    return child(kind || 'standardbox', box.name || '', items);
}

function screenItems(boxes, which, usesbg, notes) {
    return child('screen', which, [flag('usesbg', usesbg), ...boxes.map((b) => boxItems(b, notes))], false);
}

/** A treatment as jtree read it (dialects/ztree/ztt.js) as a text treatment's root node; notes: what it leaves out. */
function fromDescription(t, name) {
    const notes = new Set();
    const bg = t.background;
    const tables = (t.tables && t.tables.length ? t.tables : ['globals', 'subjects', 'summary', 'contracts', 'session', 'logfile'].map((n) => ({ name: n })))
        .map((tb) => child('table', tb.name, [...(tb.lifetime ? [raw('lifetime', tb.lifetime)] : []), ...(tb.execute ? [raw('execute', tb.execute)] : [])], false));
    const background = [
        ...tables,
        raw('numsubjects', t.numSubjects != null ? t.numSubjects : (t.subjects || []).length),
        raw('numgroups', t.numGroups != null ? t.numGroups : 1),
        raw('numpracticeperiods', t.practicePeriods || 0),
        raw('numactualperiods', Math.max(1, (t.periods || []).length - (t.practicePeriods || 0))),
        ...bg.programs.map(programItems),
        screenItems(bg.active, 'action', true, notes),
        screenItems(bg.waiting, 'wait', true, notes),
    ];
    const stages = t.stages.map((s) => child('stage', s.name || '', [
        flag('startwaitforall', s.startIfPossible !== true),
        ...(s.startCondition ? [entry('startConditionStr', s.startCondition)] : []),
        raw('timeouttype', s.timeoutType || (s.timeoutAlways ? 'always' : 'ifnoinput')),
        raw('timeout', String(s.timeout || '').trim() || '-1'),
        ...s.programs.map(programItems),
        screenItems(s.active, 'action', s.showHeader !== false, notes),
        screenItems(s.waiting, 'wait', true, notes),
    ]));
    const roles = child('roles', null, (t.subjects || []).map((s, i) => child('role', s.name || 'S ' + (i + 1), s.program ? [{ code: s.program }] : [])));
    const numSubjects = Math.max(1, (t.subjects || []).length);
    const periods = (t.periods || []).map((p, pi) => child('period', p.name || String(pi + 1), [
        ...(p.program ? [{ code: p.program }] : []),
        ...(t.params || []).slice(pi * numSubjects, (pi + 1) * numSubjects).map((c, si) => child('subject', String(si + 1), [entry('name', c.label || ''), raw('group', c.group != null ? c.group : 1), ...(c.program ? [{ code: c.program }] : [])], false)),
    ]));
    const root = { kind: 'treatment', name, quoted: true, items: [child('background', null, background), ...stages, roles, ...periods] };
    return { root, notes: [...notes] };
}

// --- A new treatment -----------------------------------------------------------------------------

/** z-Tree's new treatment: the background with its tables, header box and waiting screen; no stages. */
function newTreatment(name) {
    const screen = (which, boxes) => ['\t\tscreen ' + which + '{', '\t\t\tusesbg = TRUE;', '\t\t\twithalertscreen = FALSE;', '\t\t\tnoalertscreen = FALSE;', ...boxes, '\t\t}'];
    const t = parse([
        'treatment ' + quote(name, 0) + '{',
        '\tbackground{',
        ...['globals', 'subjects', 'summary', 'contracts', 'session', 'logfile'].flatMap((n) => ['\t\ttable ' + n + '{', '\t\t}']),
        '\t\tnumsubjects = 2;', '\t\tnumgroups = 1;', '\t\tnumpracticeperiods = 0;', '\t\tnumactualperiods = 1;',
        '\t\texchangerate = 1;', '\t\tstartendowment = 0;', '\t\tshowupfee = 0;',
        '\t\tnoAutoscope = FALSE;', '\t\tfirstBoxesOnTop = FALSE;',
        '\t\tshowupfeeawaytext = "Sie haben einen Verlust gemacht. Wollen Sie diesen Verlust mit dem Fixbetrag decken?";',
        '\t\tshowupfeeawayyestext = "Ja";', '\t\tshowupfeeawaynotext = "Nein";',
        '\t\tmoneyawaytext = "Sie haben einen Verlust gemacht. Wollen Sie weiterfahren?";',
        '\t\tmoneyawayyestext = "Ja";', '\t\tmoneyawaynotext = "Nein";',
        '\t\tbancruptwaittext = "Bitte warten Sie, bis Ihr Computer wieder freigegeben wird.";',
        '\t\tprogram{', '\t\t\ttable = globals;', '\t\t\tdo{', '\t\t\t\t', '\t\t\t}', '\t\t}',
        ...screen('action', [
            '\t\t\theaderbox "Header"{', '\t\t\t\thasframe = TRUE;', '\t\t\t\theight = 10%;', '\t\t\t\ttop = 0p;', '\t\t\t\tcuttop = TRUE;',
            '\t\t\t\tshowPeriods = TRUE;', '\t\t\t\tshowNumPeriods = TRUE;', '\t\t\t\tperiodtext = "Period";', '\t\t\t\tperiodoftext = "of";',
            '\t\t\t\tpracticeperiodprefix = "Trial ";', '\t\t\t\tshowtime = TRUE;', '\t\t\t\ttimestr = "Remaining time [sec]:";',
            '\t\t\t\tpleasedecidetext = "Please reach a decision.";', '\t\t\t}',
        ]),
        ...screen('wait', [
            '\t\t\tstandardbox "Text"{', '\t\t\t\thasframe = TRUE;', '\t\t\t\tbuttonposition = BOTTOMRIGHT;', '\t\t\t\tbuttonsequence = HORIZONTAL;',
            '\t\t\t\titem{', '\t\t\t\t\tlabel = "Please wait until the experiment continues.";', '\t\t\t\t}', '\t\t\t}',
        ]),
        '\t}',
        '\troles{', '\t\trole "S 1"{', '\t\t}', '\t\trole "S 2"{', '\t\t}', '\t}',
        '\tperiod "1"{', '\t\tprompt = "";', '\t\tsubject 1{', '\t\t\tname = "";', '\t\t\tgroup = 1;', '\t\t}', '\t\tsubject 2{', '\t\t\tname = "";', '\t\t\tgroup = 1;', '\t\t}', '\t}',
        '}',
    ].join('\r\n') + '\r\n');
    return t;
}

/**
 * What Treatment → Check finds in a treatment (as jtree reads it): each program and expression
 * that does not parse, as {where, message}.
 */
function syntaxErrors(t) {
    const out = [];
    const add = (code, kind, where) => {
        if (code == null || String(code).trim() === '') return;
        try {
            if (kind === 'program') lang.parse(code); else lang.parseExpr(code);
        } catch (err) {
            out.push({ where, message: err.message });
        }
    };
    const prog = (p, where) => { add(p.code, 'program', where + ', program on ' + (p.table || 'globals')); add(p.condition, 'expression', where + ", a program's condition"); };
    const box = (b, where) => {
        const w = where + ', box ' + (b.name || b.type);
        add(b.condition, 'expression', w + ', display condition');
        for (const it of b.items || []) {
            if (it.kind !== 'item') continue;
            if (it.variable) add(it.variable, 'expression', w + ', item ' + it.variable);
            if (it.input) { add(it.min, 'expression', w + ', item ' + it.variable + ', minimum'); add(it.max, 'expression', w + ', item ' + it.variable + ', maximum'); }
        }
        for (const bt of b.buttons || []) {
            bt.programs.forEach((p) => prog(p, w + ', button ' + bt.name));
            bt.checkers.forEach((c) => add(c.condition, 'expression', w + ', button ' + bt.name + ', checker'));
        }
        (b.programs || []).forEach((p) => prog(p, w));
        if (b.tableCondition) add(b.tableCondition, 'expression', w + ', condition');
        (b.boxes || []).forEach((c) => box(c, w));
    };
    for (const s of [t.background, ...t.stages]) {
        const where = s === t.background ? 'Background' : 'stage ' + JSON.stringify(s.name);
        s.programs.forEach((p) => prog(p, where));
        if (s.timeout) add(s.timeout, 'expression', where + ', timeout');
        [...s.active, ...s.waiting].forEach((b) => box(b, where));
    }
    (t.params || []).forEach((p, i) => add(p.program, 'program', 'parameter table, row ' + (i + 1)));
    return out;
}

// --- What the editor asks -------------------------------------------------------------------------

/** Whether the file is z-Tree's text format (else its binary one). */
function isTextFile(file) {
    const head = fs.readFileSync(file).subarray(0, 64);
    return ztext.isText(head);
}

/** Checks root is a treatment jtree runs; throws, saying what is wrong, if not. */
function check(root) {
    const text = write(root);
    try {
        if (root.kind === 'questionnaires') ztext.readTextQuestionnaires(text);
        else ztext.readTextTreatment(text);
    } catch (err) {
        fail('jtree cannot read the treatment: ' + err.message);
    }
    return text;
}

/** A treatment file's place: one the z-Tree dialect reads. */
function treatmentFile(ctx, p) {
    const file = ctx.resolve(p);
    if (!/\.(ztt|ztq|txt)$/i.test(file)) fail('not a z-Tree treatment (.ztt, .txt): ' + p);
    return file;
}

const actions = {
    /**
     * {appPath} → {appPath, savePath, format, root, notes}: the treatment as a tree. A binary file
     * is saved as text beside it (savePath, a .txt), which z-Tree imports (File → Import…).
     */
    async open({ appPath }) {
        const file = treatmentFile(this.ctx, appPath);
        if (!fs.existsSync(file)) fail('there is no file ' + appPath);
        if (isTextFile(file)) {
            const root = parse(decode(fs.readFileSync(file)));
            return { appPath: file, savePath: file, format: 'text', root, notes: [] };
        }
        if (/\.ztq$/i.test(file)) fail('z-Tree questionnaire files (.ztq) in the binary format cannot be edited here; export them from z-Tree as text.');
        const { root, notes } = fromDescription(readTreatment(fs.readFileSync(file)), path.basename(file));
        const savePath = file.replace(/\.ztt$/i, '.txt');
        return { appPath: file, savePath, format: 'binary', root, notes, savePathExists: fs.existsSync(savePath) };
    },
    /** {root} → {text}: the treatment's text, if jtree can read it. */
    async text({ root }) {
        return { text: check(root) };
    },
    /** {root} → {errors: [{where, message}], stages}: Treatment → Check. */
    async check({ root }) {
        const text = check(root);
        if (root.kind === 'questionnaires') return { errors: [], stages: 0 };
        const t = ztext.readTextTreatment(text);
        return { errors: syntaxErrors(t), stages: t.stages.length };
    },
    /** {name} → {root}: z-Tree's new treatment, not written (Save As names it). */
    async blank({ name }) {
        return { root: newTreatment(String(name || 'untitled.ztt')) };
    },
    /** {text} → {root}: a treatment's text as a tree. */
    async parse({ text }) {
        return { root: parse(String(text)) };
    },
    /** {appPath, root} → {appPath}: writes the treatment in z-Tree's text format. */
    async save({ appPath, root }) {
        const file = treatmentFile(this.ctx, appPath);
        if (fs.existsSync(file) && !isTextFile(file)) fail('will not write over z-Tree\'s binary file ' + path.basename(file) + '; save it as a .txt');
        const text = check(root);
        this.ctx.write(file, encode(text), null);
        this.ctx.reload();
        return { appPath: file };
    },
    /** {appPath} → {appPath, root}: a new, empty treatment, written. */
    async create({ appPath }) {
        let file = this.ctx.resolve(appPath);
        if (!/\.(ztt|txt)$/i.test(file)) file += '.txt';
        if (fs.existsSync(file)) fail('there is already a file ' + path.basename(file));
        const root = newTreatment(path.basename(file).replace(/\.txt$/i, '.ztt'));
        this.ctx.write(file, encode(write(root)), null);
        this.ctx.reload();
        return { appPath: file, savePath: file, format: 'text', root, notes: [] };
    },
};

// Each action gets the editor context as `this.ctx` (see actions below).
const bound = Object.fromEntries(Object.entries(actions).map(([k, fn]) => [k, (body, ctx) => fn.call({ ctx }, body)]));

module.exports = { parse, write, encode, fromDescription, newTreatment, check, syntaxErrors, actions: bound };