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

export const version = 26044;

import api from '/client/ClientAPI.js';
import Rulesets from "/client/Rulesets.js";
import Ruleset from "/client/Ruleset.js";
import Rule from "/client/Rule.js";
import Reaction from "/client/Reaction.js";

import * as Common from './reactor-ui-common.js';

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

import { RuleEditor, condTypeName, weekdayNames } from "./rule-editor.js";
import ReactionEditor from "./reaction-editor.js";
import ExpressionEditor from "./expression-editor.js";

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

import Tab from "./tab.js";
import ReactionList from "./reaction-list.js";

export default (function($) {

    var tabInstance = false;

    class RulesTab extends Tab {
        constructor( $parent ) {
            super( 'tab-rules', $parent );

            this.configModified = false;
            this.editors = {};
            this.editrule = false;
            this.newrule = false;

            const self = this;
            $( 'div#offcanvasRulesets .offcanvas-header h6' ).text( _T( "#nav_rulesets" ) );
            $( "div#offcanvasRulesets button#btnRuleSetAdd" )
                .attr( 'data-bs-dismiss', 'offcanvas' )
                .attr( 'title', _T( "Add a new rule set" ) )
                .on( 'click.reactor', async () => {
                    const $oc = $( 'div#offcanvasRulesets' );
                    const ruleset = await Ruleset.create();
                    const $nav = $( '<li class="nav-item"></li>' ).prependTo( $( 'ul.nav', $oc ) );
                    const $el = $( '<a href="#" class="nav-link ruleset-link"></a>' )
                        .attr( 'id', ruleset.id )
                        .attr( 'data-bs-dismiss', 'offcanvas' )
                        .on( 'click.reactor', self.handleRulesetClick.bind( self ) )
                        .appendTo( $nav );
                    $( '<i class="bi bi-grip-vertical me-2"></i>' ).appendTo( $el );
                    $( '<span></span>' ).text( ruleset.name || ruleset.id ).appendTo( $el );
                    $( 'a.nav-link', $nav ).triggerHandler( 'click' );
                });
        }

        async activate( event, extras ) {
            const $tab = this.getTabElement();
            console.log("Rules tab activation with", extras);
            let targetObj, ruleset_id, ruleset;
            if ( extras?.path && extras.path.length ) {
                let objId = extras.path[1];
                if ( "rule" === extras.path[0] ) {
                    try {
                        targetObj = await Rule.getInstance( objId );
                        await targetObj.refresh();
                        ruleset = await targetObj.getRuleset();
                        ruleset_id = ruleset.id;
                    } catch ( err ) {
                        console.error("Failed to load rule", objId);
                        console.error( err );
                    }
                } else if ( "reaction" === extras.path[0] ) {
                    try {
                        targetObj = await Reaction.getInstance( objId );
                        await targetObj.refresh();
                        ruleset = await targetObj.getRuleset();
                        ruleset_id = ruleset.id;
                    } catch ( err ) {
                        console.error("Failed to load reaction", objId);
                        console.error( err );
                    }
                } else {
                    if ( "ruleset" !== extras.path[0] ) {
                        objId = extras.path[0];
                    }
                    try {
                        targetObj = ruleset = await Ruleset.getInstance( objId );
                        ruleset_id = ruleset.id;
                    } catch ( err ) {
                        console.error("Failed to load ruleset", objId);
                        console.error( err );
                    }
                }
            }
            if ( ruleset_id ) {
                localStorage.setItem( 'last_ruleset', ruleset.id );
            }

            await this.show_list( ruleset_id );

            /* If we were given a target object, expand it. */
            if ( targetObj instanceof Rule ) {
                const $row = $( `div.re-rule#rule-${idSelector(targetObj.id)}`, $tab );
                if ( $row.length ) {
                    this.toggleRuleStatus( $row, true );
                }
            } else if ( targetObj instanceof Reaction ) {
                const $row = $( `div.re-reaction#${idSelector(targetObj.id)}`, $tab );
                if ( $row.length ) {
                    /* There isn't status for reactions (they're stateless), so just scroll to it. */
                    $row.get(0).scrollIntoView(true);
                }
            }
        }

        canSuspend() {
            /* If editing, can't suspend. */
            if ( "edit" === this.$tab.data( 'mode' ) && this.configModified ) {     /* ??? this.editRule not false? */
                console.log("Stopping activate event in edit mode");
                return false;
            }
            return true;
        }

        suspending() {
            $( "div#offcanvasRulesets ul.nav" ).empty();

            this.configModified = false;
            this.editors = {};
            this.editrule = false;
            this.newrule = false;

            const $e = $( 'div.ruleset-reaction-list', this.$tab );
            if ( $e.data( 'ReactionList' ) ) {
                $e.data( 'ReactionList' ).suspending();
            }

            this.getTabElement().removeData( 'mode' ).attr( 'data-mode', null );
        }

        getEditor( id ) {
            return this.editors[ id ];
        }

        /**
         * Update save/revert buttons
         */
        updateSaveControls() {
            const pos = $( window ).scrollTop();
            let cansave = this.configModified;
            // console.log("updateSaveControls() configModified", this.configModified);
            const self = this;
            Object.keys( this.editors ).forEach( function(k) {
                if ( ! self.editors[k].canSave() ) {
                    // console.log("updateSaveControls() editor",k,"reports cannot save");
                    cansave = false;
                }
            });
            $('button.saveconf').prop('disabled', ! cansave )
                .attr( 'title', this.configModified ?
                    ( cansave ? "" : _T("Fix errors before saving")) : _T("No unsaved changes") );
            let $de = $( 'div.re-controls span.re-dataerror' );
            if ( cansave || ! this.configModified ) {
                $de.remove();
            } else if ( 0 === $de.length ) {
                $( '<span class="re-dataerror me-2"></span>' )
                    .text( _T("Fix errors before saving") )
                    .prependTo( $( 'div.re-controls' ) );
            }
            $('button.revertconf').prop('disabled', false )
                .attr( 'title', this.configModified ?
                    _T("You have unsaved changes!") : _T("No unsaved changes") );
            /* If collapsed sections are showing modified/green badge/count color, reset to default/info/blue */
            $( 'div.re-sectionhead span.badge.bg-success' )
                .removeClass( 'bg-success' )
                .addClass( 'bg-info' );
            setTimeout( function() { $(window).scrollTop( pos ); }, 100 );
        }

        editRule( rule, isNew ) {
            const self = this;

            this.editrule = rule.getDataObject().value;
            this.newrule = !!isNew;

            this.$tab.empty();
            this.$tab.data( 'mode', 'edit' ).attr( 'data-mode', 'edit' );
            this.$tab.data( 'rule', rule.id ).attr( 'data-rule', rule.id );
            // this.$tab.data('ruleset', rule.__set.id).attr( 'data-ruleset', rule.__set.id );

            let $container = $('<div id="re-edit-head" class="container-fluid"></div>' )
                .appendTo( this.$tab );
            let $row = $( '<div class="row"></div>' ).appendTo( $container );
            $( '<div class="col"><h1></h1></div>' ).appendTo( $row );
            $( `<div class="col text-end mt-3 re-controls">
  <button class="btn btn-sm btn-success saveconf">${_T(['#edit-button-save','Save'])}</button>
  <button class="btn btn-sm btn-danger revertconf">${_T(['#edit-button-exit','Exit'])}</button>
</div>` ).appendTo( $row );
            $( 'h1', $row ).text( rule.name || rule.id ).on( 'click.reactor', function( ev ) {
                const $el = $( ev.currentTarget );
                const $form = $( '<div class="form-group"><input id="newname" class="form-control form-control-sm"></div>' );
                $( 'input', $form ).val( rule.name || rule.id );
                showSysModal({
                    title: _T(['#rule-rename-title','Rename Rule']),
                    body: $form,
                    buttons: [{
                        class: "btn-primary",
                        label: _T(['#rule-rename-button-cancel','Cancel']),
                        close: true
                    },{
                        class: "btn-success",
                        event: "rename",
                        label: _T(['#rule-rename-button-rename','Rename'])
                    }
                ]
                }).then( data => {
                    if ( "rename" === data ) {
                        const newname = $( '#sysdialog #newname' ).val() || "";
                        if ( "" !== newname && newname !== self.editrule.name ) {
                            self.editrule.name = newname;
                            $el.text( newname );
                            self.getEditor( 'trig_editor' ).signalModified();
                        }
                    }
                });
            });

            $container = $( '<div id="ct-trig"></div>' ).appendTo( this.$tab );
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( `<div class="col">
  <h6>
    <a class="bi-btn bi-chevron-down mx-1" data-bs-toggle="collapse" data-bs-target="#trig-editor"
         role="button" aria-expanded="true" aria-controls="trig-editor"></a>
    ${_T(['#ruleedit-triggers-title','Triggers'])}
  </h6>
</div>` )
                .appendTo( $row );
            if ( ! ( rule.triggers && "group" === rule.triggers.type ) ) {
                rule.triggers = { type: "group", op: "and", conditions: [], id: rule.id + '-trig',
                    disabled: true };
            } else {
                /* ??? Temporary fix adopted from importer. Root trigger group can't have options, so for
                   users that have already imported and started work, if their triggers got imported with
                   options, push the triggers down to a new subgroup. */
                /* If root (trig) has options, no can do, push down to new group with new ID */
                if ( Object.keys( rule.triggers.options || {} ).length > 0 ) {
                    let new_grp = { ...rule.triggers }; /* copy */
                    rule.triggers.conditions = [ { id: Common.getUID( 'cond' ), type: "comment", data: { comment: "Rule repaired; delete this comment and save the updated rule." } }, new_grp ];
                    delete rule.triggers.options;
                    new_grp.id = Common.getUID( 'grp' );
                    new_grp.name = 'Wrapper group for options';
                    delete new_grp.disabled;
                    delete new_grp.options.after;
                    delete new_grp.options.aftertime;
                    delete new_grp.options.aftermode;
                }
            }
            const trig_editor = new RuleEditor( rule.triggers || {}, $container, { fixedName: '', contextRule: rule } );
            this.editors.trig_editor = trig_editor;
            trig_editor.editor()
                .attr( 'id', 'trig-editor' )
                .addClass( "collapse show" )
                .on( 'update-controls', function( ev, ui ) {
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'modified', function( ev, ui ) {
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'ready', function( ev, ui ) {
                    ui.editor.editor().collapse( "show" );
                });

            $container = $( '<div id="ct-cons"></div>' ).appendTo( this.$tab );
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( `<div class="col">
  <h6>
    <a class="bi-btn bi-chevron-down mx-1" data-bs-toggle="collapse" data-bs-target="#const-editor"
       role="button" aria-expanded="true" aria-controls="const-editor"></a>
    ${_T(['#ruleedit-constraints-title','Constraints'])}
  </h6>
</div>` )
                .appendTo( $row );
            /* ??? temporary remove later */
            if ( rule.conditions ) {
                const d = rule.getDataObject();
                d.value.constraints = d.value.conditions;
                delete d.value.conditions;
                d.save();
            }
            /* ??? end temporary */
            if ( ! ( rule.constraints && "group" === rule.constraints.type ) ) {
                rule.constraints = { type: "group", op: "and", conditions: [], id: 'cons' };
            }
            delete rule.constraints.options; /* Constraints can never have options */
            const const_editor = new RuleEditor( rule.constraints || {}, $container,
                { fixedName: '', contextRule: rule, constraints: true } );
            this.editors.const_editor = const_editor;
            const_editor.editor()
                .attr( 'id', 'const-editor' )
                .addClass( "collapse show" )
                .on( 'update-controls', function( ev, ui ) {
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'modified', function( ev, ui ) {
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'ready', function( ev, ui ) {
                    ui.editor.editor().collapse( ((rule.constraints || {}).conditions || []).length > 0 ? "show" : "hide" );
                });

            $container = $( '<div id="ct-set"></div>' ).appendTo( this.$tab );
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( `<div class="col">
  <h6>
    <a class="bi-btn bi-chevron-down mx-1" data-bs-toggle="collapse" data-bs-target="#set-react-editor"
       role="button" aria-expanded="true" aria-controls="set-react-editor"></a>
    ${_T(['#ruleedit-setreaction-title','Set Reaction'])}
  </h6>
</div>` )
                .appendTo( $row );
            let reaction = rule.react_set;
            if ( !reaction ) {
                rule.react_set = reaction = { id: rule.id + ':S', rule: rule.id, set: true, actions: [] };
                this.configModified = true;
            }
            delete reaction.name;
            const set_editor = new ReactionEditor( reaction, $container, { fixedName: "", contextRule: rule } );
            this.editors.set_editor = set_editor;
            set_editor.editor()
                .attr( 'id', 'set-react-editor' )
                .addClass( "collapse show" )
                .on( 'update-controls', function( ev, ui ) {
                    /* During editing, various changes make the reaction saveable, or not. */
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'modified', function( ev, ui ) {
                    /* Sent when the cached reaction is modified (no errors) */
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'ready', function( ev, ui ) {
                    ui.editor.editor().collapse( "show" );
                });

            $container = $( '<div id="ct-reset"></div>' ).appendTo( this.$tab );
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( `<div class="col">
  <h6>
    <a class="bi-btn bi-chevron-down mx-1" data-bs-toggle="collapse" data-bs-target="#reset-react-editor"
       role="button" aria-expanded="true" aria-controls="reset-react-editor"></a>
    ${_T(['#ruleedit-resetreaction-title','Reset Reaction'])}
  </h6>
</div>` )
                .appendTo( $row );
            reaction = rule.react_reset;
            if ( !reaction ) {
                rule.react_reset = reaction = { id: rule.id + ':R', rule: rule.id, set: false, actions: [] };
                this.configModified = true;
            }
            delete reaction.name;
            const reset_editor = new ReactionEditor( reaction, $container, { fixedName: "", contextRule: rule } );
            this.editors.reset_editor = reset_editor;
            reset_editor.editor()
                .attr( 'id', 'reset-react-editor' )
                .addClass( "collapse show" )
                .on( 'update-controls', function( ev, ui ) {
                    /* During editing, various changes make the reaction saveable, or not. */
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'modified', function( ev, ui ) {
                    /* Sent when the cached reaction is modified (no errors) */
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'ready', function( ev, ui ) {
                    ui.editor.editor().collapse( (reaction.actions || []).length > 0 ? "show" : "hide" );
                });

            /* Expressions editor */
            $container = $( '<div id="ct-expr"></div>' ).appendTo( this.$tab );
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( `<div class="col">
  <h6>
    <a class="bi-btn bi-chevron-down mx-1" data-bs-toggle="collapse" data-bs-target="#expr-editor"
       role="button" aria-expanded="true" aria-controls="expr-editor"></a>
    ${_T(['#ruleedit-expressions-title','Local Expressions'])}
  </h6>
</div>` )
                .appendTo( $row );
            const expressions = rule.expressions || {};
            const expr_editor = new ExpressionEditor( expressions, $container, { isGlobal: false, contextRule: rule } );
            this.editors.expr_editor = expr_editor;
            expr_editor.editor()
                .attr( 'id', 'expr-editor' )
                .addClass( "collapse show" )
                .on( 'update-controls', function( ev, ui ) {
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'modified', function( ev, ui ) {
                    /* Sent when data modified */
                    self.configModified = self.configModified || ui.modified;
                    self.updateSaveControls();
                }).on( 'ready', function( ev, ui ) {
                    ui.editor.editor().collapse( Common.hasAnyProperty( expressions ) ? "show" : "hide" );
                });

            // Rule ID displayed at the bottom of the page.
            $row = $( '<div class="row re-sectionhead g-0 align-items-center"></div>' ).appendTo( $container );
            $( '<span class="re-rule-id-label">Rule Id: <span class="re-rule-id-show user-select-all">-----</span></span>' ).appendTo( $row );
            $( '.re-rule-id-show', $row ).text( rule.id );

            /* Show/hide collapsibles */
            $( '.collapse', this.$tab ).on( 'show.bs.collapse', function( ev ) {
                const $el = $( ev.currentTarget );
                const id = $el.attr( 'id' );
                $( 'a[data-bs-toggle="collapse"][data-bs-target="#' + idSelector( id ) + '"]', this.$tab )
                    .removeClass( 'bi-chevron-up' ).addClass( 'bi-chevron-down' );
                /* Remove hidden content warning badge (see below) */
                const $ee = $( 'div.re-embeddable-editor#' + idSelector( id ) );
                const $h = $ee.parent().children( 'div.re-sectionhead' );
                $h.find( 'span.badge' ).remove();
            }).on( 'hidden.bs.collapse', function( ev ) {
                const $el = $( ev.currentTarget );
                const id = $el.attr( 'id' );
                $( 'a[data-bs-toggle="collapse"][data-bs-target="#' + idSelector( id ) + '"]', this.$tab )
                    .removeClass( 'bi-chevron-down' ).addClass( 'bi-chevron-up' );
                /* Place hidden content warning badge if editor is not empty. */
                const $ee = $( 'div.re-embeddable-editor#' + idSelector( id ) );
                const $h = $ee.parent().children( 'div.re-sectionhead' );
                if ( $ee.data( "embeddable-editor" ).isEmpty() ) {
                    $h.find( 'span.badge' ).remove();
                } else {
                    /* Color of badge depends on status; red for error in section, green for modified data, info/blue otherwise */
                    $( '<span class="badge fw-normal"><i class="bi bi-eye-slash-fill"></i></span>' )
                        .attr( 'title', _T('Content hidden') )
                        .addClass( 0 !== $( '.tberror', $ee ).length ? 'bg-danger' : (
                            0 !== $( '.tbmodified', $ee ).length ? 'bg-success' : 'bg-info' ) )
                        .appendTo( $( 'div.col h6', $h ) );
                }
            });

            trig_editor.edit();
            const_editor.edit();
            set_editor.edit();
            reset_editor.edit();
            expr_editor.edit();

            $( 'button.saveconf,button.revertconf', this.$tab )
                .on( 'click.reactor', this.handleSaveRevertClick.bind( this ) );

            this.configModified = this.newrule;
            this.updateSaveControls();
        }

        handleRuleDrop( ev, ui ) {
            const $dragged = $( ui.draggable );
            const $droppedon = $( ev.target );
            // console.log($dragged, $droppedon);
            const dragid = $dragged.attr( 'id' ).replace( /^rule-/, "" );
            const dropid = $droppedon.find( 'a.nav-link' ).attr( 'id' );
            const source_ruleset_id = $( 'div#rules' ).data( 'ruleset' );
            if ( dropid === source_ruleset_id ) {
                /* No change */
                return;
            }
            const source_ruleset = Ruleset.getInstance( source_ruleset_id );
            const ix = source_ruleset.getRuleIDs().indexOf( dragid );
            if ( ix >= 0 ) {
                const dest_ruleset = Ruleset.getInstance( dropid );
                const dobj = dest_ruleset.getDataObject();
                /* Modify Data objects in place. Add to dest first. */
                dobj.value.rules.push( dragid );
                dobj.save().then( () => {
                    const dobj = source_ruleset.getDataObject();
                    dobj.value.rules.splice( ix, 1 );
                    dobj.save();
                    $( 'div.re-listhead button#deleteruleset', this.$tab )
                        .prop( 'disabled', source_ruleset.value.rules.length > 0 );
                });
            }
        }

        async handleRuleSort( ev, ui ) {  // eslint-disable-line no-unused-vars
            const $container = $( 'div#rules' );
            const ruleset_id = $container.data( 'ruleset' );
            const ruleset = await Ruleset.getInstance( ruleset_id );
            const $items = $( '.re-rule', $container );
            const rsdata = ruleset.getDataObject();
            rsdata.value.rules.length = 0; /* clear array in place */
            $items.each( ( ix, obj ) => {
                const id = $( obj ).attr( 'id' ).replace( /^rule-/, "" );
                rsdata.value.rules.push( id );
            });
            rsdata.save();
        }

        /**
         *  Called when a (drag-sorted) ruleset is dropped in some position.
         *  Reorder the rulesets to the displayed order.
         */
        async handleRulesetSort( ev, ui ) {  // eslint-disable-line no-unused-vars
            const rulesets = await Rulesets.getInstance();
            const rsdata = rulesets.getDataObject();
            /* The ruleset's DO value is just an array of ruleset IDs */
            rsdata.value.length = 0; /* clear in place */
            const $items = $( 'div#offcanvasRulesets ul li.nav-item' );
            $items.each( ( ix, obj ) => {
                const id = $('a.nav-link', obj).attr( 'id' );
                rsdata.value.push( id );
            });
            rsdata.save();
        }

        async generateCondStatePopperContent( rule, condid ) {
            const cstate = await rule.getStates();
            const st = cstate[ condid ];
            const $ul = $( '<ul></ul>' );
            if ( st ) {
                $( '<li></li>' )
                    .html( _T( ["#pop-condval-current", "Current Value: {0}<br>at {1}"],
                            JSON.stringify( st.lastvalue ).replace( /[\x26\x0A\x3c\x3e\x22\x27]/g, (r) => "&#" + r.charCodeAt(0) + ";" ),
                            new Date( st.laststamp ).toLocaleString()
                        )
                    ).appendTo( $ul );
                if ( "undefined" !== typeof st.prevvalue ) {
                    $( '<li></li>' )
                        .html( _T( ["#pop-condval-previous", "Previous Value: {0}<br>at {1}"],
                                JSON.stringify( st.prevvalue ).replace( /[\x26\x0A\x3c\x3e\x22\x27]/g, (r) => "&#" + r.charCodeAt(0) + ";" ),
                                st.prevstamp ? new Date( st.prevstamp ).toLocaleString() : "?"
                            )
                        ).appendTo( $ul );
                }
            } else {
                $ul.empty().text( _T( [`No State Data Available`] ) );
            }
            return $ul;
        }

        async showRuleStateData( rule, $row, initial ) {
            //console.log( "Update display for", rule, "row", $row, `initial=${initial}` );
            const cstate = await rule.getStates();
            const $st = $( 'div.re-rulestatus', $row );
            let last = $st.data( 'laststate' );
            let state = (cstate.rule || {}).evalstate;
            $st.data( 'laststate', state );
            let changed = last !== state;
            if ( "undefined" === typeof state ) {
                $st.text( '-' );
            } else if ( null === state ) {
                $st.text( _T(['#rule-state-null','(none)']) );
            } else {
                $st.text(
                    ( ( cstate.rule.evalstate ? _T(['#rule-state-true','SET']) : _T(['#rule-state-false','reset']) ) ) +
                    ' ' +
                    _T( 'since {0}', shortTime( cstate.rule.evalstamp ), cstate.rule.evalstamp )
                );
            }
            $( 'div.re-rulename span', $row ).text( rule.name || rule.id );
            $row.toggleClass( 'rule-disabled', !!rule.disabled );
            $( 'button.ruleEnable i', $row )
                .toggleClass( 'bi-toggle-off', !!rule.disabled )
                .toggleClass( 'bi-toggle-on', !rule.disabled );
            $row.toggleClass( 'rule-override', undefined !== rule.override );
            $row.toggleClass( 'rule-attention', undefined !== rule.override );

            if ( undefined !== rule.override ) {
                $( '<span class="rule-override-state"></span>' )
                    .text( _T( ['#rule-override-attn-text', 'OVERRIDE STATE'] ) )
                    .prependTo( $st );
            }

            if ( changed && !initial ) {
                $row.stop().clearQueue().css( 'background-color', 'var(--bs-green)' );
                $row.animate( { backgroundColor: 'transparent' }, 2000);
            }

            $row.toggleClass( 'trouble', !!( cstate.rule || {} ).trouble );
            if ( ( cstate.rule || {} ).trouble ) {
                $row.addClass( 'rule-attention' );
                if ( 0 === $( '.col.re-rulename i.trouble-icon', $row ).length ) {
                    $( '.col.re-rulename', $row )
                        .prepend( '<i class="bi bi-exclamation-triangle-fill trouble-icon me-1"></i>' );
                }
            } else {
                $( '.col.re-rulename i.trouble-icon', $row ).remove();
            }

            // Details only show if (collapsible) card is displayed.
            const $visel = $( 'div.collapse', $row );
            if ( $visel.hasClass( 'show' ) ) {
                Object.keys( cstate ).forEach( async (condid)  => {
                    const $line = $( '.card div#' + idSelector( rule.id + "-cond-" + condid ), $row );
                    const $el = $( 'span.currentvalue', $line );
                    // Skip special container groups with no real value (but still have stored state).
                    if ( 0 === $el.length ) {
                        return;
                    }
                    const st = cstate[ condid ] || {};

                    $el.toggleClass( "text-danger", !!st.error );
                    if ( st.error ) {
                        $el.text( _T(['#expr-status-error', '(error) {0}'], st.error) );
                        return;
                    }

                    let evt = Common.coalesce( st.lastvalue );
                    let tt = typeof evt;

                    /* Remapping/humanizing of values */
                    switch ( $el.closest( 'div' ).data( 'cond-type' ) ) {

                        case 'comment':
                            $( 'div.condind i', $line ).hide();
                            $el.text( "" );
                            return; /* display nothing */

                        case 'weekday':
                            /* Weekday stores a numeric day of the week */
                            if ( evt ) {
                                evt = weekdayNames[ evt ] || evt;
                                tt = typeof "";
                            }
                            break;

                        case 'sun':
                        case 'trange':
                        case 'interval':
                            /* Timestamps */
                            if ( evt ) {
                                try {
                                    evt = shortTime( evt );
                                    tt = "moment";
                                } catch ( err ) {  // eslint-disable-line no-unused-vars
                                    console.log( "Invalid timestamp", evt );
                                }
                            }
                            break;

                        default:
                            /* Nada */
                    }
                    if ( "boolean" === typeof evt ) {
                        evt = _T( String( evt ) );
                    } else if ( evt === evt ) {
                        evt = JSON.stringify( evt );
                    } else if ( null !== evt && "object" === typeof evt ) {
                        try {
                            evt = JSON.stringify( evt );
                        } catch ( err ) {
                            console.error( "Failed to stringify():", err, evt );
                        }
                    } else if ( "string" !== typeof evt ) {
                        evt = String( evt );
                    }
                    evt = `(${tt}) ${evt}`;
                    let est = _T( '{0} -- {1} as of {2}', evt, _T( String( Common.coalesce( st.evalstate ) ) ),
                        shortTime( st.evalstamp ), st.evalstamp );
                    $el.text( est )
                        .attr( 'title', String( evt ) );
                    $line.toggleClass( 'truestate', true === st.evalstate )
                        .toggleClass( 'falsestate', false === st.evalstate )
                        .toggleClass( 'nullstate', null === st.evalstate || "boolean" !== typeof st.evalstate );
                    $( 'div.condind i', $line ).toggleClass( 'bi-check-square text-success', true === st.evalstate )
                        .toggleClass( 'bi-x-square text-danger', false === st.evalstate )
                        .toggleClass( 'bi-square text-muted', null === st.evalstate || "boolean" !== typeof st.evalstate );

                    if ( "undefined" !== typeof st.repeats && st.repeats.length > 1 ) {
                        const dtime = Math.floor( ( st.repeats[ st.repeats.length - 1 ] - st.repeats[0] ) / 100 ) / 10;
                        $el.append( `<span ${_T(['#cond-status-repeats','(last {0} span {1:.1f} seconds)'], st.repeats.length, dtime)}</span>` );
                    }
                    if ( ( st.pulsecount || 0 ) > 0 ) {
                        const lim = 0; // ( cond.options||{} ).pulsecount || 0;
                        $el.append( `<span> ${_T(['#cond-status-pulsed','(pulsed {0:d?<999}{0:"&gt;999"?!<999}{1:" of max "?>0}{1:d?>0} times)'],
                            st.pulsecount, lim)}</span>` );
                    }
                    if ( st.latched ) {
                        $el.append( `<span> ${_T(['#cond-status-latched','(latched)'])}</span>` );
                    }

                    /* Generate unique IDs for timers so that redraws will have
                       different IDs, and the old timers will self-terminate. */
                    const self = this;
                    if ( rule.disabled ) {
                        $( 'span.timer', $el ).remove();
                        $line.removeClass( 'reactor-timing' ).stop( true, true );
                    } else if ( st.laststate && st.waituntil ) {
                        let id = Common.getUID( 'timer-' );
                        $line.addClass('reactor-timing');
                        $('<span class="timer"></span>').attr( 'id', id ).appendTo( $el );
                        (function( c, t, l ) {
                            setTimeout( function() { self.updateTime( c, t, _T(['#cond-status-sustained','sustained']), false, l ); }, 20 );
                        })( id, st.statestamp, st.duration );
                    } else if ( st.evalstate && st.holduntil ) {
                        let id = Common.getUID( 'timer-' );
                        $line.addClass('reactor-timing');
                        $('<span class="timer"></span>').attr( 'id', id ).appendTo( $el );
                        (function( c, t, l ) {
                            setTimeout( function() { self.updateTime( c, t, _T(['#cond-status-resetdelay','reset delay']), true, l ); }, 20 );
                        })( id, st.holduntil, 0 );
                    } else if ( st.pulsenext ) {
                        let id = Common.getUID( 'timer-' );
                        $line.addClass('reactor-timing');
                        $('<span class="timer"></span>').attr( 'id', id ).appendTo( $el );
                        (function( c, t, l ) {
                            setTimeout( function() { self.updateTime( c, t, _T(['#cond-status-pulsebreak','break']), true, l ); }, 20 );
                        })( id, st.pulsenext, 0 );
                    } else if ( st.pulseuntil ) {
                        let id = Common.getUID( 'timer-' );
                        $line.addClass('reactor-timing');
                        $('<span class="timer"></span>').attr( 'id', id ).appendTo( $el );
                        (function( c, t, l ) {
                            setTimeout( function() { self.updateTime( c, t, _T(['#cond-status-pulsing','pulse']), true, l ); }, 20 );
                        })( id, st.pulseuntil, 0 );
                    } else {
                        $( 'span.timer', $el ).remove();
                        $line.removeClass( 'reactor-timing' ).stop( true, true );
                    }

                    if ( st.next_edge ) {
                        $( `<span>; ${_T(['#cond-status-waiting-time','waiting for {0}'], shortTime( st.next_edge ), st.next_edge)}</span>` )
                            .appendTo( $el );
                    }

                    // Update condition value popper
                    try {
                        const $ul = await this.generateCondStatePopperContent( rule, condid );
                        const popper = bootstrap.Popover.getOrCreateInstance( $el,
                            {
                                title: "",
                                content: "&nbsp;",
                                html: true,
                                delay: 500,
                                placement: 'bottom',
                                trigger: 'hover focus',
                                container: '#tab-rules',
                                customClass: 're-condstate-popper'
                            }
                        );
                        popper.setContent({
                            '.popover-header': "",
                            '.popover-body': $ul
                        });
                    } catch ( err ) {
                        console.error( "Failed to create popover for", rule, "state:", err );
                    }
                });

                //console.log("update expr",rule.expressions,cstate.expr);
                $( 'div.expr-status div.expr-status-expr', $row ).each( ( ix, obj ) => {
                    const $ex = $( obj );  // this is the row element for the var/expr
                    const name = $ex.data( 'name' ) || "";
                    const expr = ( rule.expressions || {} )[ name ];
                    if ( expr && ( cstate.expr || {})[ name ] ) {
                        let st = cstate.expr[ name ] || {};
                        const lastval = st.lastvalue;
                        let val = "";
                        let stamp = "-";
                        let typ = Array.isArray( lastval ) ? 'array-len' : typeof lastval;
                        if ( st.error ) {
                            typ = _T( "#expr-status-error", "" );
                            val = String( st.error );
                        } else if ( null === lastval ) {
                            typ = "(" + _T( "null" ) + ")";
                        } else {
                            typ = "(" +
                                _T( ['#data-type-' + typ, typ], Array.isArray( lastval ) ? lastval.length : 0 ) +
                                ") ";
                            try {
                                val = JSON.stringify( lastval );
                            } catch ( err ) {  // eslint-disable-line no-unused-vars
                                val = String( lastval );
                            }
                        }
                        if ( ( st.laststamp || 0 ) > 0 ) {
                            stamp = _T( 'since {0}', shortTime( st.laststamp ), st.laststamp );
                        }
                        $( 'div.expr-value', $ex ).attr( 'title', val ).toggleClass( 'text-danger', !!st.error )
                        $( 'div.expr-value span.expr-value-type', $ex ).text( typ );
                        $( 'div.expr-value span.expr-value-value', $ex ).text( val );
                        $( 'div.expr-stamp', $ex ).text( stamp );

                        if ( $ex.data( 'since' ) !== ( st.laststamp || 0 ) ) {
                            //console.log( "Rule", $ex.closest( 'div.re-rule' ).attr( 'id' ), "variable", name, "changed" );
                            $ex.data( 'since', st.laststamp || 0 );
                            $ex.stop().clearQueue().css( 'background-color', 'var(--bs-green)' );
                            $ex.animate( { backgroundColor: 'transparent' }, 2000);
                        }
                    } else {
                        console.log("Rule var",name," no data: expr=",expr,"cstate=",cstate);
                        $( 'div.expr-value', $ex ).removeClass( 'text-danger' );
                        $( 'div.expr-value span.expr-value-type', $ex )
                            .text( "" === ( expr.expr || "" ) ? _T('#expr-status-not-set') : _T('#expr-status-not-eval') );
                        $( 'div.expr-value span.expr-value-value', $ex ).text( "" );
                        $( 'div.expr-stamp', $ex ).text( "-" );
                    }
                });

                await this.updateRuleHistory( rule );
            } else {
                // console.log("detail card not visible for", rule);
            }
        }

        async handleDataObjectChange( msg ) {
            if ( "rule-state-changed" === msg.type || "rule-changed" === msg.type ) {
                const ruleid = msg.data.getID();
                const $row = $( `div#rules div.re-rule#${ idSelector( 'rule-' + ruleid ) }` );
                const rule = await Rule.getInstance( ruleid );
                const rs = await rule.getRuleset();
                if ( rs.getID() !== this.showing_ruleset ) {
                    $row.remove();  // rule moved to another ruleset
                } else {
                    this.showRuleStateData( msg.data, $row, false );
                }
            } else if ( "rule-deleted" === msg.type ) {
                const $row = $( `div#rules div.re-rule#${idSelector( 'rule-' + msg.data.id )}` );
                $row.remove();
            }
        }

        async handleRuleEnableToggle( ev ) {
            const $el = $( ev.currentTarget );
            const $row = $el.closest( '.re-rule' );
            const id = $row.attr( 'id' ).replace( /^rule-/, "" );
            const rule = await Rule.getInstance( id );
            api.enableRule( id, !!rule.disabled ).then( data => {
                const newstate = !!data.value.triggers.disabled;
                $row.toggleClass( 'rule-disabled', newstate );
                $( 'button.ruleEnable i', $row )
                    .toggleClass( 'bi-toggle-off', newstate )
                    .toggleClass( 'bi-toggle-on', !newstate );
            });
        }

        async handleRuleTools( ev ) {
            const $el = $( ev.currentTarget );
            const $row = $el.closest( '.re-rule' );
            const id = $row.attr( 'id' ).replace( /^rule-/, "" );
            $el.addClass( "active" );
            const rule = await Rule.getInstance( id );
            if ( $el.hasClass( 'rule-override-none' ) ) {
                await rule.setOverride( undefined );
            } else if ( $el.hasClass( 'rule-override-set' ) ) {
                await rule.setOverride( true );
            } else if ( $el.hasClass( 'rule-override-reset' ) ) {
                await rule.setOverride( false );
            }
            $el.removeClass( "active" );
            return false;
        }

        async handleRuleReset( ev ) {
            const $el = $( ev.currentTarget );
            const $row = $el.closest( '.re-rule' );
            const id = $row.attr( 'id' ).replace( /^rule-/, "" );
            $el.addClass( "active" );
            const rule = await Rule.getInstance( id );
            await rule.reset().finally( () => {
                $el.removeClass( "active" );
            });
            return false;
        }

        async handleRuleCopy( ev ) {
            const $el = $( ev.currentTarget );
            const $row = $el.closest( '.re-rule' );
            const id = $row.attr( 'id' ).replace( /^rule-/, "" );
            const rule = await Rule.getInstance( id );
            const rulesets = await Rulesets.getRulesets();
            const $mm = $( '<select id="rulesetlist" class="form-select form-select-sm ms-1"></select>' );
            $( '<option></option>' ).val( "" ).text( _T('(to current rule set)') ).appendTo( $mm );
            let current_ruleset = false;
            rulesets.forEach( set => {
                if ( null === set ) { /* unable to fetch set in sequence */
                    return;
                }
                if ( set.getRuleIDs().indexOf( rule.getID() ) >= 0 ) {
                    current_ruleset = set;
                    return;
                }
                $( '<option></option>' ).val( set.id ).text( set.name || set.id )
                    .appendTo( $mm );
            });
            const $div = $( '<form></form>' );
            $( `<label for="rulesetlist">${_T(['#rule-move-target-label','Destination Rule Set:'])}</label>` )
                .appendTo( $div );
            $mm.appendTo( $div );
            showSysModal( {
                title: _T(['#rule-move-title','Copy/Move Rule']),
                body: $div,
                buttons: [{
                    label: _T(['#rule-move-button-move','Move']),
                    event: "move",
                    class: "btn-warning"
                },{
                    label: _T(['#rule-move-button-copy','Copy']),
                    event: "copy",
                    class: "btn-success"
                },{
                    label: _T(['#rule-move-button-cancel','Cancel']),
                    close: true,
                    class: "btn-primary"
                }]
            }).then( async event => {
                let rsid = $( '#sysdialog select#rulesetlist' ).val() || "";
                if ( "move" === event ) {
                    if ( isEmpty( rsid ) || rsid === current_ruleset.id ) {
                        /* Already in dest ruleset */
                        return event;
                    }
                    let dest_ruleset = await Ruleset.getInstance( rsid );
                    await rule.move( dest_ruleset );
                    $row.remove();
                } else if ( "copy" === event ) {
                    /* Synthesize a new rule with a new ID in target ruleset */
                    let dest_ruleset;
                    if ( isEmpty( rsid ) ) {
                        dest_ruleset = current_ruleset;
                    } else {
                        dest_ruleset = await Ruleset.getInstance( rsid );
                    }
                    await rule.copy( dest_ruleset );
                    if ( isEmpty( rsid ) ) {
                        /* Repaint current ruleset to show new rule here. */
                        $( `div#offcanvasRulesets ul.nav a#${idSelector( current_ruleset.id )}.nav-link` ).click();
                    }
                } else {
                    /* Cancel */
                }
            });
            return false;
        }

        async handleRuleDelete( ev ) {
            const $el = $( ev.currentTarget );
            const $row = $el.closest( '.re-rule' );
            const id = $row.attr( 'id' ).replace( /^rule-/, "" );
            try {
                const rule = await Rule.getInstance( id );
                showSysModal( {
                    title: _T(['#rule-delete-title','Delete Rule']),
                    body: _T(['#rule-delete-prompt','Really delete {0:q}?'], rule.name || id),
                    buttons: [{
                        label: _T(['#rule-delete-button-delete','Delete']),
                        event: "delete",
                        class: "btn-danger"
                    },{
                        label: _T(['#rule-delete-button-cancel','Cancel']),
                        close: true,
                        class: "btn-success"
                    }]
                }).then( event => {
                    if ( "delete" === event ) {
                        rule.delete().then( () => {
                            $row.remove();
                            $( 'div.re-listhead button#deleteruleset', this.$tab )
                                .prop( 'disabled', $( 'div#rules .re-rule' ).length > 0 );
                        }).catch( err => {
                            console.error( err );
                            showSysModal( {
                                title: _T(['#dialog-error-title','Error']),
                                body: _T(['#rule-delete-error','An error occurred while deleting the rule.'])
                            } );
                        });
                    }
                });
            } catch ( err ) {
                console.error( err );
                showSysModal( {
                    title: _T(['#dialog-error-title','Error']),
                    body: _T('The operation cannot be completed at this time.')
                } );
            }
            return false;
        }

        updateTime( elid, target, prefix, countdown, limit ) {
            const $el = $( '#' + idSelector( elid ) );
            if ( 0 === $el.length ) {
                return; /* quietly die */
            }
            const now = Date.now();
            let d;
            if ( countdown ) {
                /* Count down -- delta is (future) target to now */
                d = target - now;
                if ( d < ( limit || 0 ) ) {
                    let $line = $el.closest( ".cond" );
                    $el.remove();
                    if ( 0 === $( 'span.timer', $line ).length ) {
                        // Stop animation if no timers remain
                        $line.removeClass( 'reactor-timing' ).stop( true, true );
                    }
                    return;
                }
            } else {
                /* Count up -- delta is now since target */
                d = now - target;
                if ( limit && d > limit ) {
                    let $line = $el.closest( ".cond" );
                    $el.remove();
                    if ( 0 === $( 'span.timer', $line ).length ) {
                        // Stop animation if no timers remain
                        $line.removeClass( 'reactor-timing' ).stop( true, true );
                    }
                    return;
                }
            }
            d = Math.floor( d / 1000 + 0.5 );
            const hh = Math.floor( d / 3600 );
            d -= hh * 3600;
            const mm = Math.floor( d / 60 );
            d -= mm * 60;
            d = (mm < 10 ? '0' : '') + String(mm) + ':' + (d < 10 ? '0' : '') + String(d);
            if ( 0 !== hh ) {
                d = (hh < 10 ? '0' : '') + String(hh) + ':' + d;
            }
            $el.text( `; ${prefix} ${d}` );
            const self = this;
            setTimeout( function() { self.updateTime( elid, target, prefix, countdown, limit ); }, 500 );
        }

        async showGroupStatus( rule, grp, $container, cstate, pgrp, firstGroup ) {
            let $grpel = $( '\
<div class="reactorgroup"> \
    <div class="row grouptitle"> \
        <div class="col-auto condind"><i class="bi bi-square text-muted"></i></div> \
        <div class="col"><span class="re-title">??</span></div> \
        <div class="col"><span class="currentvalue">??</span></div> \
    </div> \
    <div class="grpbody"> \
        <div class="grpcond"></div> \
    </div>\
</div>' );

            let name;
            if ( "trig" === grp.id || grp.id.endsWith( '-trig' ) ) {
                name = _T(['#ruleedit-triggers-title','Triggers']);
            } else if ( grp.id.match( /^const?$/ ) || grp.id.endsWith( '-const' ) ) {
                name = _T(['#ruleedit-constraints-title','Constraints']);
            } else {
                name = grp.name ? grp.name : _T('Group {0}');
            }
            const grp_op_word = _T(['#cond-group-op-' + ( grp.op || "and" ), grp.op || "and"]);

            let title = "";
            if ( pgrp && ! firstGroup ) {
                title = _T(['#cond-group-op-' + ( pgrp.op || "and" ), pgrp.op || "and"])
                    .toLocaleUpperCase( getLocale() ) + ' ';
            }
            title += name + ' ';
            title += await RuleEditor.makeCondOptionDesc( rule, grp );
            title += ' [' +
                ( grp.invert ? `${_T(['#cond-group-op-not', 'NOT']).toLocaleLowerCase( getLocale() )} ` : "" ) +
                grp_op_word.toLocaleLowerCase( getLocale() ) + ']' +
                ( grp.disabled ? ` (${_T(['#cond-group-state-disabled','DISABLED'])})` : "" );
            // $( '.condbtn', $grpel ).text( (grp.invert ? "NOT " : "") + (grp.op || "and" ).toUpperCase() ); // ???
            $( 'span.re-title', $grpel ).text( title );
            $( 'span.currentvalue', $grpel ).text( _T(['#group-status-no-data','(no data)']) ); /* temporarily */

            $( '.row', $grpel ).attr( 'id', rule.id + '-cond-' + grp.id ); /* for status updates */

            /* Highlight groups that are "true" */
            $grpel.toggleClass( 'groupdisabled', !!grp.disabled );
            if ( !grp.disabled ) {
                const gs = cstate[ grp.id ] || {};
                if ( "undefined" === typeof gs.evalstate || null === gs.evalstate ) {
                    $grpel.addClass( "nostate" );
                } else if ( gs.evalstate ) {
                    $grpel.addClass( "truestate" );
                }
            }
            $container.append( $grpel );

            $grpel = $( 'div.grpcond', $grpel );
            const l = grp.conditions ? grp.conditions.length : 0;
            let first = true;
            for ( let i=0; i<l; i++ ) {
                const cond = grp.conditions[i];

                if ( "group" === ( cond.type || "group" ) ) {
                    this.showGroupStatus( rule, cond, $grpel, cstate, grp, i === 0 );
                } else {
                    const $row = $('<div class="row cond"></div>').attr( 'id', rule.id + '-cond-' + cond.id ); /* for status updates */

                    let condType = condTypeName[ cond.type ] !== undefined ? condTypeName[ cond.type ] : cond.type;
                    let condDesc = await RuleEditor.makeConditionDescription( rule, cond );

                    /* Operator/condition state */
                    let $el = $( '<div class="col-auto condind"><i class="bi bi-square text-muted"></i></div>' )
                        .appendTo( $row );

                    /* Condition description. Apply options to condition description */
                    if ( cond.type !== "comment" ) {
                        if ( ! first ) {
                            condType = grp_op_word.toLocaleUpperCase( getLocale() ) + " " + condType;
                        }
                        first = false;
                    }

                    let copt = await RuleEditor.makeCondOptionDesc( rule, cond );
                    if ( "" !== copt ) {
                        condDesc += '; ' + copt;
                    }

                    if ( "comment" === cond.type ) {
                        $row.append( $( '<div class="col"></div>' )
                            .append( $( '<span></span>' ).text( condType ) )
                            .append( $( '<span>: </span>' ) )
                            .append( $( '<tt></tt>' ).text( condDesc.trim() ) )
                        );
                    } else {
                        $row.append( $( '<div class="col cond-desc"></div>' ).text( condType + ': ' + condDesc ) );
                    }

                    /* Append current value and condition state */
                    $el = $( `<div class="col cond-status"></div>` )
                        .data( 'cond-type', cond.type ).attr( 'data-cond-type', cond.type )
                        .appendTo( $row );
                    $( `<span class="currentvalue"></span>` )
                        .text( _T(['#group-status-no-data','(no data)']) )
                        .appendTo( $el );

                    /* Apply highlight for state */
                    const cs = cstate[ cond.id ] || {};
                    // if ( cond.type !== "comment" && undefined !== currentValue ) {
                    if ( true ) {  // eslint-disable-line no-constant-condition
                        $row.toggleClass( 'truestate', true === cs.evalstate )
                            .toggleClass( 'falsestate', false === cs.evalstate )
                            .toggleClass( 'nullstate', null === cs.evalstate || "boolean" !== cs.evalstate );
                        $( 'div.condind i', $row )
                            .toggleClass( "bi-check-square-fill text-success", true === cs.evalstate )
                            .toggleClass( "bi-x-square text-danger", false === cs.evalstate )
                            .toggleClass( "bi-square text-muted", null === cs.evalstate || "boolean" !== cs.evalstate );
                    }

                    $grpel.append( $row );
                }
            }
        }

        showExprStatus( rule, $ct, st ) {
            let exp = rule.expressions;
            let elist = Object.values( exp );
            if ( 0 === elist.length ) {
                return;
            }
            elist.sort( ( a, b ) => {
                let n1 = "undefined" === typeof a.index ? 32767 : a.index;
                let n2 = "undefined" === typeof b.index ? 32767 : b.index;
                if ( n1 === n2 ) {
                    return 0;
                }
                return n1 < n2 ? -1 : 1;
            });

            const $ex = $( '<div class="expr-status mb-2"></div>' ).appendTo( $ct );
            $( `<div class="row expr-status-title"><div class="col">${_T(['#ruleedit-expressions-title','Local Expressions'])}</div></div>` )
                .appendTo( $ex );

            elist.forEach( expr => {
                let $row = $( '<div class="row expr-status-expr"></div>' )
                    .attr( 'id', rule.id + '-expr-' + expr.name )
                    .data( 'expr', expr.name ).attr( 'data-name', expr.name )
                    .appendTo( $ex );
                let es = ( st.expr || {} )[ expr.name ];
                let lastval = es?.lastvalue;
                let error = !!es?.error;
                let val = "";
                let typ = `(${typeof lastval}) `;
                let stamp = "-";
                if ( error ) {
                    typ = _T( "#expr-status-error", "" );
                    val = String( es.error );
                } else if ( ! es || "undefined" === typeof lastval ) {
                    typ = "" === ( expr.expr || "" ) ? _T('#expr-status-not-set') : _T('#expr-status-not-eval');
                } else {
                    if ( ( es.laststamp || 0 ) > 0 ) {
                        stamp = _T( 'since {0}', shortTime( es.laststamp ), es.laststamp );
                    }
                    if ( null === lastval ) {
                        typ = "(" + _T( "null" ) + ")";
                    } else {
                        typ = Array.isArray( lastval ) ? 'array-len' : typeof lastval;
                        typ = "(" +
                            _T( ['#data-type-' + typ, typ], Array.isArray( lastval ) ? lastval.length : 0 ) +
                            ") ";
                        try {
                            val = JSON.stringify( lastval );
                        } catch ( err ) {
                            console.log( "Can't JSON.stringify", lastval, err);
                            val = String( lastval );
                        }
                    }
                }
                $row.data( 'since', es?.laststamp || 0 );
                $( '<div class="col-auto expr-name"></div>' ).text( expr.name ).appendTo( $row );
                $( '<div class="col expr-expr text-break user-select-all"></div>' ).text( expr.expr || "" ).appendTo( $row );
                let $el = $( '<div class="col expr-value"></div>' )
                    .toggleClass( "text-danger", error )
                    .appendTo( $row );
                $( '<span class="expr-value-type"></span>' ).text( typ )
                    .appendTo( $el );
                $( '<span class="expr-value-value text-break user-select-all"></span>' ).text( val )
                    .attr( 'title', val )
                    .appendTo( $el );
                $( '<div class="col-auto expr-stamp text-break"></div>' ).text( stamp ).appendTo( $row );
            });
        }

        async updateRuleHistory( rule ) {
            const $row = $( `div#rule-${idSelector(rule.id)}.re-rule` );
            const $ct = $( '.re-rule-history', $row ).empty();
            try {
                const rh = await rule.getHistory();
                const df = new Intl.DateTimeFormat( undefined, {
                    year: "numeric",
                    month: "numeric",
                    day: "numeric",
                    hour: "numeric",
                    minute: "numeric",
                    second: "numeric",
                    hour12: false,
                    fractionalSecondDigits: 3
                });
                let count = 0;
                for ( let entry of rh.reverse() ) {
                    const $r = $( '<div class="row"></div>' ).appendTo( $ct );
                    const d = new Date( entry.new_stamp );
                    $( '<div class="col-auto font-monospace"></div>' ).text( df.format(d) )
                        .appendTo( $r );
                    $( '<div class="col"></div>' ).text( entry.new_state === null ? _T(['#rule-state-null','(none)']) : (
                        entry.new_state ? _T(['#rule-state-true','SET']) : _T(['#rule-state-false','reset']) ) )
                        .appendTo( $r );
                    if ( ++count >= 25 ) break;
                }
            } catch ( err ) {
                $( '<pre></pre>' ).text( err.stack ).appendTo( $ct );
            }
        }

        async handleRuleStatus( event ) {
            const $row = $( event.currentTarget ).closest( '.re-rule' );
            await this.toggleRuleStatus( $row );
        }

        async toggleRuleStatus( $row ) {
            const rule_id = $row.attr( 'id' ).replace( /^rule-/, "" );
            let $card = $( 'div.card', $row );
            let $col;
            if ( 0 === $card.length ) {
                $card = $( '<div class="card card-body bg-transparent mx-0 my-0"></div>' );
                $( '<div class="w-100"></div>' ).appendTo( $row );
                $col = $( '<div class="col-xs-12 col-sm-12 collapse"></div>' ).appendTo( $row );
                $card.appendTo( $col );
            }
            $col = $card.closest( ".collapse" );
            if ( $col.hasClass( "show" ) ) {
                $( 'div.re-rulename i', $row ).removeClass( 'bi-chevron-down' ).addClass( 'bi-chevron-right' );
                $col.slideUp( 250, function() { $(this).removeClass( "show" ); $card.empty(); } );
            } else {
                let rule = await Rule.getInstance( rule_id );
                await rule.refresh();
                let cstate = await rule.getStates();
                let $ct = $( '<div class="mb-2"></div>' ).appendTo( $card.empty() );
                this.showGroupStatus( rule, rule.triggers, $ct, cstate, undefined, true );
                if ( rule.constraints && ( rule.constraints.conditions || [] ).length > 0 ) {
                    $ct = $( '<div class="mb-2"></div>' ).appendTo( $card );
                    this.showGroupStatus( rule, rule.constraints, $ct, cstate, undefined, true );
                }
                this.showExprStatus( rule, $card, cstate );

                $ct = $( '<div></div>' ).appendTo( $card );
                $( '<span></span>' ).text( _T('Rule ID: {0}', "") ).appendTo( $ct );
                $( '<span class="user-select-all"></span>' ).text( rule.getID() ).appendTo( $ct );

                // Rule History
                $( `<div class="mt-1">Rule History</div>` ).appendTo( $card );
                $ct = $( '<div class="re-rule-history"></div>' ).appendTo( $card );
                await this.updateRuleHistory( rule );

                const self = this;
                $( 'div.re-rulename i', $row ).removeClass( 'bi-chevron-right' ).addClass( 'bi-chevron-down' );
                $col.slideDown( 250, function() {
                    $(this).addClass( "show" );
                    self.showRuleStateData( rule, $row, true );
                } );
            }
        }

        async handleRulesetClick( ev, ui ) {  // eslint-disable-line no-unused-vars
            var $navlink = $( ev.currentTarget );
            var rsid = $navlink.attr( 'id' );
            if ( "edit" === this.$tab.data( "mode" ) ) {
                if ( this.configModified ) {
                    Common.showSysModal( {
                        title: _T('Operation in Progress'),
                        body: _T('Please complete the current operation before switching views'),
                    } );
                    console.log( "Ignoring ruleset click while in editor" );
                    return;
                }
                /* Close editors */
                this.close_editor( rsid );
                return false;
            }

            await this.showRulesetContents( rsid );
        }

        async showRulesetContents( rsid ) {
            this.showing_ruleset = rsid;
            localStorage.setItem( 'last_ruleset', rsid );

            const $container = $( 'div#rules', this.$tab ).empty();
            $( 'div.re-listhead span#rulesetname', this.$tab ).text( _T("Loading...") );

            const set = await Ruleset.getInstance( rsid );
            const rules = await set.getRules();
            for ( const rule of rules ) {
                const $row = $( '<div class="re-rule row align-items-center"></div>' )
                    .attr( 'id', 'rule-' + rule.id )
                    .appendTo( $container );
                $( '<div class="col re-rulename"></div>' )
                    .append( '<span></span>' )
                    .append( '<i class="bi bi-chevron-right ms-1"></i>' )
                    .appendTo( $row );
                $( 'div.re-rulename span', $row ).text( rule.name || rule.id );
                $( '<div class="col re-rulestatus"></div>' )
                    .appendTo( $row );
                $( `<div class="col text-end text-nowrap re-rulecontrols">
  <button class="btn bi-btn ms-1 ruleEnable" title="${_T(['#rule-control-enable','Toggle Enabled'])}"><i class="bi"></i></button>
  <div class="dropdown" style="display: inline-block;">
    <button class="btn bi-btn ms-1 ruleTools text-success" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="${_T(['#rule-control-tools','Tools'])}">
      <i class="bi bi-tools"></i>
    </button>
    <ul class="dropdown-menu">
      <li><a class="dropdown-item rule-override-none" href="#">${_T(['#rule-override-auto','Auto'])}</a></li>
      <li><a class="dropdown-item rule-override-set" href="#">${_T(['#rule-override-set','Override: SET'])}</a></li>
      <li><a class="dropdown-item rule-override-reset" href="#">${_T(['#rule-override-reset','Override: RESET'])}</a></li>
    </ul>
  </div>
  <button class="btn bi-btn ms-1 ruleEdit text-primary" title="${_T(['#rule-control-edit','Edit Rule'])}"><i class="bi bi-pencil"></i></button>
  <button class="btn bi-btn ms-1 ruleReset text-warning" title="${_T(['#rule-control-reset','Reset Rule'])}"><i class="bi bi-bootstrap-reboot"></i></button>
  <button class="btn bi-btn ms-1 ruleCopy text-secondary" title="${_T(['#rule-control-copy','Copy/Move'])}"><i class="bi bi-share"></i></button>
  <button class="btn bi-btn ms-1 ruleDrag text-secondary draghandle" title="${_T(['#rule-control-sort','Sort (click and drag)'])}"><i class="bi bi-arrow-down-up"></i></button>
  <button class="btn bi-btn ms-4 ruleDelete text-danger" title="${_T(['#rule-control-delete','Delete Rule'])}"><i class="bi bi-x"></i></button>
</div>` ).appendTo( $row );
                $row.toggleClass( 'rule-override', undefined !== rule.override );
                $row.toggleClass( 'rule-disabled', !!rule.disabled );
                $row.toggleClass( 'rule-attention', undefined !== rule.override || rule.disabled );

                $( 'button.ruleEnable i', $row )
                    .toggleClass( 'bi-toggle-off', !!rule.disabled )
                    .toggleClass( 'bi-toggle-on', !rule.disabled )
                    .on( 'click.reactor', this.handleRuleEnableToggle.bind( this ) );

                $( 'div.dropdown ul li a', $row ).on( 'click.reactor', this.handleRuleTools.bind( this ) );

                $( 'button.ruleEdit', $row ).on( 'click.reactor', this.edit.bind( this ) );
                $( 'button.ruleReset', $row ).on( 'click.reactor', this.handleRuleReset.bind( this ) );
                $( 'button.ruleCopy', $row ).on( 'click.reactor', this.handleRuleCopy.bind( this ) );
                $( 'button.ruleDelete', $row ).on( 'click.reactor', this.handleRuleDelete.bind( this ) );

                /* Set up state display */
                api.subscribe( rule, this.handleDataObjectChange.bind( this ) );
                this.showRuleStateData( rule, $row, true );
            }

            if ( 0 === rules.length ) {
                $( `<span id="ruleset-empty">${_T('This rule set is empty. Click "Add Rule" to add a rule to it.')}</span>` )
                    .appendTo( $container );
            }

            const reactions = await set.getReactions();
            reactions.sort( function( a, b ) {
                return (a.name || a.id).localeCompare( b.name || b.id, undefined, { sensitivity: 'base' } );
            });
            let $section = $( 'div.ruleset-reaction-list', this.$tab );
            if ( 0 === $section.length ) {
                $section = $( '<div class="ruleset-reaction-list mt-4"></div' ).appendTo( this.$tab );
                let rl = new ReactionList( this.$tab, $section, _T( "#nav_reactions" ) );
                $section.data( 'ReactionList', rl );
                rl.show( reactions, set );
                $( 'div.re-listport-head', $section ).addClass( "re-listhead" );
            } else {
                let rl = $section.data( 'ReactionList' );
                rl.show( reactions, set );
            }

            $container.data( 'ruleset', set.id );
            $( 'div.re-listhead span#rulesetname', this.$tab ).text( set.name || set.id );
            $( 'div.re-listhead button#deleteruleset', this.$tab )
                .prop( 'disabled', set.rules.length > 0 || set.reactions.length > 0);

            $( '.re-rulename', $container ).on( 'click.reactor', this.handleRuleStatus.bind( this ) );

            const self = this;
            api.off( "structure_update.rulestab" ).on( "structure_update.rulestab", () => {
                console.log( "rules tab: refreshing after structure update", self );
                $( '.re-rule', $container ).each ( (ix, obj) => {
                    const rid = $( obj ).attr( 'id' ).replace( /^rule-/, "" );
                    Rule.getInstance( rid ).then( rule => {
                        self.showRuleStateData( rule, $( obj ), false );
                    }).catch( err => {
                        console.error( "Can't fetch", rid, err );
                    });
                });
            });
        }

        async handleRulesetRenameEvent( ev ) {  // eslint-disable-line no-unused-vars
            const rsid = $( 'div#rules' ).data( 'ruleset' );
            const set = await Ruleset.getInstance( rsid );
            const $form = $( '<div class="form-group"><input id="newname" class="form-control form-control-sm"></div>' );
            $( 'input', $form ).val( set.name || set.id );
            showSysModal({
                title: _T(['#ruleset-rename-title','Rename Rule Set']),
                body: $form,
                buttons: [{
                    label: _T(['#ruleset-rename-button-cancel','Cancel']),
                    close: true,
                    class: "btn-primary"
                },{
                    label: _T(['#ruleset-rename-button-rename','Rename']),
                    event: "rename",
                    class: "btn-success"
                }
            ]
            }).then( data => {
                if ( "rename" === data ) {
                    const newname = $( '#sysdialog #newname' ).val() || "";
                    if ( "" !== newname && newname !== set.name ) {
                        set.getDataObject().value.name = newname;
                        set.getDataObject().save().then( () => {
                            $( 'div.re-listhead span#rulesetname', this.$tab ).text( newname );
                            $( 'ul#ruleset-nav a#' + idSelector( set.getID() ) + '.nav-link' )
                                .text( newname );
                        });
                    }
                }
            });
        }

        async handleSearchResultClick( event ) {
            const where = $( event.currentTarget ).attr( 'href' );

            $( 'div#offcanvasRulesets ul#oc-searchresults-nav' ).empty().hide();
            $( 'div#offcanvasRulesets ul#oc-rulesets-nav' ).show();

            window.location.href = where;

            return false;
        }

        async handleSearchChange( event ) {  // eslint-disable-line no-unused-vars
            const val = ( $( 'div#offcanvasRulesets input#oc-rule-search' ).val() || "").trim().toLocaleLowerCase();
            localStorage.setItem( 'ruleset_last_search', val );
            //console.log("rule search for",val);
            if ( "" === val ) {
                $( 'div#offcanvasRulesets ul#oc-searchresults-nav' ).empty().hide();
                $( 'div#offcanvasRulesets ul#oc-rulesets-nav' ).show();
            } else {
                $( 'div#offcanvasRulesets ul#oc-rulesets-nav' ).hide();
                const $ct = $( 'div#offcanvasRulesets ul#oc-searchresults-nav' ).empty().show();
                const rulesets = await Rulesets.getRulesets();
                const res = [];
                for ( const set of rulesets ) {
                    const rules = await set.getRules();
                    const reactions = await set.getReactions();
                    for ( const rule of rules ) {
                        //console.log("    rule",rule.id,rule.name);
                        if ( rule.name.toLocaleLowerCase().includes( val ) || rule.id.includes( val ) ) {
                            res.push( { obj: rule, ruleset: set } );
                        }
                    }
                    for ( const reaction of reactions ) {
                        if ( reaction.name.toLocaleLowerCase().includes( val ) || reaction.id.includes( val ) ) {
                            res.push( { obj: reaction, ruleset: set } );
                        }
                    }
                }
                res.sort( function( a,b ) {
                    return String(a.obj.name || a.obj.id).localeCompare( b.obj.name || b.obj.id, undefined, { sensitivity: 'base' } );
                });
                const clickHandler = this.handleSearchResultClick.bind( this );
                for ( const entry of res ) {
                    const $nav = $( '<li class="nav-item"></li>' ).appendTo( $ct );
                    const typ = entry.obj instanceof Reaction ? 'reaction' : 'rule';
                    let $a = $( '<a></a>' ).attr( 'href', `#rules/${typ}/${entry.obj.id}` )
                        .attr( 'data-bs-dismiss', 'offcanvas' )
                        .attr( 'data-bs-target', '#offcanvasRulesets' )
                        .addClass( "nav-link" )
                        .on( 'click.reactor', clickHandler )
                        .appendTo( $nav );
                    $( '<i></i>' ).addClass( 'bi me-2' )
                        .toggleClass( 'bi-lightning', entry.obj instanceof Reaction )
                        .toggleClass( 'bi-cpu', ! ( entry.obj instanceof Reaction ) )
                        .appendTo( $a );
                    $( '<span></span>' ).text( entry.obj.name ).appendTo( $a );
                }
                if ( 0 === res.length ) {
                    $( '<li class="nav-item"></li>').text( "No matches." );
                }
            }
        }

        async show_list( ruleset_id ) {
            const self = this;

            this.$tab.empty();
            this.$tab.data( 'mode', 'list' ).attr( 'data-mode', 'list' );
            this.$tab.removeData( 'rule' ).attr( 'data-rule', null );

            /* Before we empty the subnavigation, save the active element, if any */
            let lastActiveRuleset = ruleset_id || localStorage.getItem( 'last_ruleset' );
            //console.log("lastActiveRuleset", lastActiveRuleset);

            // Build the offcanvas content
            $( 'div#offcanvasRulesets ul#oc-searchresults-nav' ).empty().hide();
            $( 'div#offcanvasRulesets input#oc-rule-search' )  // we'll take care of initial value below
                .attr( 'placeholder', _T( "Search for rule" ) )
                .off( ".reactor" )
                .on( "keyup.reactor", this.handleSearchChange.bind( this ) )
                .on( "change.reactor", this.handleSearchChange.bind( this ) )
                .on( "search.reactor", this.handleSearchChange.bind( this ) );
            const $subnav = $( 'div#offcanvasRulesets ul#oc-rulesets-nav' ).empty();

            const rulesets = await Rulesets.getRulesets();
            for ( const set of rulesets ) {
                if ( null === set ) { /* unable to fetch set in sequence */
                    continue;
                }
                if ( ! ruleset_id && ! lastActiveRuleset ) {
                    lastActiveRuleset = set.id;
                }
                const $nav = $( '<li class="nav-item"></li>' )
                    .appendTo( $subnav );
                const $el = $( '<a href="#" class="nav-link ruleset-link" data-bs-dismiss="offcanvas"></a>' )
                    .attr( 'id', set.id )
                    .on( 'click.reactor', this.handleRulesetClick.bind( this ) )
                    .appendTo( $nav );
                $( '<i class="bi bi-grip-vertical me-2"></i>' ).appendTo( $el );
                $( '<span></span>' ).text( set.name || set.id ).appendTo( $el );
                //$( '<span class="badge bg-info rounded-pill ms-2 align-top"></span>' ).text( (set.rules || []).length ).appendTo( $nav );
            }

            /* The ruleset list itself is sortable. */
            $subnav.sortable({
                items: "> .nav-item",
                axis: "y",
                update: this.handleRulesetSort.bind( this ),
                handle: "i.bi-grip-vertical",
                cursor: "grabbing",
                placeholder: "sort-placeholder",
                distance: 15
            });

            // Make sure rule set list shows last text in search field, and matching results.
            const stext = localStorage.getItem( "ruleset_last_search" ) || "";
            $( 'div#offcanvasRulesets input#oc-rule-search' ).text( stext );
            await this.handleSearchChange();

            // Build the tab content -- first, the rules...
            $( `<div class="re-listhead">
  <span id="rulesetname" class="mx-1" title="${_T(['#ruleset-control-rename','Click to rename'])}"></span>
  <div class="d-inline">
    <button id="sortalpha" class="btn bi-btn bi-btn-white" title="${_T('#ruleset-control-alphasort')}"><i class="bi bi-sort-alpha-down"></i></button>
    <button id="deleteruleset" class="btn bi-btn bi-btn-white" title="${_T('#ruleset-control-delete')}"><i class="bi bi-x"></i></button>
  </div>
  <div class="float-end mt-1">
    <button id="addrule" class="btn btn-sm btn-success">${_T(['#ruleset-button-newrule','Create Rule'])}</button>
  </div>
</div>` )
                .appendTo( this.$tab );
            const $rulelist = $( '<div id="rules" class="container-fluid"></div>' )
                .appendTo( this.$tab );
            // $rulelist.css( 'max-height', Math.floor( ( window.innerHeight - $rulelist.offset().top ) ) + "px" );

            /* Rules are also sortable */
            $rulelist.sortable({
                items: "> .re-rule",
                axis: "y",
                handle: 'button.ruleDrag',
                cancel: '', /* so draghandle can be button */
                containment: $rulelist,
                update: this.handleRuleSort.bind( this ),
                placeholder: "sort-placeholder"
            });

            $( 'button#sortalpha' ).on( 'click.reactor', async function() {
                const ruleset_id = $( 'div#rules' ).data( 'ruleset' );
                const ruleset = await Ruleset.getInstance( ruleset_id );
                const rules = await ruleset.getRules();
                rules.sort( function( a,b ) {
                    return String(a.name || a.id).localeCompare( b.name || b.id, undefined, { sensitivity: 'base' } );
                });
                const dobj = ruleset.getDataObject();
                dobj.value.rules = rules.map( el => el.id );
                dobj.save().then( () => {
                    /* Display new sort order */
                    let $insert = $( 'div#rules .re-rule:last' );
                    rules.forEach( rule => {
                        const rid = rule.getID();
                        const $el = $( 'div#rules div[id="rule-' + rid + '"].re-rule' );
                        if ( $el.attr( 'id' ) !== $insert.attr( 'id' ) ) {
                            $el.detach().insertAfter( $insert );
                        }
                        $insert = $el;
                    });
                });
            });

            $( 'button#addrule', this.$tab ).on( 'click.reactor', async function() {
                const rsid = $( 'div#rules' ).data( 'ruleset' );
                const ruleset = await Ruleset.getInstance( rsid );
                const rule = await Rule.create( ruleset );
                self.editRule( rule, true );
            });

            $( 'span#rulesetname', this.$tab ).on( 'click.reactor', this.handleRulesetRenameEvent.bind( this ) );

            $( 'button#deleteruleset', this.$tab ).on( 'click.reactor', async function() {
                const rsid = $( 'div#rules', self.$tab ).data( 'ruleset' );
                let set = await Ruleset.getInstance( rsid );
                if ( set.rules.length > 0 ) {
                    showSysModal( { title: "Unable to Delete",
                        body: "The rule set cannot be deleted because it is not empty." } );
                    return;
                }
                const rulesets = await Rulesets.getRulesets();
                if ( rulesets.length <= 1 ) {
                    showSysModal( {
                        title: _T(['#ruleset-delete-unable','Unable to Delete Rule Set']),
                        body: _T(['#ruleset-delete-message',"You cannot delete the last rule set; there must be at least one."])
                    } );
                    return;
                }
                set.delete().then( () => {
                    const $nav = $( 'div#offcanvasRulesets ul.nav' );
                    const $li = $( 'li.nav-item', $nav );  // get all li.nav-items
                    const $t = $( `a#${idSelector(rsid)}.nav-link`, $li ).closest( 'li' );  // find target li
                    // Seek next rule set
                    let ix = $li.index( $t ) + 1;
                    if ( ix >= $li.length ) {
                        // It was last in the list, so get previous rule set
                        ix -= 2;
                    }
                    // Delete the target and show the next rule set.
                    $t.remove();
                    const $next = $( 'a.nav-link', $li.slice( ix, ix+1 ) );
                    $next.click();
                });
            });

            /* Finally, select a ruleset to display. If we have an active item, try to restore. */
            const $i = $( 'li.nav-item a#' + idSelector( lastActiveRuleset || "_" ) + '.nav-link', $subnav );
            if ( 1 === $i.length ) {
                await this.showRulesetContents( lastActiveRuleset );
            } else {
                /* Default select first one */
                const $first = $( 'li:first a.nav-link', $subnav );
                await this.showRulesetContents( $first.attr('id') );
            }
        }

        close_editor( ruleset_id ) {
            Object.values( this.editors ).forEach( function( ed ) { ed.close(); });
            this.$tab.empty().removeData( "rule" ).attr( "data-rule", null )
                .data( "mode", "list" ).attr( "data-mode", "list" );
            this.configModified = false;
            this.editors = {};
            this.editrule = false;
            this.newrule = false;
            this.show_list( ruleset_id );
        }

        /**
         * Handle save/revert click
         */
        async handleSaveRevertClick( event ) {
            const $el = $( event.target );
            $( '.saveconf,.revertconf', this.$tab ).prop( 'disabled', true );
            const rule_id = this.$tab.data( 'rule' );
            const rule = await Rule.getInstance( rule_id );
            if ( $el.hasClass('revertconf') ) {
                if ( this.configModified ) {
                    showSysModal( {
                        title: _T(['#ruleedit-unsaved-title','Rule Modified (Unsaved Changes)']),
                        body: _T(['#ruleedit-unsaved-prompt','You have unsaved changes to this rule. Really discard changes and exit?']),
                        buttons: [{
                            label: _T(['#ruleedit-unsaved-button-discard',"Discard Changes"]),
                            event: "discard",
                            class: "btn-danger"
                        },{
                            label: _T(['#ruleedit-unsaved-button-cancel',"Cancel"]),
                            close: true,
                            class: "btn-success"
                        }]
                    }).then( async event => {
                        if ( "discard" === event ) {
                            const removeIt = this.newrule;
                            this.configModified = false;
                            this.close_editor();  // close first for unsubscribe/disconnect
                            if ( removeIt ) {
                                await rule.delete();
                            }
                            return;
                        }
                        this.updateSaveControls();
                    });
                } else {
                    const removeIt = this.newrule;
                    this.close_editor();  // close first for unsubscribe/disconnect
                    if ( removeIt ) {
                        await rule.delete();
                    }
                }
                return false;
            } else if ( $el.hasClass( 'saveconf' ) ) {
                const doexit = $el.hasClass( 'saveexit' );

                console.log("Saving rule", rule_id, "editrule", this.editrule);

                const dobj = rule.getDataObject();
                dobj.value.name = this.editrule.name;
                dobj.value.serial = ( dobj.value.serial || 0 ) + 1;

                /* Grab the data from the embedded editors */
                dobj.value.triggers = this.editors.trig_editor.current();
                dobj.value.triggers.id = 'trig';
                dobj.value.constraints = this.editors.const_editor.current();
                dobj.value.constraints.id = 'cons';
                dobj.value.react_set = this.editors.set_editor.current();
                delete dobj.value.react_set.name;   // Rule Reactions inherit name from parent rule
                dobj.value.react_set.set = true;    // ...and can't be disabled.
                dobj.value.react_reset = this.editors.reset_editor.current();
                delete dobj.value.react_reset.name; // Rule Reactions inherit name from parent rule
                dobj.value.react_reset.set = false; // ...and can't be disabled.
                dobj.value.expressions = this.editors.expr_editor.current();
                dobj.save().then( dobj => {  // eslint-disable-line no-unused-vars
                    this.configModified = false;
                    this.newrule = false;
                    Object.values( this.editors ).forEach( function( ed ) {
                        ed.notifySaved();
                    });
                    if ( doexit ) {
                        this.close_editor();
                    } else {
                        this.updateSaveControls();
                    }
                }).catch( function( e ) {
                    console.error(this,"save failed",e);
                    console.error(e);
                    showSysModal({
                        title: _T('#dialog-error-title'),
                        body: _T('The save failed; try again. {0}', e),
                    }).then( function( data ) {  // eslint-disable-line no-unused-vars
                        this.updateSaveControls();
                    });
                });

                return false;
            }
        }

        async edit( event ) {
            const $row = $( event.currentTarget ).closest( '.re-rule' );
            const rule_id = $row.attr( 'id' ).replace( /^rule-/, "" );

            const rule = await Rule.getInstance( rule_id );
            if ( !rule ) {
                return;
            }

            await rule.refresh();
            this.editRule( rule );
        }
    }

    return {
        "init": function( $main ) {
            if ( ! tabInstance ) {
                tabInstance = new RulesTab( $main );
            }
        },
        "tab": () => tabInstance.getTabElement()
    };

})(jQuery);
