/** This file is part of Reactor.
 *  Copyright (c) 2020-2025 Kedron Holdings LLC, All Rights Reserved.
 *  Reactor is not public domain or open source. Distribution or derivative works are expressly prohibited.
 */

export const version = 25344;

import api from '/client/ClientAPI.js';
import Expression from '/client/Expression.js';
import Rulesets from '/client/Rulesets.js';
import Rule from '/client/Rule.js';
import Container from '/client/Container.js';
import Data from '/client/Data.js';
import { DataNotFoundException, ObjectNotFoundException } from '/client/Exception.js';

import EmbeddableEditor from './ee.js';
import entitypicker from './entity-picker.js';
import * as Common from './reactor-ui-common.js';

import { _T } from './i18n.js';

/* Frequently used */
const isEmpty = Common.isEmpty;
const idSelector = Common.idSelector;

export default class ExpressionEditor extends EmbeddableEditor {

    constructor( expressions, $container, options) {
        super( expressions, $container, options );

        this.$editor.addClass( "re-expressions-editor" );
    }

    toString() {
        return 'ExpressionEditor';
    }

    notifySaved() {
        super.notifySaved();

        $( '.tbmodified', this.$editor ).removeClass( 'tbmodified' );
        Object.values( this.data ).forEach( el => delete el.__modified );

        this.updateExpressionControls();
    }

    isEmpty() {
        return 0 === Object.keys( this.data ).length;
    }

    /* Override of redraw() below */

    /* -------------------- IMPLEMENTATION SPECIFICS ---------------------- */

    /** Reindex the expressions to the displayed order */
    reindexExpressions() {
        const self = this;
        let changed = false;
        $( 'div.varlist div.row.varexp', this.$editor ).each( ( ix, row ) => {
            const id = $( row ).data( 'name' );
            if ( self.data[ id ].index !== ix ) {
                self.data[ id ].index = ix;
                self.data[ id ].__modified = true;
                changed = true;
            }
        });
        if ( changed ) {
            this.signalModified();
        }
    }

    indicateError( $el, msg ) {
        $el.addClass('is-invalid').removeClass('is-valid');
        if ( msg ) {
            // $('<div class="invalid-tooltip"></div>').text(msg).insertAfter( $el );
            $el.attr( 'title', msg );
        }
    }

    fieldCheck( bool, $el, msg ) {
        console.assert( 1 === $el.length );
        if ( bool ) {
            this.indicateError( $el, msg );
        }
        return !bool;
    }

    updateCurrentValue( $row, data ) {
        const $blk = $( 'span.currval', $row );
        const $typ = $( 'span.currtyp', $row );
        if ( "undefined" === typeof data || null === data || "undefined" === typeof data.lastvalue ) {
            $typ.empty();
            $blk.text( _T('#expr-status-not-eval') ).attr( 'title', '' );
            $row.removeClass( 'evalerror' );
            $( '.evalerror', $row ).removeClass( 'evalerror' );
        } else if ( data.error ) {
            $typ.text( _T(['#expr-status-error', '(error) {0}'], "") );
            $blk.text( data.error ).attr( 'title', data.error );
            $row.addClass( 'evalerror' );
        } else {
            $row.removeClass( 'evalerror' );
            $( '.evalerror', $row ).removeClass( 'evalerror' );
            const ls = $blk.data( 'laststamp' );
            const typ = null === data.lastvalue ? 'null' :
                ( Array.isArray( data.lastvalue ) ? 'array-len' : ( typeof data.lastvalue ) );
            let val;
            if ( null === data.lastvalue || "boolean" === typ ) {
                val = _T( String( data.lastvalue ) );
            } else {
                try {
                    val = JSON.stringify( data.lastvalue );
                    val = val.replace( /[^ -~]/g, ( m ) => {
                        return "\\u" + m.charCodeAt( 0 ).toString( 16 ).padStart( 4, "0" );
                    });
                } catch ( err ) {
                    console.log( `Value of ${$row.data('name')} cannot be represented as JSON:`, err );
                    val = _T(`(value cannot be represented as JSON)`);
                }
            }
            const changed = ls && ( data.changed || ls !== data.laststamp );
            //console.log(`expr ${$row.data('name')} ui.laststamp=${ls} changed=${changed}`);
            //console.log(data);
            $typ.text( `(${_T(['#data-type-'+typ, typ], Array.isArray( data.lastvalue ) ? data.lastvalue.length : 0 )})` );
            $blk.text( val )
                .data( 'laststamp', data.laststamp )
                .attr( { "data-laststamp": data.laststamp,
                    title: "string" === typ && "" === val ? _T('(empty string)') : val } );
            if ( changed ) {
                $row.stop().clearQueue().css( 'background-color', 'var(--bs-green)' );
                $row.animate( { backgroundColor: 'transparent' }, 2000 );
            }
        }
    }

    /* Override base class for Observer */
    async notify( event ) {
        try {
            if ( event.type === 'data-changed' ) {
                if ( this.options.contextRule ) {
                    /* Rule context, so data object is rule state. Update all expressions for the rule. */
                    const self = this;
                    Object.keys( ( event.data.value || {} ).expr || {} ).forEach( name => {
                        const key = 'expr-' + self.options.contextRule.getID() + '-' + name;
                        const $el = $( 'div#' + Common.idSelector( key ), self.$editor );
                        if ( $el.length > 0 ) {
                            self.updateCurrentValue( $el, event.data.value.expr[name] );
                        }
                    });
                } else {
                    /* Global context */
                    const key = event.data.id;
                    const $el = $( 'div#' + Common.idSelector( key ), this.$editor );
                    if ( $el.length > 0 ) {
                        this.updateCurrentValue( $el, event.data.value );
                    }
                }
            }
        } finally {
            // No matter what...
            this.propagate( event );
        }
    }

    /**
     * Make a menu of attributes available on the given entity. Reselect attr
     * (or insert as missing) if provided.
     */
    makeAttributeMenu( entity, attr ) {
        const $el = $('<select class="varmenu form-select form-select-sm"></select>');
        const devobj = api.getEntity( entity );
        let any = false;
        if ( devobj ) {
            /* First, for every declared capability, add the known attributes */
            let $opt, $xg;
            const xs = devobj.getAttributes();
            const caps = devobj.getCapabilities().sort();
            /* Show the capabilities in the order they are defined */
            caps.forEach( function( cn ) {
                $xg = false;
                const cap = devobj.getCapability( cn );
                for ( let at of Object.keys( cap.attributes || {} ) ) {
                    if ( !$xg ) {
                        $xg = $( '<optgroup></optgroup>' )
                            .attr( 'label', _T('Capability: {0}', cn) )
                            .appendTo( $el );
                    }
                    const attr = cn + '.' + at;
                    $opt = $('<option></option>').val( attr );
                    if ( ! devobj.hasCapabilityAttribute( cn, at ) ) {
                        $opt.text( _T('{0} (no value)', attr ) );
                    } else if ( attr === devobj.getPrimaryAttribute() ) {
                        $opt.text( _T('{0} (primary)', attr) );
                    } else {
                        $opt.text( attr );
                    }
                    $opt.appendTo( $xg );
                    delete xs[ attr ];
                    any = true;
                }
            });
            $xg = false;
            for ( let a of Object.keys( xs ) ) {
                if ( !$xg ) {
                    $xg = $( '<optgroup></optgroup>' )
                        .attr( 'label', _T('Extended Attributes') )
                        .appendTo( $el );
                }
                $opt = $('<option></option>').val( a ).text( a )
                    .appendTo( $xg );
                any = true;
            }
        }

        if ( !any ) {
            $( '<option></option>' ).val( "" )
                .text( _T('(no eligible attributes)') )
                .appendTo( $el );
        }

        if ( isEmpty( attr ) ) {
            Common.menuSelectDefaultFirst( $el, (devobj && devobj.getPrimaryAttribute()) || "" );
        } else {
            Common.menuSelectDefaultInsert( $el, attr );
        }
        return $el;
    }

    updateFunctionButtons( $row ) {
        const expr = ( $( 'textarea.expr', $row ).val() || "" ).trim();
        //console.log( "updateFunctionButtons", $row.attr("id"), JSON.stringify(expr), this.isModified() );
        if ( this.isModified() || $row.hasClass( 'evalerror' ) ) {
            $( 'button.re-tryexpr', $row ).prop( 'disabled', true );
        } else {
            $( 'button.re-tryexpr', $row ).prop( 'disabled', "" === expr );
        }
        $( 'button.re-autoeval', $row ).prop( 'disabled', "" === expr );
    }

    updateExpressionControls() {
        const self = this;
        this.updateSaveControls();
        $( 'div.varexp', this.$editor ).each( ( ix, row ) => self.updateFunctionButtons( $( row ) ) );
    }

    handleExpressionChange( ev ) {
        if ( ev ) {
            const $row = $( ev.currentTarget ).closest( 'div.row' );
            $row.removeClass( 'tberror' );
            $( '.tberror', $row ).removeClass( 'tberror' );
        }

        const $ct = $( 'div.varlist', this.$editor );
        let modified = false;
        const self = this;
        $('div.varexp', $ct).each( ( ix, obj ) => {
            const $row = $( obj );
            const vname = $row.data( "name" );
            if ( undefined === vname ) {
                return;
            }
            let expr = ( $('textarea.expr', $row ).val() || "" ).trim();
            expr = expr.replace( /^=+\s*/, "" ); /* Remove leading =, this isn't Excel people */
            $( 'textarea.expr', $row ).val( expr );
            let varmodified = false;
            if ( "undefined" === typeof self.data[vname] ) {
                self.data[vname] = { name: vname, expr: expr, index: ix, __modified: true };
                varmodified = true;
            } else {
                if ( expr !== self.data[vname].expr ) {
                    self.data[vname].expr = expr;
                    varmodified = true;
                }
                if ( self.data[vname].index !== ix ) {
                    self.data[vname].index = ix;
                    varmodified = true;
                }
            }
            if ( varmodified ) {
                self.data[vname].__modified = true;
                $row.addClass( 'tbmodified' );
            }
            modified = modified || varmodified;
        });
        if ( modified ) {
            this.signalModified();
        }

        return false;
    }

    handleExpressionSort( ev, ui ) {
        $( ui.item ).addClass( 'tbmodified' );
        this.reindexExpressions();
    }

    handleTryExprClick( ev ) {
        const $row = $( ev.currentTarget ).closest( "div.varexp" );
        const vname = $row.data( 'name' );
        console.log('evaluating',vname);
        api.evalExpression( vname, this.options.contextRule ).then( () => {
            console.log('eval of',vname,'success');
            this.attachLastValue( $row );
        });
        return false;
    }

    handleAutoEvalClick( ev ) {
        const $btn = $( ev.currentTarget );
        const $row = $btn.closest( "div.varexp" );
        const vname = $row.data( 'name' );
        if ( $btn.toggleClass( 'slash' ).hasClass( 'slash' ) ) {
            this.data[ vname ].noautoeval = true;
        } else {
            delete this.data[ vname ].noautoeval;
        }
        this.signalModified();
    }

    handleDeleteExpressionClick( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.varexp' );
        const vname = $row.data( 'name' );
        Common.showSysModal( {
            title: _T(['#expression-delete-title','Delete Expression']),
            body: _T(['#expression-delete-prompt'], vname),
            close: false,
            buttons: [{
                label: _T(['#expression-delete-button-delete','Delete']),
                event: "delete",
                class: "btn-danger"
            },{
                label: _T(['#expression-delete-button-cancel','Cancel']),
                class: "btn-primary"
            }]
        }).then( event => {
            if ( "delete" === event ) {
                delete this.data[vname];
                $row.remove();
                this.reindexExpressions();
                this.signalModified();
            }
        });
        return false;
    }

    clearGetStateOptions() {
        const $row = $( 'div.opt-state', this.$editor );
        $row.remove();
        $( '.buttonrow button', this.$editor ).prop( 'disabled', false );
        $( 'textarea.expr,button.bi-btn', this.$editor ).prop( 'disabled', false );
    }

    handleGetStateClear( ev ) {
        ev.preventDefault();
        ev.stopPropagation();
        this.clearGetStateOptions();
        return false;
    }

    handleGetStateInsert( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.row.varexp' );

        const entity = $( '#gsdev', $row ).val() || "-1";
        const attr = $( 'select#gsvar', $row ).val() || "";
        const useName = $( 'input#usename', $row ).prop( 'checked' );
        let str = 'getEntity( "';
        if ( useName ) {
            let k = entity.indexOf( '>' );
            let e = api.getEntity( entity );
            if ( e && k >= 0 ) {
                str += entity.substring( 0, k+1 ) + e.getName();
            } else {
                str += entity;
            }
        } else {
            str += entity;
        }
        str += '" ).attributes.' + attr + ' ';

        const $f = $( 'textarea.expr', $row );
        let expr = $f.val() || "";
        const p = $f.get(0).selectionEnd || -1;
        if ( p >= 0 ) {
            expr = expr.substring(0, p) + str + expr.substring(p);
        } else {
            expr = expr + str;
        }
        expr = expr.trim();
        $f.val( expr );

        this.clearGetStateOptions();
        return this.handleExpressionChange( ev );
    }

    handleGetStateOptionChange( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.row' );
        const $f = $( 'select#gsvar', $row );
        $( 'button#getstateinsert', $row ).prop( 'disabled', "" === $f.val() );
        return false;
    }

    handleGetStateEntityChange( ev, result ) {
        console.log("entity change", result);
        const $row = $( ev.currentTarget ).closest( 'div.row' );
        const device = result.id || "";
        if ( "" === device ) {
            $( 'select#gsvar', $row ).empty();
        } else {
            const $s = this.makeAttributeMenu( result.id || "", "", "" ).attr( 'id', 'gsvar' );
            $( 'select#gsvar', $row ).replaceWith( $s );
            $s.on( 'change.reactor', this.handleGetStateOptionChange.bind(this) );
        }
        return this.handleGetStateOptionChange( ev );
    }

    handleGetStateClick( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.varexp' );
        const $div = $( 'div.re-vartools', $row );

        $( '.buttonrow button', this.$editor ).prop( 'disabled', true );
        $( 'button.bi-btn', this.$editor ).prop( 'disabled', true );
        $( 'textarea.expr', this.$editor ).prop( 'disabled', true );
        $( 'textarea.expr', $row ).prop( 'disabled', false );

        /* Remove any prior getstates */
        $('div.opt-state', this.$editor).remove();

        const $el = $( '<div class="form-inline"></div>' );
        const $ep = entitypicker.getPickerControl( 'gsdev' ).appendTo( $el );
        $ep.on( 'change.reactor', this.handleGetStateEntityChange.bind( this ) );
        this.makeAttributeMenu( $( '#gsdev', $el ).val(), "", "" ).attr( 'id', 'gsvar' ).appendTo( $el );
        $el.append( `<label class="checkbox-inline px-2"><input id="usename" type="checkbox" class="mx-1">${_T('Use&nbsp;Name')}</label>` );
        $( '<button class="btn bi-btn bi-btn-white text-success"><i class="bi bi-check-square"></i></button>' ).attr( 'id', 'getstateinsert' )
            .appendTo( $el );
        $( '<button class="btn bi-btn bi-btn-white text-primary"><i class="bi bi-x-square"></i></button>' ).attr( 'id', 'getstatecancel' )
            .appendTo( $el );
        $( '<div class="opt-state"></div>' )
            .append( $el )
            .insertAfter( $div );

        $( 'button#getstateinsert', $el ).prop( 'disabled', true )
            .on( 'click.reactor', this.handleGetStateInsert.bind(this) );
        $( 'button#getstatecancel', $el ).on( 'click.reactor', this.handleGetStateClear.bind(this) );

        return false;
    }

    async handleImportVarSelection( ev ) {
        const $el = $( ev.currentTarget );
        const id = $el.attr( 'id' );
        const rule_id = $el.data( 'rule' ) || false;
        if ( this.data[ id ] ) {
            Common.showSysModal( {
                title: _T('Name Conflict'),
                body: _T('#expression-import-conflict-message', id)
            });
            return;
        }

        let expr, rule;
        if ( rule_id ) {
            rule = await Rule.getInstance( rule_id );
            expr = rule.expressions[ id ];
            if ( "undefined" === typeof expr ) {
                console.log("Expression", id, "in rule", rule_id, "no longer exists");
                return;
            }
        } else {
            rule = false;
            expr = await Expression.getInstance( id );
        }
        const self = this;
        Common.showSysModal( {
            title: _T(['#expression-copy-title','Copy or move?']),
            body: _T('#expression-copy-prompt', ( rule_id ? rule.name : _T('Global Expressions') )),
            buttons: [{
                label: _T(['#expression-copy-button-move','Move']),
                event: "move",
                class: "btn-warning"
            },{
                label: _T(['#expression-copy-button-copy','Copy']),
                event: "copy",
                class: "btn-success"
            },{
                label: _T(['#expression-copy-button-cancel','Cancel']),
                close: true,
                class: "btn-primary"
            }]
        }).then( async event => {
            if ( event !== "close" ) {
                if ( rule_id ) {
                    self.data[ id ] = { ...expr };
                    if ( "move" === event ) {
                        let dobj = rule.getDataObject();
                        delete dobj.value.expressions[ id ];
                        dobj.forceModified().save();
                        /* Remove state data from rule */
                        dobj = await rule.getStatesDataObject();
                        if ( dobj.value && dobj.value.expr && dobj.value.expr[ id ] ) {
                            delete dobj.value.expr[ id ];
                            dobj.forceModified().save();
                        }
                    }
                } else {
                    /* Global rule */
                    self.data[ id ] = { ...expr.getDataObject().value };
                    if ( "move" === event ) {
                        await expr.delete(); /* also deletes state */
                    }
                }

                /* Draw new rule */
                const exp = self.data[ id ];
                const newid = 'expr-' + ( self.options.contextRule ? self.options.contextRule.getID() : 'global' ) + '-' + id;
                const $el = self.getExpressionRow().attr( { id: newid, "data-name": id } )
                    .data( 'name', id )
                    .addClass( 'tbmodified' );
                $( 'div.re-varname', $el).text( id );
                $( 'textarea.expr', $el ).val( exp.expr ).prop( 'disabled', false );
                $( 'button.bi-btn', $el ).prop( 'disabled', false );
                $( 'button.re-autoeval', $el ).toggleClass( 'slash', !!exp.noautoeval ) /* globals */
                    .prop( 'disabled', isEmpty( exp.expr ) );

                $( 'span.currval', $el ).empty();
                $( 'span.currtyp', $el ).empty();
                $el.appendTo( 'div.varlist', self.$editor );
                this.reindexExpressions();
                this.signalModified();

                this.attachLastValue( $el );
            }
        });
    }

    getExpressionRow() {
        const $el = $('<div class="row varexp gx-2"></div>');
        $el.append( '<div class="col-6 col-lg-2 order-1 re-varname"></div>' );
        $el.append( `<div class="order-4 order-lg-2 col-12 col-lg-8 re-varexpr">
  <textarea class="expr form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>
  <div class="cvwrap">
    <span>${_T('Last value:')}</span>
    <span class="currtyp">()</span>
    <span class="currval text-break user-select-all"></span>
  </div>
</div>` );
        $el.append( `<div class="col-6 col-lg-2 order-3 text-end re-vartools">
  <button class="btn bi-btn bi-btn-white re-tryexpr text-success" title="${_T('Try this expression')}"><i class="bi bi-play"></i></button>
  <button class="btn bi-btn bi-btn-white re-autoeval text-secondary" title="${_T('Auto-evaluate on dependency changes')}"><i class="bi bi-watch"></i></button>
  <button class="btn bi-btn bi-btn-white re-getstate text-secondary" title="${_T('Helper: Insert entity attribute value function')}"><i class="bi bi-cpu"></i></button>
  <button class="btn bi-btn bi-btn-white draghandle text-secondary" title="${_T('Change order (drag)')}"><i class="bi bi-arrow-down-up"></i></button>
  <button class="btn bi-btn bi-btn-white re-deletevar text-danger ms-4" title="${_T('Delete this expression')}"><i class="bi bi-x text-danger"></i></button>
</div>` );
        $( 'textarea.expr', $el ).prop( 'disabled', true ).on( 'change.reactor', this.handleExpressionChange.bind(this) );
        $( 'button.re-tryexpr', $el ).prop( 'disabled', true ).on( 'click.reactor', this.handleTryExprClick.bind(this) );
        $( 'button.re-getstate', $el ).prop( 'disabled', true ).on( 'click.reactor', this.handleGetStateClick.bind(this) );
        $( 'button.re-deletevar', $el ).prop( 'disabled', true ).on( 'click.reactor', this.handleDeleteExpressionClick.bind(this) );
        $( 'button.draghandle', $el ).prop( 'disabled', true );
        $( 'button.re-autoeval', $el ).toggle( ! this.options.contextRule )
            .on( 'click.reactor', this.handleAutoEvalClick.bind( this ) );
        return $el;
    }

    attachLastValue( $row ) {
        const $blk = $( 'span.currval', $row ).empty();
        const $typ = $( 'span.currtyp', $row ).empty();
        if ( this.options.contextRule ) {
            /* Rule context; use rule state for access to expression state */
            let name = $row.data( 'name' );
            this.options.contextRule.getStatesDataObject().then( dobj => {
                this.subscribe( dobj );
                this.updateCurrentValue( $row, Common.coalesce( dobj.value.expr || {} )[ name ] );
            }).catch( err => {
                $blk.text( _T('(not available: {0})', String( err ) ) )
                    .attr( 'title', "" );
            });
        } else {
            let key = $row.attr( 'id' );
            Data.getInstance( Container.getInstance( 'states' ), key, {} ).then( dobj => {
                this.subscribe( dobj );
                this.updateCurrentValue( $row, dobj.value );
            }).catch( err => {
                if ( err instanceof ObjectNotFoundException || err instanceof DataNotFoundException ) {
                    $typ.text( _T('(not available)' ) );
                    $blk.text( "" ).attr( 'title', "" );
                } else {
                    $typ.text( _T('(not available)' ) );
                    $blk.text( String( err ) ).attr( 'title', String( err ) );
                }
            });
        }
    }

    handleAddExpressionClick( /* event */ ) {
        const self = this;
        const $ct = $( 'div.vargroup', this.$editor );

        $( 'button#addvar', $ct ).prop( 'disabled', true );
        $( 'div.varexp textarea.expr,button.bi-btn', $ct ).prop( 'disabled', true );

        const $editrow = this.getExpressionRow();
        $( 'div.re-varname', $editrow ).empty()
            .append( `<input class="form-control form-control-sm" autocorrect="off" autocapitalize="off" autocomplete="off" spellcheck="off"
                title="${_T('Enter a variable name and then TAB out of the field.')}">` );
        $( 'div.re-varname input', $editrow ).on('change.reactor', function( ev ) {
            /* Convert to regular row */
            const $f = $( ev.currentTarget );
            const $row = $f.closest( 'div.varexp' );
            const vname = ($f.val() || "").trim();
            if ( vname === "" ||
                    !vname.match( /^\p{Alphabetic}[\p{Alphabetic}0-9_]*$/iu ) ||
                    $( 'div.varexp[data-name="' + idSelector( vname ) + '"]' ).length > 0 ) {
                $row.addClass( 'tberror' );
                $f.addClass('tberror');
                $f.focus();
            } else {
                $row.addClass( 'tbmodified' );
                let key = 'expr-' +
                    ( self.options.contextRule ? self.options.contextRule.getID() : 'global' ) +
                    '-' + vname;
                $row.attr( 'id', key )
                    .data( 'name', vname ).attr( 'data-name', vname )
                    .removeClass('editrow')
                    .removeClass('tberror');
                $( '.tberror', $row ).removeClass('tberror');
                /* Remove the name input field and swap in the name (text) */
                $f.parent().empty().text(vname);
                /* Re-enable fields and add button */
                $( 'button#addvar', $ct ).prop( 'disabled', false );
                $( 'button.bi-btn', $ct ).prop('disabled', false);
                $( 'textarea.expr', $ct ).prop( 'disabled', false );
                $( 'textarea.expr', $row ).focus();
                /* Do the regular stuff */
                self.data[ vname ] = { name: vname, expr: "", index: 32767 };
                self.reindexExpressions();
                self.handleExpressionChange( null );

                self.attachLastValue( $row );
            }
            return false;
        });
        $( 'div.varlist', $ct ).append( $editrow );
        $( 'div.re-varname input', $editrow ).focus();
        return false;
    }

    async redraw()
    {
        const self = this;

        /* Our styles. */
        if ( 0 === $( 'link#expression-editor-styles' ).length ) {
            $( 'head' ).append( '<link id="expression-editor-styles" rel="stylesheet" href="lib/css/expression-editor.css"></link>' );
        }

        this.$editor.empty();

        let $ct = $('<div class="vargroup"></div>').appendTo( this.$editor );

        const $list = $( '<div class="varlist tb-sortable"></div>' );
        $ct.append( $list );

        /* Create a list of expressions sorted by index */
        const vix = Object.values( this.data );
        vix.sort( function( a, b ) {
            const i1 = 'index' in a ? a.index : 32767;
            const i2 = 'index' in b ? b.index : 32767;
            return i1 - i2;
        });
        vix.forEach( function( vd ) {
            const key = "expr-" + ( self.options.contextRule ? self.options.contextRule.getID() : "global" ) + "-" + vd.name;
            const $el = self.getExpressionRow().attr( 'id', key )
                .data( 'name', vd.name ).attr( 'data-name', vd.name )
                .appendTo( $list );
            $( 'div.re-varname', $el).text( vd.name );
            $( 'textarea.expr', $el ).val( vd.expr ).prop( 'disabled', false );
            $( 'button.bi-btn', $el ).prop( 'disabled', false );
            $( 'button.re-autoeval', $el ).toggleClass( 'slash', !!vd.noautoeval ) /* globals */
                .prop( 'disabled', isEmpty( vd.expr ) );

            self.attachLastValue( $el );
        });

        $( 'textarea.expr', $ct ).each( ( ix, obj ) => {
            $( obj ).css( 'height', '16px' );
            let l = 2;
            while ( l <= 16 ) {
                $( obj ).css( 'height', `${l}rem` );
                const sh = $( obj ).prop( 'scrollHeight' );
                if ( sh <= $( obj ).innerHeight() ) {
                    break;
                }
                ++l;
            }
        });

        /* Add "Add" button */
        $ct = $( '<div class="row buttonrow gx-0"></div>' ).appendTo( $ct );
        const $bg = $( '<div class="col"></div>' ).appendTo( $ct );
        $( `<button id="addvar" class="btn btn-sm btn-success me-1"><i class="bi bi-plus-lg me-1"></i>${_T('Add Expression')}</button>` )
            .on( 'click.reactor', this.handleAddExpressionClick.bind(this) )
            .appendTo( $bg );
        $( `<div id="importVar" class="btn-group dropdown">
  <button class="btn btn-sm btn-primary dropdown-toggle" type="button" id="importVarButton"
    data-bs-toggle="dropdown" aria-expanded="false">
    ${_T('Copy/Move From...')}
  </button>
  <ul class="dropdown-menu"></ul>
</div>` )
            .appendTo( $bg );
        // $bg.append( Common.getWiki( 'Expressions-&-Variables' ) );

        const $mm = $( 'ul.dropdown-menu', $bg ).empty();
        ( async ( $menu ) => {
            if ( ! self.options.isGlobal ) {
                let exp = await Expression.getGlobalExpressions();
                let first = true;
                Object.values( exp ).forEach( expr => {
                    let $li = $( '<li></li>' ).appendTo( $menu );
                    if ( first ) {
                        first = false;
                        $( '<h6 class="dropdown-header"></h6>').text( _T('Global Variables') )
                            .appendTo( $li );
                        $li = $( '<li></li>' ).appendTo( $menu );
                    }
                    $( '<a class="dropdown-item" href="#"></a>' )
                        .attr( 'id', expr.name )
                        .text( expr.name )
                        .appendTo( $li );
                });
            }
            let rulesets = await Rulesets.getRulesets();
            for ( let j=0; j<rulesets.length; ++j ) {
                let set = rulesets[j];
                let rules = await set.getRules();
                for ( let k=0; k<rules.length; ++k ) {
                    let rule = rules[k];
                    if ( this.options.contextRule && rule.getID() === this.options.contextRule.getID() ) {
                        continue;
                    }
                    let first = true;
                    Object.values( rule.expressions || {} ).forEach( expr => {
                        let $li = $( '<li></li>' ).appendTo( $menu );
                        if ( first ) {
                            first = false;
                            $( '<h6 class="dropdown-header"></h6>').text( _T('Rule: {0}', rule.name) )
                                .appendTo( $li );
                            $li = $( '<li></li>' ).appendTo( $menu );
                        }
                        $( '<a class="dropdown-item" href="#"></a>' )
                            .attr( { id: expr.name, "data-rule": rule.id } )
                            .data( 'rule', rule.id )
                            .text( expr.name )
                            .appendTo( $li );
                    });
                }
            }
            $( 'a', $menu ).on( 'click.reactor', this.handleImportVarSelection.bind( this ) );
        })( $mm );

        $list.sortable({
            vertical: true,
            containment: 'div.varlist',
            // helper: "clone",
            handle: ".draghandle",
            cancel: "", /* so draghandle can be button */
            update: this.handleExpressionSort.bind(this)
        });

        this.updateExpressionControls();

        // Base class will send "modified" message with flags when signalModified() or save() are called.
        this.$editor.on( 'modified', this.updateExpressionControls.bind( this ) );

        // Update all value rows when we reconnect to host.
        api.on( 'structure_update', () => {
            $( 'div.varlist div.row.varexp', this.$editor ).each( ( ix, row ) => {
                const $row = $( row );
                const $blk = $( 'span.currval', $row );
                const $typ = $( 'span.currtyp', $row );

                if ( self.options.contextRule ) {
                    /* Rule context; use rule state for access to expression state */
                    const name = $row.data( 'name' );
                    self.options.contextRule.getStatesDataObject().then( dobj => {
                        dobj.refresh().then( () => {
                            self.updateCurrentValue( $row, Common.coalesce( dobj.value.expr || {} )[ name ] );
                        }).catch( err => console.error( err ) );
                    }).catch( err => {
                        if ( err instanceof ObjectNotFoundException || err instanceof DataNotFoundException ) {
                            $typ.text( _T('(not available)' ) );
                            $blk.text( "" ).attr( 'title', "" );
                        } else {
                            $typ.text( _T('(not available)' ) );
                            $blk.text( String( err ) ).attr( 'title', String( err ) );
                        }
                    });
                } else {
                    const key = $row.attr( 'id' );
                    Data.getInstance( Container.getInstance( 'states' ), key, {} ).then( dobj => {
                        dobj.refresh().then( () => {
                            self.updateCurrentValue( $row, dobj.value );
                        }).catch( err => console.error( err ) );
                    }).catch( err => {
                        if ( err instanceof ObjectNotFoundException || err instanceof DataNotFoundException ) {
                            $typ.text( _T('(not available)' ) );
                            $blk.text( "" ).attr( 'title', "" );
                        } else {
                            $typ.text( _T('(not available)' ) );
                            $blk.text( String( err ) ).attr( 'title', String( err ) );
                        }
                    });
                }
            });
        });
    }
}
