/** 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 = 26192;

var lastx = 0;

import { _T, locale_time, locale_datetime, htmlEncode } from './i18n.js';

export const _DOCURL = "https://reactor.toggledbits.com/docs/";

export function getAuthInfo( app ) {
    const cookie = (document.cookie || "").split( /;\s*/ ).find( el => el.match( /^reactor-session=/ ) );
    if ( cookie ) {
        try {
            const auth = JSON.parse( decodeURIComponent( cookie.substring( 16 ) ) );
            if ( 1 === auth.applications.length && true === auth.applications[0] ) {
                // all applications permitted
            } else if ( false === auth.applications[0] || ( app && ! auth.applications.includes( app ) ) ) {
                window.location.href = "/reactor/login.html?msg=Refused&redir=" + encodeURIComponent( window.location.pathname );
                return;
            }
            return auth;
        } catch ( err ) {
            console.error( cookie );
            console.error( err );
            window.location.href = "/reactor/login.html?msg=Invalid&redir=" + encodeURIComponent( window.location.pathname );
            return;
        }
    }
    return undefined;
}

export function getUID( prefix ) {
    /* Not good, but good enough. */
    var newx = Date.now() - 1602820800000; /* don't ever change this constant; must agree in all sources */
    if ( newx <= lastx ) {
        newx = lastx + 1;
    }
    lastx = newx;
    return ( prefix ? prefix : "" ) + newx.toString(36);
}

export function isEmpty( s ) {
    return undefined === s || null === s || "" === s ||
        ( "string" === typeof( s ) && null !== s.match( /^\s*$/ ) );
}

export function quot( s ) {
    return JSON.stringify( String(s) );
}

export function escapeHTML( s ) {
    return htmlEncode( s );
}

/* Remove special characters that disrupt JSON */
/* Ref https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-es3/def92c0a-e69f-4e5e-8c5e-9f6c9e58e28b */
export function purify( s ) {
    return "string" !== typeof(s) ? s :
        s.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u2028\u2029]/g, "");
        /* or... s.replace( /[\u007F-\uFFFF]/g, function(ch) { return "\\u" + (ch.charCodeAt(0).toString(16)).padStart(4, "0"); } ) */
}

export function clean_json_keys( key, val ) {
    return key.match( /^__/ ) ? undefined : purify( val );
}

export function hasAnyProperty( obj ) {
    return Object.keys( obj || {} ).length > 0;
}

export function clone( d ) {
    if ( null === d || Number.isNaN( d ) || (typeof d).match( /^(string|number|boolean|undefined)$/i ) ) {
        return d;
    }
    if ( structuredClone ) {  // ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone
        try {
            return structuredClone( d );
        } catch ( err ) {
            console.warn("common: structuredClone() failed:", err, d)
        }
    }
    return JSON.parse( JSON.stringify( d ) );  // sleazy but often works
}

export function idSelector( id ) {
    return String( id ).replace( /([^A-Z0-9_-])/ig, "\\$1" );
}

/* Select current value in menu; if not present, select first item. */
export function menuSelectDefaultFirst( $mm, val ) {
    var $opt = $( 'option[value=' + quot( coalesce( val, "" ) ) + ']', $mm );
    if ( 0 === $opt.length ) {
        $opt = $( 'option:first', $mm );
    }
    val = $opt.val(); /* actual value now */
    $mm.val( val );
    return val;
}

/** Select current value in menu; insert if not present. The menu txt is
 * optional.
 */
export function menuSelectDefaultInsert( $mm, val, txt ) {
    var $opt = $( 'option[value=' + quot( val ) + ']', $mm );
    if ( 0 === $opt.length ) {
        $opt = $( '<option></option>' )
            .val( val )
            .text( txt || _T('{0}? (missing)', val) );
        $mm.addClass( "tberror" )
            .append( $opt );
    }
    val = $opt.val(); /* actual value now */
    $mm.val( val );
    return val;
}

/** getWiki - Get (as jQuery) a link to Wiki for topic */
export function getWiki( where ) {
    var $v = $( '<a></a>', {
        "class": "tbdocslink",
        "alt": _T( "Link to documentation for topic" ),
        "title": _T( "Link to documentation for topic" ),
        "target": "_blank",
        "href": _DOCURL + String(where || "")
    } );
    $v.append( '<i class="bi bi-question-circle"></i>' );
    return $v;
}

/* Return the first argument that is not undefined/null/NaN */
export function coalesce( ...args ) {
    /** Note careful use of Number.isNaN() here. It returns true iff the value is currently
     *  NaN, where naked isNaN() returns true if the value *converts to* NaN. That's an important
     *  distinction, and using naked isNaN() would cause the test below to fail for any string
     *  that cannot be converted to a number (which is not the intent).
     */
    var e = args.find( val => !( "undefined" === typeof val || null === val || Number.isNaN(val) ) ); /* but false is OK */
    return ( "undefined" === typeof e ) ? null : e;
}

/* Evaluate input string as integer, strict (no non-numeric chars allowed other than leading/trailing whitespace, empty string fails). */
export function getInteger( s ) {
    s = String(s).trim().replace( /^\+/, '' ); /* leading + is fine, ignore */
    if ( s.match( /^-?[0-9]+$/ ) ) {
        return parseInt( s );
    }
    return NaN;
}

/* Like getInteger(), but returns dflt if no value provided (blank/all whitespace) */
export function getOptionalInteger( s, dflt ) {
    if ( /^\s*$/.test( String(s) ) ) {
        return dflt;
    }
    return getInteger( s );
}

/* Generate an inline checkbox. Note that "classes" applies to the input field alone, not the div. */
export function getCheckbox( id, value, label, classes, help ) {
    var $div = $( '<div class="form-check"></div>' );
    $('<input>').attr( { type: 'checkbox', id: id } )
        .val( value )
        .addClass( 'form-check-input' )
        .addClass( classes || "" )  // to add to div, add then on returned value
        .appendTo( $div );
    $('<label></label>').attr( 'for', id )
        .addClass( 'form-check-label' )
        .html( label )
        .appendTo( $div );
    if ( help ) {
        getWiki( help ).appendTo( $div );
    }
    return $div;
}

/* Generate an inline radio button */
export function getRadio( name, ix, value, label, classes, help ) {
    var $div = $( '<div></div>' ).addClass( 'form-check' );
    $('<input>').attr( { type: 'radio', id: name + ix, name: name } )
        .val( value )
        .addClass( 'form-check-input' )
        .addClass( classes || "" )
        .appendTo( $div );
    $('<label></label>').attr( 'for', name + ix )
        .addClass( 'form-check-label' )
        .html( label )
        .appendTo( $div );
    if ( help ) {
        getWiki( help ).appendTo( $div );
    }
    return $div;
}

/* Traverse - pre-order */
export function traverse( node, op, args, filter ) {
    if ( node ) {
        if ( ( !filter ) || filter( node ) ) {
            op( node, args );
        }
        if ( "group" === ( node.type || "group" ) ) {
            let l = node.conditions ? node.conditions.length : 0;
            for ( let ix=0; ix<l; ix++ ) {
                traverse( node.conditions[ix], op, args, filter );
            }
        }
    }
}

/**
 * Remap an array into a dict. In its simplest form, where only the array
 * is passed, the keys of the dictionary are the array values, and the
 * value for each key is the index in the array of the original value. Thus
 * the dictionary is a map into the array. A function can also be passed
 * that returns an object with properties "key" and "value" to set on the
 * result dictionary.
 */
export function map( arr, f ) {
    const res = {};
    const ln = arr.length;
    for ( let k=0; k<ln; k++ ) {
        if ( f ) {
            const t = f( k, arr[k] );
            res[t.key] = t.value;
        } else {
            res[arr[k]] = k;
        }
    }
    return res;
}

/**
 * Convert timestamp (msecs since Epoch) to text; if within 24 hours,
 * show time only.
 */
export function shortTime( dt ) {
    dt = coalesce( dt );
    if ( null === dt || 0 === dt ) {
        return "";
    }
    const now = new Date();
    const dd = dt instanceof Date ? dt : new Date( dt );
    const ago = Math.abs( now - dd );
    //console.log("ago",ago);
    if ( ago < 86400000 && now.getDate() === dd.getDate() ) {
        return locale_time( dd );
    }
    return locale_datetime( dd );
}

export function showSysModal( opts ) {
    return new Promise( function( resolve ) {
        const $dlg = $( '#sysdialog' );
        $dlg.removeClass().addClass( 'modal fade' );
        if ( opts?.extraClasses ) {
            $dlg.addClass( opts.extraClasses );
        }
        $dlg.data( 'opts', opts || {} );
        $( '.modal-title', $dlg ).text( opts?.title || "?" );
        if ( opts?.body instanceof $ ) {
            $( '.modal-body', $dlg ).empty().append( opts.body );
        } else {
            $( '.modal-body', $dlg ).text( opts?.body || "?" );
        }
        $( '.re-close', $dlg ).toggle( opts?.close !== false );

        /* buttons */
        const $footer = $( '.modal-footer', $dlg ).empty();
        if ( 0 === (opts?.buttons || []).length ) {
            $( '<button type="button" class="btn btn-sm btn-primary"></button>' )
                .data( 'dismiss', 'modal' )
                .data( 'index', 'close' )
                .text( 'OK' )
                .appendTo( $footer );
        } else {
            opts?.buttons.forEach( function( b, ix ) {
                const $btn = $( '<button class="btn btn-sm"></button>' )
                    .addClass( b.class || "btn-primary" )
                    .text( b.label || ix )
                    .data( 'index', b.event || ix )
                    .attr( 'data-index', b.event || ix )
                    .prop( 'disabled', !!b.disabled )
                    .appendTo( $footer );
                if ( b.close ) {
                    $( '.re-close', $dlg ).hide();
                    $btn.data( { index: 'close' } )
                        .attr( { 'data-index': 'close', 'data-bs-dismiss': 'modal' } );
                }
                $btn.prop( 'disabled', true === b.disabled );
            });
        }
        $dlg.data( 'event', "" );
        $( 'button:not([data-bs-dismiss="modal"])', $footer ).on( 'click.reactor', function( event ) {
            const $el = $( event.target );
            const index = $el.data( 'index' );
            $dlg.data( 'event', index );
            if ( "function" === typeof opts?.valid ) {
                if ( opts?.debug ) { console.log("showSysModal Promise calling validator with", index, $dlg); }
                if ( false === opts?.valid( index, $dlg ) ) {
                    if ( opts?.debug ) { console.log("showSysModal validator returned false"); }
                    $dlg.data( "event", "" );  // Remove event from dialog
                    event.preventDefault();
                    return false;
                }
            }
            // Passed validation. We're going to be done.
            $dlg.modal( 'hide' );  // event handler for hidden.bs.modal.reactor will resolve Promise.
            return false;
        });
        $dlg.on( 'keydown.reactor', ( event ) => {
            if ( opts?.keys && undefined !== opts.keys[ event.which ] ) {
                const t = opts.keys[ event.which ];
                if ( "function" === typeof t ) {
                    return t( $dlg, event );
                } else {
                    const $btn = $( `button[data-index="${t}"]`, $footer );
                    if ( 1 === $btn.length && ! $btn.prop( 'disabled' ) ) {
                        $btn.trigger( 'click' );
                    }
                }
                event.preventDefault();
                return false;
            }

            /* Default handling */

            /* Except for one-button modal, swallow ENTER key */
            if ( event.which === 13 || event.which === 10 ) {
                const $b = $( 'button', $footer );
                if ( $b.length === 1 ) {
                    $b.trigger( 'click' );
                }
                event.preventDefault();
                return false;
            }
            /* Bootstrap already handles ESC as close */
        });
        $dlg.on( 'hidden.bs.modal.reactor', function( event ) {  // eslint-disable-line no-unused-vars
            const t = $dlg.data( 'event' ) || "close";
            if ( opts?.debug ) { console.log("showSysModal resolving with", t); }
            $dlg.removeData( 'opts' );
            resolve( t );
            $( '*', $dlg ).off( '.reactor' );
        });
        $dlg.modal( 'show' );
    });
}

export function getListport() {
    return $( '<div class="re-listport container-fluid ps-0">\
  <div class="re-listport-head container-fluid ps-0"></div>\
  <div class="re-listport-body container-fluid ps-0"></div>\
</div>' );
}

export function getListportRow() {
    return $( '<div class="row re-listport-item align-items-start"></div>' );
}

export function listportResize( $listport, inc ) {
    const $body = $( '.re-listport-body', $listport );
    const container_height = window.innerHeight;
    const section_start = $body.offset().top;
    $body.css( 'max-height', ( inc * Math.floor( ( container_height - section_start ) / inc ) + 1)  + "px" );
}

export async function asyncForEach( array, callback ) {
    for ( let i = 0; i < array.length; ++i ) {
        await callback( array[i], i, array );
    }
}

// Ref: https://getbootstrap.com/docs/5.3/layout/breakpoints/
// 1-575.98 = xs, 576-767.98 = sm
const _SIZE_TO_BREAK = [ {w:1, size: 'xs'}, {w:576, size: 'sm'}, {w:768, size: 'md'}, {w:992, size: 'lg'}, {w:1200, size: 'xl'}, {w:1400, size: 'xxl'} ]

export function getWindowBreak() {
    let size = 'xs';
    for ( let sz of _SIZE_TO_BREAK ) {
        if ( ! window.matchMedia( `(min-width: ${sz.w}px)` ).matches ) {
            break;
        }
        size = sz.size;
    }
    return size;
}

// Returns true if window is at least `size` (null or xs, sm, md, lg, xl, xxl) size
export function isWindowBreak( size ) {
    const px = _SIZE_TO_BREAK.find( el => el.size === ( size || 'xs' ) );
    return px ? window.matchMedia( `(min-width: ${px.w}px)` ).matches : true;
}

export function showGlobalLoading(message="Please wait...") {
    const $overlay = $( '#global-loading-overlay' );
    const $msg = $( '#global-loading-message' );
    if ( $msg ) {
        $msg.text( message );
    }
    $overlay.removeClass( 'd-none' ).addClass( 'd-flex' );
}

export function hideGlobalLoading() {
    const $overlay = $( '#global-loading-overlay' );
    $( '#global-loading-message', $overlay ).empty();
    $overlay.removeClass( 'd-flex' ).addClass( 'd-none' );
}

export async function busyAction( msg, promise ) {
    showGlobalLoading( msg );
    return promise.finally( () => {
        hideGlobalLoading();
    });
}

export function isArrowFunction(fn) {
  return typeof fn === 'function' &&
         ! Object.hasOwn(fn, 'prototype') &&
         fn.prototype === undefined;
}

/** Attach a context (right-click) menu to the given target jQuery object.
 *  When the user right-clicks within the target and the event reaches the target's
 *  contextmenu handler (i.e. isn't preempted by another handler preventing default),
 *  the showHandler function is called to construct the menu contents, or if the
 *  showHandler argument is an array, the menu contents are constructed from the array.
 *  When a menu item is clicked, the itemHandler function is called. The optional
 *  argument "thisObject" determines what the "this" keyword represents inside the
 *  handler functions; if not provided, the target jQuery object is passed. The
 *  context menu is cleared if the user clicks outside of it or presses the ESCape key.
 *
 *  NOTA BENE: If an arrow function is used for either handler, that handler will
 *             not receive "thisObject" as its "this". Arrow functions have lexical
 *             "this" binding (the first argument to Function.call() is basically
 *             ignored/not used).
 */
export function attachContextMenu( $target, showHandler, itemHandler, thisObject ) {
    const $menu = $('#globalContextMenu');
    if ( $target.length !== 1 ) {
        throw new Error( "Can't attach context menu to multiple targets" );
    }

    // Hide our context menu AND unhook everything.
    function hide() {
        //console.log("gcm hide+cleanup");
        $menu.hide();
        $menu.empty();
        $(document).off( '.globalContextMenu' );
        $menu.off();
    }

    function show(e) {
        // Shift key prevents our context menu (allows default)
        if ( e.shiftKey ) {
            return;
        }

        const $tt = $(e.currentTarget);
        console.log("gcm show", $tt.get(0), e);

        e.preventDefault();
        e.stopPropagation();

        if ( "function" === typeof showHandler ) {
            showHandler.call( thisObject || $tt, $menu, $tt );
        } else if ( Array.isArray( showHandler ) ) {
            $menu.empty();
            for ( const el of showHandler ) {
                if ( "string" === typeof el ) {
                    if ( el.startsWith( '#' ) ) {
                        $( '<h6 class="dropdown-header"></h6>' )
                            .text( el.substring( 1 ) )
                            .appendTo( $menu );
                    } else if ( el.startsWith( '--' ) ) {
                        // Divider
                        $( '<div class="context-divider"></div>' ).appendTo( $menu );
                    } else {
                        // Simple string; action and text same.
                        $( `<a class="dropdown-item"></a>` )
                            .data( 'action', el )
                            .attr( 'data-action', el )
                            .text( el )
                            .appendTo( $menu );
                    }
                } else if ( el && "object" === typeof el ) {
                    // Object element
                    $( `<a class="dropdown-item"></a>` )
                        .data( 'action', el.action || el.text )
                        .attr( 'data-action', el.action || el.text )
                        .text( el.text || el.action || "???" )
                        .addClass( el.classes || "" )
                        .appendTo( $menu );
                }
            }
        }

        const menuWidth = $menu.outerWidth();
        const menuHeight = $menu.outerHeight();
        const windowWidth = $(window).width();
        const windowHeight = $(window).height();

        let posX = e.pageX;
        let posY = e.pageY;

        // Smart positioning - prevent menu from going off-screen
        if (posX + menuWidth > windowWidth) {
            posX = windowWidth - menuWidth - 10;
        }
        if (posY + menuHeight > windowHeight) {
            posY = windowHeight - menuHeight - 10;
        }

        $menu.css({
            top: posY + 'px',
            left: posX + 'px',
            display: 'block'
        });

        $menu.on( 'click.globalContextMenu', '.dropdown-item', (e) => {
            const action = $(e.target).data( 'action' );
            e.preventDefault();
            e.stopPropagation();
            console.log("gcm click handler", action, e);
            itemHandler.call( thisObject || $tt, action, $tt, e ); // NB can be async
            hide();
            return false;
        });

        $(document).on( 'click.globalContextMenu', (e) => {
            // Handle click on something else
            if ( ! $(e.target).closest( '#globalContextMenu' ).length ) {
                hide();
            }
        });

        // Optional: Keyboard escape to close
        $(document).on('keydown.globalContextMenu', function(e) {
            if (e.key === "Escape" || e.which === 27) {
                hide();
                return false;
            }
        });

        return false;
    }

    $target.on( 'contextmenu', show )
    //console.log("gcm hooked",$target,thisObject);
}

export function detachContextMenu( $target ) {
    $target.off( 'contextmenu' );
}
