/** 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.
 */

 /* ??? TO-DO: Convert to Tab subclass */

export const version = 26171;

import api from '/client/ClientAPI.js';

import * as Common from './reactor-ui-common.js';
/* Frequently used */
const isEmpty = Common.isEmpty;
const idSelector = Common.idSelector;

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

import '/common/util.js'; /* global util */

/* Note: int/uint === i4/ui4 */
const inttypes = {
    "ui1": { min: 0, max: 255 }, "i1": { min: -128, max: 127 },
    "ui2": { min: 0, max: 65535 }, "i2": { min: -32768, max: 32767 },
    "ui4": { min: 0, max: 4294967295 }, "i4": { min: -2147483648, max: 2147483647 },
    "ui8": { min: 0, max: Number.MAX_SAFE_INTEGER }, "i8": { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
    "uint": { min: 0, max: 4294967295 }, "int": { min: -2147483648, max: 2147483647 }
};

const INCREMENTAL_LOAD = 100;

/* ??? needs conversion to Tab class */

export default (function($) {

    var tabId = "tab-entities";

    function clean( str ) {
        try {
            if ( "boolean" === typeof str ) {
                str = _T( String( str ) );
            } else if ( null === str || "undefined" === typeof str ) {
                str = _T( 'null' );
            } else {
                str = JSON.stringify( str );
            }
        } catch( err ) {  // eslint-disable-line no-unused-vars
            // console.log("Can't stringify",str,":",err);
            str = String( str );
        }
        return str.replace( /\n/g, "\\n" ).replace( /\r/g, "\\r" ).replace( /\t/g, "\\t" ).replace( /[\x00-\x1f\x7f]/g,
            function( m ) {
                return '\\x' + m.charCodeAt( 0 ).toString( 16 );
            } );
    }

    /** Copied from reaction-editor.js; need to just use it where it is. Although this is MODIFIED */
    function getArgumentField( entity, action, pname, parm, $container, lastval ) {
        var $inp, $opt;
        parm.type = parm.type || "string";
        if ( "undefined" !== typeof parm.value ) {
            $inp = $( '<input type="hidden" class="argument">' ).val( String( parm.value ) );
        } else {
            let values = parm.values;
            if ( parm.values_from_attr_array && entity && entity.hasAttribute( parm.values_from_attr_array ) ) {
                values = entity.getAttribute( parm.values_from_attr_array );
            }
            if ( values ) {
                if ( "string" === typeof values ) {
                    /* String to array of values */
                    values = values.split( /, */ );
                } else if ( ! Array.isArray( values ) ) {
                    /* Convenience format of capability: params as dict rather than array of key/value pairs */
                    if ( "object" === typeof values ) {
                        let m = [];
                        Object.keys( values ).forEach( key => { m.push( { [key]: values[key] } ); } );
                        values = m;
                    } else {
                        /* Ugly, but not crashy */
                        values = [ String( values ) ];
                    }
                }
                if ( undefined !== window.HTMLDataListElement ) {
                    /* Use datalist when supported (allows more flexible entry) */
                    const dlid = (['dl', entity ? entity.getCanonicalID() : "any", action, pname]).join( '-' ).replace( /[^a-z0-9_-]/ig, "-" );
                    if ( 0 === $( 'datalist#' + idSelector( dlid ) ).length ) {
                        /* Datalist doesn't exist yet, create it */
                        $inp = $('<datalist class="argdata"></datalist>').attr( 'id', dlid );
                        values.forEach( val => {
                            $opt = $( '<option></option>' );
                            if ( null !== val && "object" === typeof( val ) ) {
                                Object.keys( val ).forEach( z => {
                                    $opt.val( String( z ) );
                                    $opt.text( String( val[z] ) + ( parm.default === val[z] ? " *" : "" ) );
                                });
                            } else {
                                $opt.val( String( val ) );
                                $opt.text( String( val ) + ( val === parm.default ? " *" : "" ) );
                            }
                            $inp.append( $opt );
                        });
                        if ( parm.optional ) {
                            $( "<option></option>" ).val("").text( _T("(not specified)") )
                                .prependTo( $inp );
                        }
                        /* Add variables and append to tab (datalists are global to tab) */
                        /*
                        if ( && ! parm.novars ) {
                            this.appendVariables( $inp );
                        }
                        */
                        ( $container || this.$parent ).append( $inp );
                    }
                    /* Now pass on the input field */
                    $inp = $( '<input class="argument form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false">' )
                        .attr( 'placeholder', _T('Click for predefined values' ) )
                        .attr( 'list', dlid );
                    if ( undefined !== lastval ) {
                        $inp.val( lastval );
                    } else if ( undefined !== parm.default && !parm.optional ) {
                        $inp.val( String( parm.default ) );
                    }
                } else {
                    /* Standard select menu */
                    $inp = $( '<select class="argument form-select form-select-sm"></select>' );
                    if ( parm.optional ) {
                        $inp.append( `<option value="">${_T('(not specified)')}</option>` );
                    }
                    values.forEach( val => {
                        $opt = $( '<option></option>' );
                        if ( null !== val && "object" === typeof( val ) ) {
                            Object.keys( val ).forEach( z => {
                                $opt.val( String( z ) );
                                $opt.text( String( val[z] + ( parm.default === val ? " *" : "" ) ) );
                            });
                        } else {
                            $opt.val( String( val ) );
                            $opt.text( String( val ) + ( parm.default === val ? " *" : "" ) );
                        }
                        $inp.append( $opt );
                    });
                    /* Add variables */
                    /*
                    if ( false && ! parm.novars ) {
                        this.appendVariables( $inp );
                    }
                    */
                    /* As a default, just choose the first option, unless specified & required */
                    if ( undefined !== lastval ) {
                        $opt = $( `option[value="${lastval}"]`, $inp );
                        if ( 0 === $opt.length ) {
                            $( '<option></option>' ).val( lastval ).text( lastval ).prependTo( $inp );
                        }
                        $inp.val( lastval );
                    } else if ( undefined !== parm.default && !parm.optional ) {
                        $inp.val( parm.default );
                    } else {
                        $( 'option:first', $inp ).prop( 'selected', true );
                    }
                }
                /* END MODIFIED */
            } else if ( parm.type.match( /^bool(ean)?$/ ) ) { /* MODIFIED */
                /* Menu */
                $inp = $('<select class="argument form-select form-select-sm"></select>');
                if ( parm.optional ) {
                    $inp.prepend( `<option value="">${_T('(not specified)')}</option>` );
                }
                $inp.append( `<option value="false">${_T('false')}${"false"===String(parm.default)?" *":""}</option>` );
                $inp.append( `<option value="true">${_T('true')}${"true"===String(parm.default)?" *":""}</option>` );
                /* Add variables */
                /*
                if ( false && !parm.novars ) {
                    this.appendVariables( $inp );
                }
                */
                /* Force default when available and not optional, otherwise first */
                if ( undefined !== lastval ) {
                    $inp.val( lastval );
                } else if ( undefined !== parm.default && !parm.optional ) {
                    $inp.val( String( parm.default ) );
                } else {
                    $( 'option:first', $inp ).prop( 'selected', true );
                }
    /*
            } else if ( parm.type === "ui1" && parm.min !== undefined && parm.max !== undefined ) {
                $inp = $('<div class="argument tbslider"></div>');
                $inp.slider({
                    min: parm.min, max: parm.max, step: parm.step || 1,
                    range: "min",
                    stop: function ( ev, ui ) {
                        // DeusExMachinaII.changeDimmerSlider( $(this), ui.value );
                    },
                    slide: function( ev, ui ) {
                        $( 'a.ui-slider-handle', $( this ) ).text( ui.value );
                    },
                    change: function( ev, ui ) {
                        $( 'a.ui-slider-handle', $( this ) ).text( ui.value );
                    }
                });
                $inp.slider("option", "disabled", false);
                $inp.slider("option", "value", parm.default || parm.min || 0 );
    */
            } else if ( parm.type.match( "^(real|uint|int)$" ) || parm.type.match(/^(u?i)[1248]$/i ) ) {
                $inp = $( '<input class="argument narrow form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false">' );
                if ( ! parm.novars ) {
                    $inp.attr( 'list', 'reactorvarlist' );
                }
                $inp.attr( 'placeholder', pname );
                if ( undefined !== lastval ) {
                    $inp.val( lastval );
                } else {
                    $inp.val( parm.optional ? "" : Common.coalesce( parm.default, parm.min, 0 ) );
                }
            } else {
                if ( "string" !== parm.type ) {
                    console.warn("getArgumentField: using default (string) presentation for type " +
                        String(parm.type) + " " + String(pname) );
                }
                $inp = $( '<input class="argument form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false">' );
                if ( ! parm.novars ) {
                    $inp.attr( 'list', 'reactorvarlist' );
                }
                $inp.attr( 'placeholder', pname );
                if ( undefined !== lastval ) {
                    $inp.val( lastval );
                } else {
                    $inp.val( parm.optional ? "" : Common.coalesce( parm.default, "" ) );
                }
            }
        }

        if ( parm.tip ) {
            $inp.attr( 'title', _T( parm.tip ) );  /* localize tip string */
        }

        return $inp;
    }

    function buildActionForm( entity, ad, parameters ) {
        const $ct = $( '<form></form>' );
        const keys = Object.keys( ad.arguments || {} );
        if ( 0 === keys.length ) {
            return false;
        }
        keys.sort( ( a, b ) => {
            let ka = ad.arguments[a].sort || 32767;
            let kb = ad.arguments[b].sort || 32767;
            if ( ka === kb ) {
                if ( a === b ) {
                    return 0;
                }
                return a < b ? -1 : 1;
            }
            return ka < kb ? -1 : 1;
        });
        keys.forEach( pname => {
            const p = ad.arguments[ pname ];
            const id = 'param-' + pname;
            const $af = getArgumentField( entity, "form", pname, p, $ct, ( parameters || {} )[pname] ).attr( 'id', id );
            if ( p.password || p.autocomplete ) {
                $af.attr( "autocomplete", p.autocomplete || "off" );
            }
            const $fg = $( '<div class="form-group"></div>' ).appendTo( $ct );
            let label = p.label || pname;
            const extra = [];
            if ( !isEmpty( p.min ) ) {
                extra.push( 'min: ' + String(p.min) );
            }
            if ( !isEmpty( p.max ) ) {
                extra.push( 'max: ' + String(p.max) );
            }
            if ( p.optional ) {
                extra.push( _T('optional') );
            }
            if ( "undefined" !== typeof p.default ) {
                extra.push( _T('default: {0}', p.default) );
            }
            if ( extra.length ) {
                label += " (" + extra.join( '; ' ) + ")";
            }
            $( "<label></label>" ).attr( "for", id )
                .text( label + ":" )
                .appendTo( $fg );
            $af.appendTo( $fg );
        });
        $( `<div class="action-form-warning">${_T('#action-form-warning')}</div>`).appendTo( $ct );
        return $ct;
    }

    function handleActionClick( event ) {
        const $el = $( event.target );
        const action = $el.data( 'action' ) || "";
        const eid = $el.closest( '.re-listport-item' ).attr( 'id' );
        const entity = api.getEntity( eid );
        const [ capName,actName ] = action.split( '.' );
        const cap = entity.getCapability( capName ) || {};
        const ad = ( cap.actions || {} )[ actName ] || {};
        if ( ! Common.hasAnyProperty( ad.arguments || {} ) ) {
            // No arguments for action. Just do it.
            entity.perform( action, {} );
            return true;
        }

        // Action has arguments. Need to ask for them (modal dialog)
        let parm;
        try {
            let last = localStorage.getItem( 'entity_last_action' );
            last = JSON.parse( last );
            let a = last.find( el => el.action === action );
            if ( a ) {
                parm = a.parameters || {};
            }
        } catch ( err ) {  // eslint-disable-line no-unused-vars
            // nada
        }
        const $form = buildActionForm( entity, ad, parm );
        Common.showSysModal( { title: _T("Perform {0}", action ),
            body: $form ? $form :
                _T( 'Ready to perform {0:q} on {1} ({2})?', action, entity.getName(), eid ),
            buttons: [
                { label: _T(['#entity-action-cancel', "Cancel" ]), class: "btn-danger", close: true },
                { label: _T(['#entity-action-perform', "Perform Now" ]), class: "btn-success", ident: "perform" }
            ]
        }).then( function( btn ) {
            if ( "close" !== btn ) {
                const p = {}, ps = {};
                $( '#sysdialog form .argument' ).each( (ix,obj) => {
                    const pname = $(obj).attr( 'id' ).replace( /^param-/, "" );
                    let val = ( $(obj).val() || "" ).trim();
                    const pd = ( ad.arguments || {} )[ pname ] || {};
                    if ( ! pd.password ) {
                        ps[ pname ] = val;
                    } else {
                        delete ps[ pname ];
                    }
                    const typ = pd.type || "string";
                    if ( "" === val && pd.optional ) {
                        if ( "undefined" !== typeof pd.default ) {
                            val = pd.default;
                        } else {
                            return;
                        }
                    } else if ( "real" === typ ) {
                        val = isNaN( val ) ? null : parseFloat( val );
                    } else if ( typ.match( /^(u?int|u?i[1248])$/ ) ) {
                        val = isNaN( val ) ? null : parseInt( val );
                    } else if ( typ.match( /^bool(ean)?/ ) ) {
                        val = null !== val.match( /^(true|yes|1|t|y|on)$/i );
                    }
                    p[ pname ] = val;
                });
                if ( Common.hasAnyProperty( p ) ) {
                    let last = localStorage.getItem( 'entity_last_action' );
                    try {
                        last = JSON.parse( last );
                        if ( ! Array.isArray( last ) ) {
                            last = [];
                        }
                    } catch ( err ) {  // eslint-disable-line no-unused-vars
                        last = [];
                    }
                    let ix = last.findIndex( el => el.action === action );
                    if ( ix < 0 ) {
                        last.push( { action: action, parameters: ps } );
                        if ( last.length > 10 ) {
                            last.splice( 0, 10 - last.length );
                        }
                    } else {
                        let a = last.splice( ix, 1 );
                        a[0].parameters = ps;
                        last.push( a[0] );
                    }
                    localStorage.setItem( 'entity_last_action', JSON.stringify( last ) );
                    //console.log( "entity_last_action", last );
                }
                console.log( "Sending perform", action, p );
                entity.perform( action, p );
            }
            $( '#sysdialog form' ).remove();
        });
        return true;
    }

    function generatePopperContent( e, attr ) {
        const $ul = $( '<ul></ul>' );
        let meta = e.getAttributeMeta( attr );
        if ( meta ) {
            for ( let key in meta ) {
                let vv = meta[ key ];
                let vd = JSON.stringify( vv );
                if ( "number" === typeof vv && vv >= 1528689600000 && vv <= 4133912400000 ) {
                    vd += ` (${new Date(vv).toLocaleString()})`;
                } else if ( vd.length > 256 ) {
                    vd = vd.substring( 0, 256 ) + "...";
                }
                $( '<li></li>' ).text( key + ': ' + vd )
                    .appendTo( $ul );
            }
        } else {
            $ul.append( '<li>No metadata available</li>' );
        }
        return $ul;
    }

    function updateDetailCard( $card, e ) {
        console.assert( $card );
        const eid = e.getCanonicalID();
        let $list = $( 'ul.re-entity-attrs', $card );
        if ( 0 === $list.length ) {
            $list = $( '<ul class="px-0 py-0 re-entity-attrs"></ul>' )
                .attr( 'id', 'attrs-' + eid )
                .appendTo( $card );
        }
        const alist = e.getAttributes();
        const primary = e.getPrimaryAttribute();
        Object.keys( alist || {} ).sort().forEach( function( k ) {
            const spid = ( eid + ":" + k ).replace( /[^a-z0-9]/ig, '_' );
            let $sp = $( 'span#' + idSelector( spid ), $list );
            if ( 0 === $sp.length ) {
                // Should put into alphabetical order???
                let $el = $( '<li></li>' )
                    .appendTo( $list );
                $sp = $( '<span></span>' )
                    .attr( 'id', spid )
                    .text( k + "=" + clean( alist[k].value ) )
                    .addClass( 'text-break' )
                    .toggleClass( 'entity-primary-attribute', k === primary )
                    .appendTo( $el );

                    /* Popper for attribute metadata */
                    try {
                        const $ul = generatePopperContent( e, k );
                        const popper = bootstrap.Popover.getOrCreateInstance( $sp,
                            {
                                title: `${k} metadata`,
                                content: $ul,
                                html: true,
                                delay: 500,
                                placement: 'right',
                                trigger: 'hover focus',
                                container: '#tab-entities',
                                customClass: 're-attr-meta'
                            }
                        );
                        $sp.data( 'popper', popper );
                    } catch ( err ) {
                        console.error( "Failed to create popover for", eid, k, "metadata", err );
                    }
            } else {
                /* Element alread exists */
                $sp.text( k + "=" + clean( alist[k].value ) );
                const popper = $sp.data( 'popper' );
                if ( popper ) {
                    popper.setContent({
                        '.popover-body': generatePopperContent( e, k )
                    });
                }
            }
        });

        const caps = [ ...e.getCapabilities() ];
        let $el = $( 'div#' + idSelector( 'caps-' + eid ), $card );
        if ( 0 === $el.length ) {
            $el = $( '<div class="mt-1"></div>')
                .attr( 'id', 'caps-' + eid )
                .appendTo( $card );
        }
        $el.text( _T( 'Capabilities: {0}', caps.sort().join( ', ' ) ) );

        const acts = [ ...e.getActions() ];
        const $droplist = $( 'div.re-perform-dropdown ul.dropdown-menu', $card );
        $droplist.empty();
        let last_cap = null;
        acts.sort().forEach( function( act, ix ) {  // eslint-disable-line no-unused-vars
            let [cap] = act.split( '.' );
            if ( cap !== last_cap ) {
                if ( last_cap ) {
                    $('<li><hr class="dropdown-divider" /></li>').appendTo( $droplist );
                }
                last_cap = cap;
            }
            const $li = $( '<li></li>' ).appendTo( $droplist );
            $( '<a></a>' ).attr( 'href', '#' ).addClass( 'dropdown-item' )
                .attr( 'data-action', act )
                .data( 'action', act )
                .text( act )
                .appendTo( $li )
                .on( 'click', handleActionClick );
        });
        $droplist.prop( 'disabled', 0 === acts.length );
    }

    function handleEntityActionClick( event ) {
        const $el = $( event.currentTarget );
        const eid = $el.closest( '.re-listport-item' ).attr( 'id' );
        const entity = api.getEntity( eid );
        if ( ! entity ) {
            return;
        }
        switch( $el.data( 'action' ) ) {

            case 'rename':
                {
                    const nom = entity.getName() || "";
                    let $form = $( '<label></label>' )
                        .append( `<span class="me-1">${_T('#entity-rename-input-label')}:</span>` )
                        .append( $( '<input type="text" class="form-control form-control-sm" >' ).val( nom ) )
                        .append( `<div>${_T('#entity-rename-advice')}</div>` );
                    Common.showSysModal( { title: _T("#entity-rename-title"),
                        body: $( '<form></form>' ).append( $form ),
                        buttons: [
                            { label: _T([ '#entity-rename-button-cancel', 'Cancel' ]), class: "btn-danger", close: true },
                            { label: _T([ '#entity-rename-button-rename', 'Rename' ]), class: "btn-success" }
                        ]
                    }).then( function( btn ) {
                        if ( "close" !== btn ) {
                            const newname = $( '#sysdialog form input' ).val();
                            entity.setName( newname );
                        }
                    });
                }
                break;

            case 'delete':
                Common.showSysModal( { title: _T("#entity-delete-title"),
                    body: _T([ '#entity-delete-body', 'Deleting an entity removes it from Reactor but does not remove it from the hub/source. If the source object still exists, the entity will be recreated next time the source is inventoried.' ]),
                    buttons: [
                        { label: _T([ '#entity-delete-button-cancel', 'Cancel' ]), class: "btn-success", close: true },
                        { label: _T([ '#entity-delete-button-delete', 'Delete' ]), class: "btn-danger" }
                    ]
                }).then( function( btn ) {
                    if ( "close" !== btn ) {
                        entity.delete();
                        $el.closest( 'div.re-listport-item' ).remove();
                    }
                });
                break;

            case 'copy-attrs':
                {
                    const $ul = $el.closest( 'div.re-device-actions' ).siblings( 'ul' );
                    const range = document.createRange();
                    range.selectNode( $ul.get(0) );
                    window.getSelection().removeAllRanges();
                    window.getSelection().addRange( range );
                    document.execCommand( "copy" );
                    window.getSelection().removeAllRanges();
                }
                break;

            case 'set-attr':
                {
                    const $form = $( "<form></form>" );
                    let $f = $( '<div class="input-group input-group-sm"></div>' );
                    $( '<span class="input-group-text" id="entity-setattr-selectattr-label"></span>' )
                        .text( _T( ["#entity-setattr-selectattr-label", "Attribute:"] ) )
                        .appendTo( $f );
                    let $g = $( '<select id="setattr-selectattr" class="form-select form-select-sm"></select>' )
                        .appendTo( $f );
                    let caps = ( entity.getCapabilities() || [] ).sort( /* ??? needs localization */ );
                    for ( let capName of caps ) {
                        const cap = entity.getCapability( capName );
                        if ( cap ) {
                            for ( let attrName of Object.keys( cap.attributes || {} ) ) {
                                const attr = cap.attributes[ attrName ];
                                if ( attr.writable ) {
                                    const key = `${capName}.${attrName}`;
                                    $( '<option></option>' ).val( key ).text( key ).appendTo( $g );
                                }
                            }
                        }
                    }
                    $f.appendTo( $form );

                    $f = $( '<div class="input-group input-group-sm"></div>' );
                    $( '<span class="input-group-text" id="entity-setattr-newattrval-label"></span>' )
                        .text( _T( ["#entity-setattr-newattrval-label", "New Value:"] ) )
                        .appendTo( $f );
                    $( '<input type="text" id="setattr-newattrval" class="form-control" aria-label="New value">' )
                        .appendTo( $f );
                    $f.appendTo( $form );

                    $( '<div id="entity-setattr-dialog-msg"></div>' ).appendTo( $form );

                    Common.showSysModal( {
                        title: _T("#entity-setattr-title"),
                        opts: { debug: true },
                        body: $form,
                        buttons: [
                            { label: _T([ '#entity-setattr-button-cancel', 'Cancel' ]), class: "btn-danger", close: true },
                            { label: _T([ '#entity-setattr-button-set', 'Set Attribute' ]), class: "btn-success" }
                        ],
                        valid: ( btn, $dlg ) => {
                            const $msg = $( 'form div#entity-setattr-dialog-msg', $dlg );
                            const key = $( 'form select#setattr-selectattr', $dlg ).val();
                            const $val = $( 'form input#setattr-newattrval', $dlg );
                            $val.removeClass( ':invalid' );
                            let newval = $val.val();
                            let [capName, attrName] = key.split( '.' );
                            let cap = entity.getCapability( capName );
                            let attr = cap.attributes[ attrName ];
                            let type = attr.type || "string";
                            console.log("Entities set attribute valid",btn,$dlg,key,type,newval,attr);
                            if ( "$default" === newval && "undefined" === typeof attr.default ) {
                                return false;
                            }
                            if ( inttypes[ type ] ) {
                                // Min and max can be specified, or default to range for type.
                                const mn = "number" === typeof attr.min ? attr.min : inttypes[ type ].min;
                                const mx = "number" === typeof attr.max ? attr.max : inttypes[ type ].max;
                                newval = parseInt( newval );
                                if ( Number.isNaN( newval ) ) {
                                    $msg.text( _T( ['#validation-not-integer', 'The supplied value is not a valid integer.'] ) );
                                    $val.addClass( ':invalid' );
                                    return false;
                                }
                                if ( newval < mn ) {
                                    $msg.text( _T( ['#validation-int-minimum', 'The supplied value must be an integer >= {0:d}'], mn ) );
                                    $val.addClass( ':invalid' );
                                    return false;
                                }
                                if ( newval > mx ) {
                                    $msg.text( _T( ['#validation-int-maximum', 'The supplied value must be an integer <= {0:d}'], mx ) );
                                    $val.addClass( ':invalid' );
                                    return false;
                                }
                            }
                            if ( "real" === type ) {
                                newval = parseFloat( newval );
                                if ( Number.isNaN( newval ) ) {
                                    $val.addClass( ':invalid' );
                                    $msg.text( _T( ['#validation-not-real', 'The supplied value is not a real number.'] ) );
                                    return false;
                                }
                                if ( "number" === typeof attr.min && newval < attr.min ) {
                                    $msg.text( _T( ['#validation-real-minimum', 'The supplied value must be a real number >= {0:f}'], attr.min ) );
                                    return false;
                                }
                                if ( "number" === typeof attr.max && newval > attr.max ) {
                                    $msg.text( _T( ['#validation-real-maximum', 'The supplied value must be a real number <= {0:f}'], attr.max ) );
                                    return false;
                                }
                            }
                            if ( String(type).match( /^bool(ean)$/i ) ) {
                                if ( isNaN( newval ) && ! newval.match( /^(true|yes|t|y|on|false|no|f|off)$/i ) ) {
                                    $msg.text( _T( ['Invalid boolean value.'], attr.max ) );
                                    return false;
                                }
                            }

                            // It's OK.
                            console.log( "    valid OK" );
                            return true;
                        }
                    }).then( function( btn ) {
                        const $dlg = $( '#sysdialog' );
                        if ( "close" !== btn ) {
                            const key = $( 'form select#setattr-selectattr', $dlg ).val();
                            let newval = $( 'form input#setattr-newattrval', $dlg ).val();
                            const origval = newval;
                            let [capName, attrName] = key.split( '.' );
                            let cap = entity.getCapability( capName );
                            let attr = cap.attributes[ attrName ];
                            let type = attr.type || "string";
                            if ( "null" === newval ) {
                                newval = null;
                            } else if ( "$default" === newval ) {
                                newval = Common.coalesce( attr.default );
                            } else if ( inttypes[type] ) {
                                newval = parseInt( newval );
                            } else if ( "real" === type ) {
                                newval = parseFloat( newval );
                            } else if ( type.match( /^bool(ean)?$/i ) ) {
                                if ( ! isNaN( newval ) ) {
                                    newval = parseInt( newval ) !== 0;
                                } else {
                                    newval = !!newval.match( /^(true|yes|t|y|on)$/i );
                                }
                            }
                            try {
                                console.log("Entities: setting attribute", key, "to", newval, "from user entered", origval);
                                entity.setAttribute( key, newval );
                                $( 'form#entity-setattr-form', $dlg ).remove();
                                $dlg.modal( 'hide' );
                            } catch ( err ) {
                                console.error( err );
                            }
                        }
                    });
                }
                break;

            default:
                /* nada */
        }
    }

    function showEntityStatusDetail( event ) {
        const $el = $( event.currentTarget );
        const $row = $el.closest( '.re-listport-item' );
        const eid = $row.attr( 'id' );
        let $coll = $( 'div.collapse', $row );
        let $card = $( 'div.card-body', $coll );
        const e = api.getEntity( eid );
        if ( 0 === $card.length && e ) {
            $( '<div class="w-100"></div>' ).appendTo( $row );
            const $col = $( '<div class="col"></div>' ).appendTo( $row );
            $coll = $( '<div class="collapse show"></div>' ).appendTo( $col );
            $card = $( '<div class="card card-body re-entity-detail bg-transparent mx-0 my-0"></div>' )
                .appendTo( $coll );
            $( `<div class="re-device-actions">
  <button class="btn btn-sm btn-warning" data-action="rename">${_T([ '#entity-rename-button-rename', 'Rename' ])}</button>
  <button class="btn btn-sm btn-danger" data-action="delete">${_T([ '#entity-delete-button-delete', 'Delete' ])}</button>
  <button class="btn btn-sm btn-success" data-action="copy-attrs">${_T([ '#entity-copyattr-button-copy', 'Copy Attributes' ])}</button>
  <button class="btn btn-sm btn-success" data-action="set-attr">${_T([ '#entity-setattr-button-set', 'Set Attribute' ])}</button>
  <div class="btn-group re-perform-dropdown">
    <button class="btn btn-sm btn-success dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
      ${_T('Perform {0}', "")}
    </button>
    <ul class="dropdown-menu"></ul>
  </div>
</div>`).appendTo( $card );
            $( '.re-device-actions button[data-action="rename"]' ).prop( 'disabled', ! e.canRename() );
            $( '.re-device-actions button[data-action="delete"]' ).prop( 'disabled', ! e.canDelete() );
            $( '.re-device-actions button[data-action="set-attr"]' ).prop( 'disabled',
                ( e.enumAttributes() || [] ).findIndex( (el) => {
                    const d = e.getAttributeDef( el );
                    return true === d?.writable;
                }) < 0
            );
            updateDetailCard( $card, e );
            api.subscribe( e, function( event ) {
                // console.log( "entities list detail card notification", event.type, event.data.getCanonicalID() );
                if ( "entity-update" === event.type ) {
                    const $item = $( 'div#tab-entities div.re-listport-item[id="' + idSelector( event.data.getCanonicalID() ) + '"]' );
                    const $card = $( 'div.card', $item );
                    // console.log( $item, $card );
                    if ( $card.length > 0 ) {
                        let scrollpos = $( 'ul', $card ).scrollTop();
                        // console.log( "updating card for", event.data.getCanonicalID(), "scrolled to", scrollpos );
                        updateDetailCard( $card, event.data );
                        $( 'ul', $card ).scrollTop( scrollpos );
                    }
                }
            });
            // $card.on( 'click', function() { return false; } );
            $( 'div.re-device-actions > button', $card ).on( 'click', handleEntityActionClick );

            $( '.re-entity-name i.bi', $row )
                .removeClass( 'bi-chevron-right' )
                .addClass( 'bi-chevron-down' );
        } else {
            /* Because of poppers we now use for attribute meta, we remove the card rather than hiding it.
             * A hidden popper updates continuously and saps performance, and since there is a popper per
             * attribute, there can be a lot of them.
             */
            $( '.re-entity-name i.bi', $row )
                .removeClass( 'bi-chevron-down' )
                .addClass( 'bi-chevron-right' );
            $coll.remove();
        }
        return false;
    }

    function drawFilteredEntities( $lp ) {

        const ctrl_filter = $( 'select#filter-ctrl', $lp ).val() || "";
        localStorage.setItem( 'entitylist_filter_ctrl', ctrl_filter );
        const ctrl_re = isEmpty( ctrl_filter ) ? false : new RegExp( `^${ctrl_filter}>`, "i" );
        const grp_filter = $( 'select#filter-group', $lp ).val() || "";
        localStorage.setItem( 'entitylist_filter_grp', grp_filter );
        const grp_entity = isEmpty( grp_filter ) ? false : api.getEntity( grp_filter );
        const cap_filter = $( 'select#filter-cap', $lp ).val() || "";
        localStorage.setItem( 'entitylist_filter_cap', cap_filter );
        const name_filter = $( 'input#filter-name', $lp ).val() || "";
        localStorage.setItem( 'entitylist_filter_name', name_filter );

        /** Special handling for name filter. Will match name or ID. If @ prefix, matches CANONICAL ID.
         *  Regex can follow optional prefix. String matches are not case sensitive. Regex depends on
         *  flag given (or not).
         */
        let name_re = name_filter.trim();
        let canonical = name_re.startsWith( '@' );
        if ( canonical ) {
            name_re = name_re.substring( 1 );
        }
        name_re = util.stringOrRegexp( name_re );
        if ( "string" === typeof name_re ) {
            name_re = name_re.toLocaleLowerCase();
        }

        const getter = canonical ? 'getCanonicalID' : 'getID';
        let filtered;
        //console.log( `Entity filters: name=${name_re} ctrl=${ctrl_re} grp=${grp_entity} cap=${cap_filter}` );
        try {
            filtered = api.getEntities().filter( e => {
                if ( ! ( isEmpty( cap_filter ) || e.hasCapability( cap_filter ) ) ) {
                    return false;
                }
                if ( grp_entity && ! grp_entity.hasMember( e ) ) {
                    return false;
                }
                if ( ctrl_re && ! ctrl_re.test( e.getCanonicalID() ) ) {
                    return false;
                }
                if ( grp_entity && ! grp_entity.hasMember( e ) ) {
                    return false;
                }
                if ( name_re ) {
                    if ( name_re instanceof RegExp && ! ( name_re.test( e.getName() ) || name_re.test( e[getter]() ) ) ) {
                        // Regular expression, but doesn't match name or ID.
                        return false;
                    } else if ( "string" === typeof name_re ) {
                        console.log("filter", name_re, canonical, getter);
                        if ( ! ( e.getName().toLocaleLowerCase().includes( name_re ) || e[getter]().includes( name_re ) ) ) {
                            return false;
                        }
                    }
                }
                return true;
            });
        } catch ( err ) {
            console.error( "Unable to filter entities: ", err.message );
            console.error( err );
        }

        filtered.sort( function( a, b ) {
            return a.getName().localeCompare( b.getName(), undefined, { sensitivity: 'base' } );
        });

        // Get attribute to be displayed (menu of attributes shows when capability filter is active)
        const disp_attr = $( 'select#entity-val-col-selector' ).val() || "";

        const $section = $( '.re-listport-body', $lp ).empty()
            .data('re-last',0).attr('data-re-last',0)
            .off( "scroll.reactor" );

        const draw_chunk = function(filtered, pos, limit) {
            limit = limit || INCREMENTAL_LOAD;
            pos = pos || 0;
            const last = Math.min( filtered.length, pos + limit );
            for ( let ix=pos; ix<last; ++ix ) {
                const entity = filtered[ix];
                const eid = entity.getCanonicalID();
                //if ( ix === pos ) console.log("draw_chunk from", pos, eid, entity.getName() );
                const $row = Common.getListportRow().attr( 'id', eid ).appendTo( $section );
                let $el = $( '<div class="col-6 col-lg-4 re-entity-name"></div>')
                    .on( 'click.reactor', showEntityStatusDetail )
                    .appendTo( $row );
                $( '<span style="max-width: 120px" class="text-wrap"></span>' ).text( entity.getName() || eid ).appendTo( $el );
                let $dv = $( '<div class="d-none d-sm-inline"></div>' ).appendTo( $el );
                $( '<i class="bi bi-chevron-right ms-1"></i>' ).appendTo( $dv );
                if ( entity.getDeadSince() ) {
                    $( '<i class="bi bi-question-diamond-fill ms-3"></i>' )
                        .attr( 'title', _T( ['#entity-missing-alert', 'This entity is marked missing from the hub'] ) )
                        .appendTo( $el );
                    $( 'span', $el ).addClass( 'entity-name-deleted' );
                }
                $( '<div class="col-lg-2 col-xl-3 d-none d-lg-block overflow-hidden user-select-all"></div>' )
                    .text( eid ).attr( 'title', eid )
                    .appendTo( $row );
                $el = $( '<div class="col-6 col-md-3 overflow-hidden re-entity-state user-select-all"></div>' )
                    .appendTo( $row );
                if ( "" !== disp_attr ) {
                    $el.text( clean( entity.getAttribute( disp_attr ) ) )
                        .attr( 'title', disp_attr );
                } else if ( null !== entity.getPrimaryAttribute() ) {
                    $el.text( clean( entity.getPrimaryValue() ) )
                        .attr( 'title', String(entity.getPrimaryAttribute() ) );
                } else {
                    $el.text( '-' ).attr( 'title', null );
                }
                const st = entity.getLastUpdate();
                $( '<div class="col d-none d-md-block text-end overflow-hidden re-entity-update"></div>' )
                    .text( null === st ? _T( [ '#entities-never-updated', "never" ] ) : Common.shortTime( st ) )
                    .appendTo( $row );
            }
            $section.data('re-last', last).attr('data-re-last', last);
            setTimeout( Common.hideGlobalLoading, 250 );
        };

        draw_chunk( filtered );

        $section.off( 'scroll.reactor' ).on( 'scroll.reactor', (ev,ui) => {  // eslint-disable-line no-unused-vars
            const $el = $(ev.target);
            // console.log( "scroll",$el,$el.scrollTop(),$el.height(),$el.get(0).scrollHeight);
            if ( $el.scrollTop() + $el.innerHeight() >= $el.get(0).scrollHeight - 1 ) {
                const n = $el.data('re-last');
                if ( n < filtered.length ) {
                    console.log("Entities: incremental load from", n);
                    Common.showGlobalLoading( _T( "Loading more entities..." ) );
                    setTimeout( () => { draw_chunk( filtered, n ); }, 250 );
                }
            }
            return false;
        });
    };

    function handleEntityListFilterChange( event ) {
        const $lp = $( event.currentTarget ).closest( '.re-listport' );
        // console.log("Redrawing entities (filter changed)");
        drawFilteredEntities( $lp );
        return false;
    };

    function handleEntityListCapabilityFilterChange( ev ) {
        let sel = $( 'select#filter-cap' ).val();
        let $col = $( 'div#entities-col-head' );
        if ( "" !== sel ) {
            // "Primary Value" column becomes selector
            let $m = $( '<select></select>' ).attr( 'id', 'entity-val-col-selector' )
                .addClass( 'form-select' )
                .addClass( 'form-select-sm' );
            $( '<option></option>' )
                .val( "" )
                .text( _T(['#entities-col-value','Primary Value']) )
                .appendTo( $m );
            let cap = api.getCapability( sel );
            let attrs = Object.keys( cap?.attributes || {} );
            attrs.sort( (a,b) => a.localeCompare( b, undefined, { sensitivity: 'base' } ) );
            for ( let attr of attrs ) {
                const cattr = `${sel}.${attr}`;
                $( '<option></option>' )
                    .val( cattr )
                    .text( cattr )
                    .appendTo( $m );
            }
            $col.empty().append( $m );
            $m.on( 'change', handleEntityListFilterChange );
        } else {
            // "Primary Value" returns to text.
            $col.empty().text( _T(['#entities-col-value','Primary Value']) );
        }

        // Handle the rest/generic stuff
        handleEntityListFilterChange( ev || { currentTarget: $col } );
    };

    function refresh_entity_row( $row, entity ) {
        const disp_attr = $( 'select#entity-val-col-selector' ).val() || "";
        let $el = $( '.re-entity-name', $row );
        $( 'span', $el ).text( entity.getName() )
            .toggleClass( 'entity-name-deleted', !!entity.getDeadSince() );
        if ( entity.getDeadSince() ) {
            $( '<i class="bi bi-question-diamond-fill ms-3"></i>' )
                .attr( 'title', _T( ['#entity-missing-alert', 'This entity is marked missing from the hub'] ) )
                .appendTo( $el );
        } else {
            $( 'i.bi-question-diamond-fill', $el ).remove();
        }

        $el = $( '.re-entity-state', $row );
        if ( "" !== disp_attr ) {
            $el.text( clean( entity.getAttribute( disp_attr ) ) )
                .attr( 'title', disp_attr );
        } else if ( null !== entity.getPrimaryAttribute() ) {
            $el.text( clean( entity.getPrimaryValue() ) )
                .attr( 'title', String(entity.getPrimaryAttribute() ) );
        } else {
            $el.text( '-' ).attr( 'title', null );
        }

        $( '.re-entity-update', $row ).text( Common.shortTime( entity.getLastUpdate(), "%F %T" ) );

        /* Detail card, if present, takes care of itself */

        /* Animate row green back to transparent */
        $row.stop().clearQueue().addClass( "active" ).css( 'background-color', 'var(--bs-green)' );
        $row.animate( { backgroundColor: 'transparent' }, 2000, () => {
            $row.removeClass( "active" );
        });
    }

    async function activate( event, extras ) {
        const $tab = $( event.target );
        const $lp = Common.getListport().appendTo( $tab );
        console.log("entities tab activation", extras);

        const $section = $( ".re-listport-head", $lp );
        let $row = $( '<div class="row"></div>' ).appendTo( $section ); /* not listport-item */
        let $col = $( '<div class="col align-middle"></div>').appendTo( $row );
        $( `<span class="re-listport-title">${_T(['#nav_entities','Entities'])}</span>` ).appendTo( $col );
        $col = $( '<div class="col-auto float-end mt-2"></div>' ).appendTo( $row );
        $( `<button class="btn btn-sm btn-primary ms-2"><i class="bi bi-x me-1"></i><span>${_T(['#ep-filter-clear','Clear Filters'])}</button>` )
            .on( 'click.reactor', function( event ) {  // eslint-disable-line no-unused-vars
                $( 'select.entity_filter' ).val( "" );
                $( 'input#filter-name' ).val( "" );
                $( 'select#filter-cap' ).trigger( 'change' );
            })
            .appendTo( $col );

        $row = $( '<div class="d-flex flex-row flex-wrap +my-2"></div>' ).appendTo( $section ); /* not listport-item */
        $col = $( '<div class=""></div>' ).appendTo( $row );
        $( '<input type="search" id="filter-name" class="form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false">' )
            .val( localStorage.getItem( 'entitylist_filter_name' ) || "" )
            .attr( 'placeholder', _T( 'Name Contains:' ) )
            .on( 'keyup', handleEntityListFilterChange )
            .on( 'change', handleEntityListFilterChange )
            .on( 'search', handleEntityListFilterChange )
            .appendTo( $col );
        $col = $( '<div class="ps-1"></div>' ).appendTo( $row );
        let $mm = $( '<select id="filter-ctrl" class="form-select form-select-sm entity_filter"></select>' )
            .on( 'change', handleEntityListFilterChange )
            .appendTo( $col );
        $( `<option value="">${_T( '(any controller)' )}</option>` ).appendTo( $mm );
        const t = api.getControllers();
        t.forEach( (n, ix) => {
            t[ix] = api.getController( n );
        });
        t.sort( (a,b) => {
            return (a.name || a.id).localeCompare( b.name || b.id, undefined, { sensitivity: 'base' } );
        });
        t.forEach( n => {
            $( '<option></option>' ).val( n.id ).text( n.name ? ( n.name + " (" + n.id + ")" ) : n.id )
                .appendTo( $mm );
        });
        $mm.val( localStorage.getItem( 'entitylist_filter_ctrl' ) || "" );
        $col = $( '<div class="ps-1"></div>' ).appendTo( $row );
        $mm = $( '<select id="filter-group" class="form-select form-select-sm entity_filter"></select>' )
            .on( 'change', handleEntityListFilterChange )
            .appendTo( $col );
        $( `<option value="">${_T( '(any group)' )}</option>` ).appendTo( $mm );
        let gl = api.getEntities().filter( e => e.getType() === "Group" );
        gl.sort( (a, b) => {
            return a.getName().localeCompare( b.getName(), undefined, { sensitivity: 'base' } );
        });
        gl.forEach( e => {
            let title = e.getName() + " (" + e.getCanonicalID() + ")";
            $( '<option></option>' ).val( e.getCanonicalID() ).text( title )
                .appendTo( $mm );

        });
        $mm.val( localStorage.getItem( 'entitylist_filter_grp' ) || "" );
        $col = $( '<div class="ps-1"></div>' ).appendTo( $row );
        $mm = $( '<select id="filter-cap" class="form-select form-select-sm entity_filter"></select>' )
            .on( 'change', handleEntityListCapabilityFilterChange )
            .appendTo( $col );
        $( "<option>}</option>" ).val( "" ).text( _T( '(any capability)' ) ).appendTo( $mm );
        let cap = {};
        api.getEntities().forEach( e => {
            let c = e.getCapabilities() || [];
            c.forEach( capName => {
                cap[ capName ] = true;
            });
        });
        cap = Object.keys( cap );
        cap.sort();
        cap.forEach( capName => {
            /* x_vera_svc_micasaverde_com_SceneControllerLED1 => x_vera_..._SceneControlledLED1 */
            let abbrev = capName.length <= 24 ? capName : capName.replace( /^(x_[^_]+_).{4,}(_[^_]+)$/i, "$1...$2" );
            $( '<option></option>' ).val( capName ).text( abbrev ).attr( 'title', capName )
                .appendTo( $mm );
        });
        $mm.val( localStorage.getItem( 'entitylist_filter_cap' ) || "" );

        Common.getListportRow().appendTo( $section )
            .append( `<div class="col-6 col-lg-4">${_T(['#entities-col-name','Entity Name'])}</div>` )
            .append( `<div class="col-lg-2 col-xl-3 d-none d-lg-block">${_T(['#entities-col-id','Canonical ID'])}</div>` )
            .append( `<div id="entities-col-head" class="col-6 col-md-3">${_T(['#entities-col-value','Primary Value'])}</div>` )
            .append( `<div class="col d-none d-md-block text-end">${_T(['#entities-col-modified','Last Modified'])}</div>` );


        if ( extras.path && extras.path.length > 0 ) {
            $( 'select.entity_filter', $tab ).val( "" );  // clear selects
            $( 'input#filter-name', $tab ).val( "@" + extras.path[0] );
        }
        $( 'select#filter-cap' ).trigger( 'change' );

        $( window ).resize( function() { Common.listportResize( $lp, 42 ); } ).trigger( 'resize' );

        api.off( "." + tabId );
        api.on('entity_change.' + tabId, function( entity ) {
            // console.log("reactor-ui-core: entity change id " + entity.getCanonicalID() + " name " + String(entity.getName()));
            const $row = $( 'div#' + idSelector( entity.getCanonicalID() ) );
            if ( 0 === $row.length ) {
                // Entity is probably filtered out
                return;
            }

            refresh_entity_row( $row, entity );
        });
        api.on( 'entity_delete.' + tabId, function( entity ) {
            const $row = $( 'div#' + idSelector( entity.getCanonicalID() ) );
            $row.remove();
        });
        api.on( 'structure_update.' + tabId, ( ix, obj ) => {
            // Update all displayed rows.
            console.log("Entities: structure update received; refreshing displayed rows.");
            const $rows = $( 'div.re-listport-item', $tab ).each( ( $obj ) => {
                const $row = $( obj );
                const id = $row.attr( "id" ) || "";
                if ( "" !== id ) {
                    const entity = api.getEntity( id );
                    if ( entity ) {
                        refresh_entity_row( $row, entity );
                    }
                }
            });
        });

        $( 'a#testlink' ).attr( 'href', '#' ).on( 'click', function() {
            api.recycle();
        });
    };

    function suspend( event ) {
        // console.log("reactor-ui-entities.suspend() running");
        // console.log(event);
        const $tab = $( event.target );
        if ( tabId !== $tab.attr( 'id' ) || !$tab.hasClass( "re-tab-container" ) ) {
            // console.log( "    event is not for the tab, but a child" );
            return;
        }

        $tab.empty();

        api.off( '.' + tabId );

        return true;
    };

    return {
        "init": function( $main ) {
            $( 'div#' + idSelector( tabId ) + '.re-tab-container' ).remove();
            const $tab = $( '<div></div>' )
                .hide()
                .attr( 'id', tabId )
                .addClass( 're-tab-container' )
                .appendTo( $main );
            $tab.on( 'activate', activate );
            $tab.on( 'suspend', suspend );

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

        },
        "tab": function() {
            return $( 'div#' + idSelector( tabId ) + ".re-tab-container" );
        }
    };
})(jQuery);
