
//\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\//
// Ai Chat functions
//\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\//

// AiModelSetId from the last toolbar click — used as a fallback when no record is selected
var sLastAiModelSetId = "";

// Chat window entries keyed by AiChatSessionId (a per-window GUID).
// Each entry: { win: WindowProxy, sessionId: string, modelId: string, recordId: string|null }
// Keying by sessionId (not modelId) allows multiple windows per model.
var AiChatWindows = {};

// The most recently activated chat window, or null if no chat window is currently active
var ActiveAiChatWindow = null;

// Record selection labels keyed by AiChatSessionId — persisted across content refreshes
var AiChatRecordLabels = {};





function GenerateGuid() {
    return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
        var r = Math.random() * 16 | 0, v = c === "x" ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
}


// Entry point from the toolbar. Resolves the target AiModelSetId (selected record or user default),
// checks UR_AI_ACCESS, then opens the chat window via callback.
function OpenAiModelChat() {
    AjaxAiOpenChatWithSelectedAiModelSetId(null/*sAiChatSessionId*/, true/*bDisplayError*/, OpenAiModelChatWindow);
}


// Like OpenAiModelChat() but calls callback(sAiModelSetId, sAiChatSessionId) once the window's document is ready.
function OpenAiModelChatAndThen(callback) {
    AjaxAiOpenChatWithSelectedAiModelSetId(null/*sAiChatSessionId*/, true/*bDisplayError*/, function (sAiModelSetId) {
        var sAiChatSessionId = OpenAiModelChatWindow(sAiModelSetId);
        var entry = AiChatWindows[sAiChatSessionId];
        var win = entry ? entry.win : null;
        if (!win) {
            if (callback) callback(sAiModelSetId, sAiChatSessionId);
            return;
        }
        var poll = setInterval(function () {
            try {
                // readyState alone is not enough — the initial blank document is also "complete".
                // Wait until the chat page's root element is present in the DOM.
                if (win.document
                    && win.document.readyState === "complete"
                    && win.document.getElementById("AiChatWindowContent")) {
                    clearInterval(poll);
                    if (callback) callback(sAiModelSetId, sAiChatSessionId);
                }
            } catch (e) {
                clearInterval(poll);
                if (callback) callback(sAiModelSetId, sAiChatSessionId);
            }
        }, 100);
    });
}


// Opens a new popup chat window for the given AiModelSetId.
// Generates a unique AiChatSessionId for this window instance so multiple windows per model are supported.
// Returns the session ID so the caller can retrieve the entry from AiChatWindows.
function OpenAiModelChatWindow(sAiModelSetId) {

    sLastAiModelSetId = sAiModelSetId;
    var sAiChatSessionId = GenerateGuid();
    var win = window.open(VirtualDirectory + "/Home/GetAiChatWindowFullPageView?AiChatSessionId=" + encodeURIComponent(sAiChatSessionId) + "&AiModelSetId=" + encodeURIComponent(sAiModelSetId), "_blank", "width=500,height=800,resizable=yes,scrollbars=no,left=100,top=60");

    AiChatWindows[sAiChatSessionId] = { win: win, sessionId: sAiChatSessionId, modelId: sAiModelSetId, recordId: null };

    win.addEventListener("focus", function () {
        if (AiChatWindows[sAiChatSessionId] && AiChatWindows[sAiChatSessionId].win === win)
            ActiveAiChatWindow = win;
    });

    // Poll until the window is closed, then clean up.
    // beforeunload/pagehide are unreliable on cross-window listeners; win.closed is the only dependable signal.
    var poll = setInterval(function () {
        if (!win.closed) return;
        clearInterval(poll);
        if (ActiveAiChatWindow === win)
            ActiveAiChatWindow = null;
        delete AiChatWindows[sAiChatSessionId];
        for (var id in AiChatWindows) {
            var next = AiChatWindows[id].win;
            if (next && !next.closed) { next.focus(); break; }
        }
    }, 500);

    return sAiChatSessionId;
}

/**
 * Called from a menu command to open ai chat from a recordset (usually a project)
 * See MenuCmdItem.CMD_CHATWITHAI
 * See BaseDataSet.OpenAiModelChatFromRecordset()
 * @param {any} RecordsetObject
 * @param {any} sDbTable
 * @param {any} sDbRecordId
 * @returns
 */
function OpenAiModelChatFromRecordset(RecordsetObject, sDbTable, sDbRecordId) {

    // Did the caller send an array with all the values inside?
    // If so, reassign parameter values from the array elements
    if (Array.isArray(RecordsetObject) && sDbTable === undefined && sDbRecordId === undefined) {
        RecordsetObject = arguments[0][0];
        sDbTable = arguments[0][1];
        sDbRecordId = arguments[0][2];
    }

    if (!sDbTable)
        return;
    if (!sDbRecordId)
        return;

    // If a chat window is already open and analyzing this exact record, just focus it
    // This prevent multiple ai chat windows from opening for the same selection
    var existingWin = GetAiChatWindowByRecordId(sDbRecordId);
    if (existingWin) {
        existingWin.focus();
        return;
    }

    // Open the ai chat window, then tell the chat window about the selected record
    // This pops up a message box asking for permission to analyze and update the item
    OpenAiModelChatAndThen(function (sNewAiModelSetId, sAiChatSessionId) {
        AjaxAiSelectPropertiesChange(sAiChatSessionId, ""/*PropertyType*/, ""/*ContentType*/, ""/*SelectedDataType*/, sDbTable, sDbRecordId, sNewAiModelSetId, true/*AskFirst*/);
    });
}



/**
 * Open an ai chat with all these record ids
 * This allows ai to anaylize and update all the records at once with a single chat prompt
 * See: MenuCmdItem.CMD_CHATWITHAIRECORDIDS
 * See BaseDataSet.OpenAiModelChatFromRecordIds()
 * This is most commonly used in the Project Tasks page where there are many tasks under multiple projects
 * @param {any} RecordIds
 */
function OpenAiModelChatFromRecordIds(RecordIds) {

    // Did the caller send an array with all the values inside?
    // If so, reassign parameter values from the array elements
    if (Array.isArray(RecordIds))
        RecordIds = arguments[0][0];

    // Still array?
    if (Array.isArray(RecordIds))
        RecordIds = arguments[0][0];

    // No record ids? Query for them from the active content page, then recurse
    if (!RecordIds) {
        var SqlScript = GetSelectedSqlScript();

        // Try getting actual record ids using this sql script
        AjaxAiQueryForRecordIds(GetActiveAiChatSessionId(), SqlScript, function (sRecordIds) {

            // There are some records
            // Call this function back again. It will fall through to the next stage below
            OpenAiModelChatFromRecordIds(sRecordIds);

        }, function () {
            // No records resulted from the SqlScript above
            PopupMessageBox("Chat With AI", "Hmm, it doesn't look like there are any items to send to AI.\r\n\r\nTry filtering the page by future dates or project.");
        });
        return;
    }

    // Should never happen, just in case...
    if (!RecordIds)
        return;

    // If a chat window is already open and analyzing this exact set of records, just focus it
    // This prevent multiple ai chat windows from opening for the same selection
    var existingWin = GetAiChatWindowByRecordId(HashRecordIds(RecordIds));
    if (existingWin) {
        existingWin.focus();
        return;
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////////////////
    // Check the number of records first
    // Ask the user if they want to proceed
    // Only proceed if the user answers Yes to the number of records to be sent to AI for analyis and updates
    // Sending a large number of records to ai could result in high token usage and costs
    // Warn the user first, then proceed if Yes
    ////////////////////////////////////////////////////////////////////////////////////////////////////////////

    var nRecordCount = RecordIds.split(",").length;
    CheckAskSendNumberOfRecordsToAi(nRecordCount, function () {

        // The record count is okay with the user
        // Open the ai chat window, then tell the chat window about the selected record ids
        // AI will analyze and update the records with a single chat prompt
        OpenAiModelChatAndThen(function (sNewAiModelSetId, sAiChatSessionId) {
            AjaxAiSelectRecordIdsChange(sAiChatSessionId, ""/*PropertyType*/, ""/*ContentType*/, ""/*SelectedDataType*/, RecordIds, sNewAiModelSetId, true/*AskFirst*/);
        });

    });
}





// Returns the AiModelSetId for the active session, derived from the session registry.
function GetActiveAiModelSetId() {
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[GetActiveAiChatSessionId()];
    return entry ? entry.modelId : null;
}

// Returns the AiChatSessionId (registry key) of the most relevant open chat window.
// Mirrors GetActiveAiModelSetId() but returns the session key instead of the model ID.
function GetActiveAiChatSessionId() {
    // In the popup, AiChatSessionId is emitted by the view directly from the URL — no cross-window lookup needed.
    if (window.opener && typeof AiChatSessionId !== "undefined" && AiChatSessionId) return AiChatSessionId;
    try { var id = window.opener.GetActiveAiChatSessionId(); if (id) return id; } catch (e) { }
    if (Object.keys(AiChatWindows).length === 0) return null;
    for (var key in AiChatWindows) {
        if (AiChatWindows[key].win === ActiveAiChatWindow) return key;
    }
    for (var key in AiChatWindows) {
        if (AiChatWindows[key].win && !AiChatWindows[key].win.closed) return key;
    }
    return null;
}

// Returns the live WindowProxy for the given AiModelSetId, or null if the window is closed or unknown.
// Prefers the currently active window when multiple windows share the same model.
function GetAiChatWindow(sAiModelSetId) {
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var activeWin = (window.opener && window.opener.ActiveAiChatWindow) ? window.opener.ActiveAiChatWindow : ActiveAiChatWindow;
    // Prefer the active window if it matches this model
    if (activeWin && !activeWin.closed) {
        for (var id in registry) {
            var e = registry[id];
            if (e.modelId === sAiModelSetId && e.win === activeWin) return e.win;
        }
    }
    // Fall back to any open window for this model
    for (var id in registry) {
        var e = registry[id];
        if (e.modelId === sAiModelSetId && e.win && !e.win.closed) return e.win;
    }
    return null;
}


// Returns the live WindowProxy for the given AiChatSessionId, or null if the window is closed or unknown.
function GetAiChatWindowBySessionId(sAiChatSessionId) {
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[sAiChatSessionId];
    return (entry && entry.win && !entry.win.closed) ? entry.win : null;
}


// Returns a short, order-independent hash key for a comma-separated RecordIds string.
// Sorts the IDs first so "a,b,c" and "c,a,b" produce the same key.
// Prefixed with "r" so it can never collide with a plain GUID used as a single-record key.
function HashRecordIds(sRecordIds) {
    var sorted = sRecordIds.split(",").map(function (s) { return s.trim(); }).sort().join(",");
    var hash = 5381;
    for (var i = 0; i < sorted.length; i++) {
        hash = ((hash << 5) + hash) ^ sorted.charCodeAt(i);
        hash = hash & hash;
    }
    return "r" + (hash >>> 0).toString(16);
}


// Sets entry.recordId on the window identified by its session GUID.
// Called after the server confirms the record loaded, so the field stays accurate.
function RegisterAiChatWindowRecord(sAiChatSessionId, sRecordId) {
    if (!sAiChatSessionId || !sRecordId) return;
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[sAiChatSessionId];
    if (entry) entry.recordId = sRecordId;
}


// Returns the live WindowProxy for the window whose recordId matches sRecordId, or null if none.
function GetAiChatWindowByRecordId(sRecordId) {
    if (!sRecordId) return null;
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    for (var id in registry) {
        var e = registry[id];
        if (e.recordId === sRecordId && e.win && !e.win.closed) return e.win;
    }
    return null;
}


// Focuses the chat window identified by its session GUID.
// Returns true if the window was found and focused, false if it is closed or unknown.
function SetAiChatSessionFocus(sAiChatSessionId) {
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[sAiChatSessionId];
    if (!entry || !entry.win || entry.win.closed) return false;
    entry.win.focus();
    return true;
}



// Close all tracked chat windows when the main window unloads.
// Uses "pagehide" instead of "beforeunload" to avoid triggering the browser's leave-page dialog.
window.addEventListener("pagehide", function () {
    for (var id in AiChatWindows) {
        var win = AiChatWindows[id].win;
        if (win && !win.closed)
            win.close();
    }
});

function SetAiIsThinking(bThinking = true) {
    var el = document.getElementById("AiChatThinking");
    if (!el) return;
    el.style.display = bThinking ? "" : "none";
}


// Resolves the document and window that own the AiChatPopup for the given sAiChatSessionId.
// Always tries the current document first — when called from inside the chat window (e.g. from
// AjaxAiUpdateSelectedRecord), the popup is right here and no cross-window round-trip is needed.
// Falls back to win.document only when called from the parent, where the popup lives in a
// separate WindowProxy. Using the current document avoids unreliable cross-window DOM wiring
// that can break event listeners and onclick resolution in some browsers.
function _ResolveAiChatPopupContext(sAiChatSessionId) {
    var popup = document.getElementById("AiChatPopup-" + sAiChatSessionId);
    if (popup) return { doc: document, vwin: window, popup: popup };
    var win = GetAiChatWindowBySessionId(sAiChatSessionId);
    if (win && !win.closed) {
        popup = win.document.getElementById("AiChatPopup-" + sAiChatSessionId);
        if (popup) return { doc: win.document, vwin: win, popup: popup };
    }
    return null;
}


// Shows a modeless popup dialog in the AI chat window with a title, message, and OK button.
// proceedcallback(msg, proceedobject) is called when the user clicks OK.
// All state (callback, timer, outside-click handler) is stored on the popup DOM element so it
// survives across window contexts — the chat window is a separate WindowProxy from the parent,
// and assigning closures cross-window via element.onclick is unreliable when the click fires in
// the other window's event loop. Storing on the element and using a static inline onclick avoids this.
function OpenAiChatPopup(sAiChatSessionId, title, msg, proceedcallback, proceedobject) {
    if (!sAiChatSessionId) return;

    // Resolve doc/vwin by trying the current document first, then the chat window via WindowProxy.
    var ctx = _ResolveAiChatPopupContext(sAiChatSessionId);
    if (!ctx) return;
    var doc  = ctx.doc;
    var vwin = ctx.vwin;

    var popup = ctx.popup;
    var okBtn = doc.getElementById("AiChatPopupOk-"      + sAiChatSessionId);
    var msgEl = doc.getElementById("AiChatPopupMessage-" + sAiChatSessionId);
    if (!okBtn || !msgEl) return;

    // If already open, tear down all old state then slide closed before reopening with the new args.
    // Clearing _proceedCallback immediately ensures the old callback cannot fire during the animation.
    if (popup.style.display !== "none") {
        if (popup._autoCloseTimer) { clearTimeout(popup._autoCloseTimer); popup._autoCloseTimer = null; }
        popup._proceedCallback = null;
        popup.style.transition = "top 0.12s ease-in";
        popup.style.top = vwin.innerHeight + "px";
        popup.addEventListener("transitionend", function reopen() {
            popup.style.transition = "none";
            popup.style.display = "none";
            popup.removeEventListener("transitionend", reopen);
            OpenAiChatPopup(sAiChatSessionId, title, msg, proceedcallback, proceedobject);
        });
        return;
    }

    doc.getElementById("AiChatPopupTitleText-" + sAiChatSessionId).textContent = title || "";
    msgEl.innerHTML = (msg || "").replace(/\r\n|\r|\n/g, "<br>");

    // Store callback on the popup element so OnAiChatPopupOk() can retrieve it regardless of
    // which window fires the click. The OK button uses a static inline onclick="OnAiChatPopupOk(sAiChatSessionId)"
    // baked in at render time, so it always executes in the chat window's own event context.
    popup._proceedCallback = proceedcallback || null;
    popup._proceedObject   = proceedobject   || null;
    popup._proceedMsg      = msg             || "";

    // Show off-screen at the bottom so offsetWidth/offsetHeight are measurable, then animate up.
    // Double rAF ensures the browser commits the start position before the animation fires.
    // Animation is removed after settling so dragging the title bar remains instant.
    popup.style.transition = "none";
    popup.style.animation  = "";
    popup.style.display = "";
    var finalLeft = Math.max(0, (vwin.innerWidth  - popup.offsetWidth)  / 2);
    var finalTop  = 20;
    popup.style.left = finalLeft + "px";
    popup.style.top  = vwin.innerHeight + "px";

    // Show overlay so real clicks outside the popup dismiss it.
    // The overlay approach avoids false triggers from synthetic events fired by jQuery
    // HTML replacement (e.g. RefreshSelectedContent) or framework focus changes.
    var overlay = doc.getElementById("AiChatPopupOverlay");
    if (overlay) overlay.style.display = "";

    // Auto-close after 2 minutes if the user takes no action
    if (popup._autoCloseTimer)
        clearTimeout(popup._autoCloseTimer);
    popup._autoCloseTimer = setTimeout(function () { CloseAiChatPopup(sAiChatSessionId); }, 2 * 60 * 1000);

    // Make the popup appear to bounce off the top before settling at its destination
    // Inject keyframes with the exact computed pixel positions so the two-bounce
    // overshoot works regardless of window height or finalTop value.
    var bounceStyleId = "AiChatPopupBounceStyle";
    var bounceStyleEl = doc.getElementById(bounceStyleId);
    if (!bounceStyleEl) {
        bounceStyleEl = doc.createElement("style");
        bounceStyleEl.id = bounceStyleId;
        doc.head.appendChild(bounceStyleEl);
    }
    bounceStyleEl.textContent = [
        "@keyframes aichat-bounce-in {",
        "  0%   { top: " + vwin.innerHeight + "px; }",
        "  65%  { top: " + (finalTop - 25)  + "px; }",
        "  78%  { top: " + (finalTop + 18)  + "px; }",
        "  88%  { top: " + (finalTop - 10)  + "px; }",
        "  94%  { top: " + (finalTop + 6)   + "px; }",
        "  100% { top: " + finalTop         + "px; }",
        "}"
    ].join("\n");

    requestAnimationFrame(function () {
        requestAnimationFrame(function () {
            popup.style.animation = "aichat-bounce-in 0.45s linear forwards";
            popup.addEventListener("animationend", function cleanup() {
                popup.style.animation = "";
                popup.style.top = finalTop + "px";
                popup.removeEventListener("animationend", cleanup);
            });
        });
    });

    okBtn.focus();
}


// Called by the OK button's static inline onclick="OnAiChatPopupOk(sAiChatSessionId)".
// Retrieves the callback stored on the popup element by OpenAiChatPopup(), then invokes it.
// Using a named global function with a static inline onclick (rather than assigning element.onclick
// cross-window) ensures the handler always fires in the chat window's own event context.
function OnAiChatPopupOk(sAiChatSessionId) {
    var ctx   = _ResolveAiChatPopupContext(sAiChatSessionId);
    if (!ctx) return;
    var doc   = ctx.doc;
    var popup = ctx.popup;

    if (popup._autoCloseTimer) { clearTimeout(popup._autoCloseTimer); popup._autoCloseTimer = null; }
    var overlay = doc.getElementById("AiChatPopupOverlay");
    if (overlay) overlay.style.display = "none";
    popup.style.display = "none";

    // Snapshot and clear before invoking — prevents double-fire if the callback reopens the popup
    var cb  = popup._proceedCallback;
    var obj = popup._proceedObject;
    var msg = popup._proceedMsg;
    popup._proceedCallback = null;
    popup._proceedObject   = null;
    popup._proceedMsg      = null;

    if (cb) cb(msg, obj);
}


// Dismisses the popup with a slide-down animation (X button, outside click, or auto-close timer).
// Tears down all associated state before animating so nothing can fire during the slide.
function CloseAiChatPopup(sAiChatSessionId) {
    var ctx   = _ResolveAiChatPopupContext(sAiChatSessionId);
    if (!ctx) return;
    var doc   = ctx.doc;
    var vwin  = ctx.vwin;
    var popup = ctx.popup;

    // Cancel auto-close timer — it may be what triggered this call, harmless either way
    if (popup._autoCloseTimer) { clearTimeout(popup._autoCloseTimer); popup._autoCloseTimer = null; }

    // Hide the overlay that was shown when the popup opened
    var overlay = doc.getElementById("AiChatPopupOverlay");
    if (overlay) overlay.style.display = "none";

    // Clear callback — overlay click and X button are a cancel, not a confirm
    popup._proceedCallback = null;
    popup._proceedObject   = null;
    popup._proceedMsg      = null;

    // Slide down off-screen, then hide. ease-in accelerates the fall for a natural feel.
    popup.style.transition = "top 0.12s ease-in";
    popup.style.top = vwin.innerHeight + "px";
    popup.addEventListener("transitionend", function cleanup() {
        popup.style.transition = "none";
        popup.style.display = "none";
        popup.removeEventListener("transitionend", cleanup);
    });
}




function RefreshAiChatSessionContent(sAiChatSessionId) {
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[sAiChatSessionId];
    if (entry && entry.win && !entry.win.closed) entry.win.RefreshSelectedContent();
}



function SetAiChatRecordSelectionLabel(sAiChatSessionId, sRecordName) {
    if (!sAiChatSessionId || !sRecordName) return;
    // Store in the main-window registry so RefreshSelectedContent can re-apply it after an HTML swap
    var labels = (window.opener && window.opener.AiChatRecordLabels) ? window.opener.AiChatRecordLabels : AiChatRecordLabels;
    labels[sAiChatSessionId] = sRecordName;
    // Apply immediately to the live DOM
    var registry = (window.opener && window.opener.AiChatWindows) ? window.opener.AiChatWindows : AiChatWindows;
    var entry = registry[sAiChatSessionId];
    if (!entry || !entry.win || entry.win.closed) return;
    var el = entry.win.document.getElementById("AiChatSelectionLabel");
    if (el) el.textContent = sRecordName;
}



function SetAiChatInputText(sInputId, text) {
    var el = document.getElementById(sInputId);
    if (!el) return;
    el.value = text;
    el.style.height = 'auto';
    el.style.height = el.scrollHeight + 'px';
    el.focus();
}


// How long until speech recognition times out and stops listening
var nAiChatSpeechRecognitionTimeoutMs = (1 * 60 * 1000);

// Shows a fixed-position "Listening..." tooltip above the mic button.
// btn may be null when called from a context without a direct button reference (e.g. keyboard shortcut);
// in that case the button is resolved from the active AiModelSetId.
function ShowListeningForSpeechToolTip(btn) {
    if (!btn) {
        var sUid = GetActiveAiModelSetId();
        if (!sUid) return;
        btn = document.getElementById("AiChatMicBtn-" + sUid);
    }
    if (!btn) return;

    // Inject keyframe animations once. ai-tooltip-pulse fades the text in and out to signal active
    // listening. ai-countdown-ring drains the SVG arc from full (offset 0) to empty (offset = circumference ~44)
    // over the timeout duration, giving the user a visual cue of time remaining.
    if (!document.getElementById("ai-tooltip-pulse-style")) {
        var s = document.createElement("style");
        s.id = "ai-tooltip-pulse-style";
        s.textContent = "@keyframes ai-tooltip-pulse { 0%,100%{opacity:1} 50%{opacity:0.25} } @keyframes ai-countdown-ring { from{stroke-dashoffset:0} to{stroke-dashoffset:44} }";
        document.head.appendChild(s);
    }

    // Tooltip container — data-from-id ties it to the button so HideListeningForSpeechToolTip can find and remove it.
    var tip = document.createElement("div");
    tip.setAttribute("data-from-id", btn.id);
    tip.style.cssText = "position:fixed; background:rgba(0,0,0,0.78); color:#fff; font-size:9pt; padding:4px 8px 4px 6px; border-radius:4px; pointer-events:none; z-index:9999; white-space:nowrap; display:flex; align-items:center; gap:6px;";

    // SVG countdown ring: 18×18 viewport, circle centered at (9,9) with r=7.
    // Circumference ≈ 2π×7 ≈ 43.98, rounded to 44 in the keyframe and stroke-dasharray.
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("width", "18");
    svg.setAttribute("height", "18");
    svg.setAttribute("viewBox", "0 0 18 18");
    svg.style.flexShrink = "0";

    // Dim background track so the draining arc has visible contrast.
    var bgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    bgCircle.setAttribute("cx", "9");
    bgCircle.setAttribute("cy", "9");
    bgCircle.setAttribute("r", "7");
    bgCircle.setAttribute("fill", "none");
    bgCircle.setAttribute("stroke", "rgba(255,255,255,0.25)");
    bgCircle.setAttribute("stroke-width", "2.5");

    // Foreground arc: stroke-dasharray sets dash length to the full circumference so the circle
    // is initially fully drawn. The animation increases stroke-dashoffset to the same value,
    // which shifts the dash out of view — visually draining the ring to empty.
    // rotate(-90) starts the arc at 12 o'clock instead of the SVG default of 3 o'clock.
    var fgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    fgCircle.setAttribute("cx", "9");
    fgCircle.setAttribute("cy", "9");
    fgCircle.setAttribute("r", "7");
    fgCircle.setAttribute("fill", "none");
    fgCircle.setAttribute("stroke", "rgba(255,255,255,0.75)");
    fgCircle.setAttribute("stroke-width", "2.5");
    fgCircle.setAttribute("stroke-dasharray", "44");
    fgCircle.setAttribute("stroke-dashoffset", "0");
    fgCircle.setAttribute("stroke-linecap", "round");
    fgCircle.setAttribute("transform", "rotate(-90 9 9)");
    fgCircle.style.animation = "ai-countdown-ring " + (nAiChatSpeechRecognitionTimeoutMs / 1000) + "s linear forwards";

    svg.appendChild(bgCircle);
    svg.appendChild(fgCircle);

    // "Listening..." text pulses independently of the ring.
    var textSpan = document.createElement("span");
    textSpan.textContent = "Listening...";
    textSpan.style.animation = "ai-tooltip-pulse 1.4s ease-in-out infinite";

    tip.appendChild(svg);
    tip.appendChild(textSpan);
    document.body.appendChild(tip);

    // Position above the button, clamped so the tooltip doesn't overflow the right edge.
    var r    = btn.getBoundingClientRect();
    var left = Math.min(Math.round(r.left), window.innerWidth - tip.offsetWidth - 4);
    tip.style.left = left + "px";
    tip.style.top  = Math.round(r.top - tip.offsetHeight - 6) + "px";
}

function HideListeningForSpeechToolTip(btnid) {
    if (!btnid) return;
    var tip = document.querySelector('[data-from-id="' + btnid + '"]');
    if (!tip) return;
    tip.style.transition = "opacity 0.3s";
    tip.style.opacity = "0";
    setTimeout(function () { if (tip.parentNode) tip.parentNode.removeChild(tip); }, 300);
}


function StartAiChatSpeechRecognition(textboxid, micbtnid, callback) {
    StartSpeechRecognition(textboxid, micbtnid, callback);

    // Speech recognition timeout
    setTimeout(function () {
        StopSpeechRecognition();
        HideListeningForSpeechToolTip(micbtnid);
    }, nAiChatSpeechRecognitionTimeoutMs);
}

// Returns a one-shot speech callback that deposits the recognized phrase into the AI chat textarea.
// Stops listening after the first result so the user can review and edit before sending.
function AiChatSpeechRecognitionCallback(textboxid, micbtnid, phrases) {

    // Stop listening, even if there is no text from speech recognition
    HideListeningForSpeechToolTip(micbtnid);
    StopSpeechRecognition();

    if (!textboxid)
        return;
    if (!phrases)
        return;
    if (phrases.length == 0)
        return;
    var sPhrase = phrases[0];
    if (!sPhrase)
        return;

    //ToastMessage("AiChatSpeechRecognitionCallback", sPhrase);

    SetAiChatInputText(textboxid, sPhrase);
}




function AiChatSend(sAiModelSetId, AiChatText) {
    sLastAiModelSetId = sAiModelSetId;
    AjaxAiProcessChatSend(GetActiveAiChatSessionId(), sAiModelSetId, AiChatText);
}

function OnAiChatShortSummary(menucmd) {
    AjaxAiSetChatDisplayType(GetActiveAiChatSessionId(), sLastAiModelSetId, "ShortSummary");
}

function OnAiChatDetailedSummary(menucmd) {
    AjaxAiSetChatDisplayType(GetActiveAiChatSessionId(), sLastAiModelSetId, "DetailedSummary");
}

function OnAiChatFullResponseSummary(menucmd) {
    AjaxAiSetChatDisplayType(GetActiveAiChatSessionId(), sLastAiModelSetId, "RawText");
}

function AiChatShowMessageList(menucmd) {
    AjaxAiGetChatMessageList(GetActiveAiChatSessionId(), sLastAiModelSetId);
}





function AjaxAiOpenChatWithSelectedAiModelSetId(sAiChatSessionId, bDisplayError, proceedcallback) {

    // Use the selected record as the first choice. It may be a model
    // If the selected record is not an AiModelSet, then find the default model from user logged-in user
    var SelectedRecordId = GetSelectedRecordId();

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "SelectedRecordId": SelectedRecordId,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiGetSelectedModelId",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            if (response.Success) {
                if (response.AiModelSetId)
                    sLastAiModelSetId = response.AiModelSetId;

                if (proceedcallback)
                    proceedcallback(response.AiModelSetId);
            }
            else {
                // User does not have this right
                if (bDisplayError) {
                    PopupMessageBox(response.PromptTitle, response.PromptMessage);
                }
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiGetSelectedModelId", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}





// Fetch the full AI model message list (the model's chat "memory") and display it
// in a resizable popup. Each message shows [Participant] followed by the raw text,
// falling back to the session display type if raw text is unavailable.
function AjaxAiGetChatMessageList(sAiChatSessionId, sAiModelSetId) {

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiGetChatMessageList",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {

                // Pop up a message box to view the Ai Model message list
                // This is the "memory" for the model
                var lines = response.Messages.map(function (m) { return "[" + m.Participant + "]\n" + m.Text; });
                var target = window.opener || window;
                target.PopupResizableMessageBox("AI Message List", "<div style='white-space:pre-wrap; overflow-wrap:break-word;'>" + lines.join("\n\n") + "</div>");
            }
            else {
                (window.opener || window).ToastError("AI Message List", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText;
            (window.opener || window).ToastError("AjaxAiGetChatMessageList", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}




function AjaxAiGetChatContextMenu(sAiChatSessionId, event, sAiModelSetId) {

    sLastAiModelSetId = sAiModelSetId;

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiGetChatContextMenu",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                if (response.ContextMenuItems) {
                    var menuitems = JSON.parse(response.ContextMenuItems);
                    var menu = new ContextMenu(menuitems);
                    menu.display(event);
                }
            }
            else {
                (window.opener || window).ToastError("Ai Chat Menu", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText;
            (window.opener || window).ToastError("AjaxAiGetChatContextMenu", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}


function AjaxAiSetChatDisplayType(sAiChatSessionId, sAiModelSetId, sAiChatReplyDisplayType) {

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
        "AiChatReplyDisplayType": sAiChatReplyDisplayType,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiSetChatDisplayType",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                RefreshSelectedContent();
            }
            else {
                (window.opener || window).ToastError("Ai Chat Menu", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText;
            (window.opener || window).ToastError("AjaxAiSetChatDisplayType", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}






// Called from both onclick and oncontextmenu on the toolbar icon button.
// sAiModelSetId identifies which chat window's menu to open (each chat panel has its own menu div).
function OnAiChatContextMenu(event, element, sAiModelSetId) {

    AjaxAiGetChatContextMenu(GetActiveAiChatSessionId(), event, sAiModelSetId);
}





function AjaxAiProcessChatSend(sAiChatSessionId, sAiModelSetId, sAiChatText, ClearMessagesAndBuildFromStart) {

    if (!sAiModelSetId)
        return;
    if (!sAiChatText)
        return;
    if (!sAiModelSetId.length)
        return;
    if (!sAiChatText.length)
        return;

    // Turn on the "Thinking" label
    SetAiIsThinking();

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
        "AiChatText": sAiChatText,
        "ClearMessagesAndBuildFromStart": ClearMessagesAndBuildFromStart,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiProcessChatSend",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            if (response.Success) {
                window.focus();
                RefreshSelectedContent();

                // Next step: Update the selected record associated with this chat
                // This is normally a project
                // Retrieve the Json results from the ai chat and update the selected project from it
                // This allows users to tell the ai how to update their ST projects
                // The Project Tasks page is the most common place to observe these updates
                if (response.HasSelectedRecord)
                    setTimeout(function () { AjaxAiUpdateSelectedRecord(sAiChatSessionId, sAiModelSetId, true/*AskFirst*/); }, 1000);
                else if (response.HasRecordIds)
                    setTimeout(function () { AjaxAiUpdateRecordIds(sAiChatSessionId, sAiModelSetId, true/*AskFirst*/); }, 1000);
            }
            else {
                (window.opener || window).ToastError("AI Chat Error", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiProcessChatSend", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}





/**
 * Get Json results from the last ai chat message and use that to update an ST record
 * This is normally a project
 * This allows users to tell ai how to edit their ST projects
 * Watch the content panel update as this occurs
 * The Project Tasks page is the most common place to observe these updates
 * @param {any} sAiModelSetId
 * @returns
 */
function AjaxAiUpdateSelectedRecord(sAiChatSessionId, sAiModelSetId, AskFirst) {

    if (!sAiModelSetId)
        return;
    if (!sAiModelSetId.length)
        return;

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
        "AskFirst": AskFirst,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiUpdateSelectedRecord",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            // Update the ST content from the previous ai action (using the last Json results from ai)
            // This normally involves showing an updated project in the content pane
            // Enter text into an ai chat, and then instantly see the results in ST
            // The Project Tasks page is the most common place to observe these updates
            if (response.Success) {
                if (response.Prompt) {
                    // Prompt Yes/No
                    // Ask the user if they want to allow AI to update the selected project
                    // If yes, rebuild the project with the last ai json results

                    window.focus();

                    // Prompt the user Yes/No
                    OpenAiChatPopup(sAiChatSessionId, response.PromptTitle, response.PromptMessage, function () {
                        window.focus();
                        SetAiIsThinking();

                        // Apply the Json results now
                        AjaxAiUpdateSelectedRecord(sAiChatSessionId, sAiModelSetId, false/*AskFirst*/);
                    });
                }
                else if (response.Refresh) {
                    window.focus();
                    RefreshSelectedContent();

                    // Tell user that change as occurred
                    (window.opener || window).ToastMessage(response.SelectedRecordName + " updated");
                    (window.opener || window).RefreshSelectedContent();
                }

                // Display a new project using this filter string
                // Show project tasks for the newly created project
                if (response.FilterJsonString)
                    (window.opener || window).ShowProjectTaskGridViewWithFilter(response.FilterJsonString);
            }
            else {
                (window.opener || window).ToastError("AI Update Selected Record Error", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiUpdateSelectedRecord", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}








/**
 * A new record was selected
 * Check to see if this new record is a project. If it is, ask the user if they want to allow ai to analyze it
 * @param {any} PropertyType
 * @param {any} ContentType
 * @param {any} SelectedDataType
 * @param {any} SelectedTable
 * @param {any} SelectedRecordId
 * @param {any} SelectedAiModelSetId
 * @param {any} AskFirst
 */
function AjaxAiSelectPropertiesChange(sAiChatSessionId, PropertyType, ContentType, SelectedDataType, SelectedTable, SelectedRecordId, SelectedAiModelSetId, AskFirst) {

    // Turn on the "Thinking" label
    SetAiIsThinking();

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "PropertyType": PropertyType,
        "ContentType": ContentType,
        "SelectedDataType": SelectedDataType,
        "SelectedTable": SelectedTable,
        "SelectedRecordId": SelectedRecordId,
        "SelectedAiModelSetId": SelectedAiModelSetId,
        "AskFirst": AskFirst,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiSelectPropertiesChange",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            if (response.Success) {
                if (response.Prompt) {
                    // Prompt Yes/No
                    // Ask the user if they want to allow AI to analyze the selected project
                    // If yes, send that project to the ai chat
                    SetAiChatSessionFocus(sAiChatSessionId);
                    OpenAiChatPopup(sAiChatSessionId, response.PromptTitle, response.PromptMessage, function () {
                        SetAiChatSessionFocus(sAiChatSessionId);
                        RefreshAiChatSessionContent(sAiChatSessionId);
                        AjaxAiSelectPropertiesChange(sAiChatSessionId, PropertyType, ContentType, SelectedDataType, SelectedTable, SelectedRecordId, SelectedAiModelSetId, false/*AskFirst*/);
                    });
                }
                else if (response.Refresh) {
                    SetAiChatSessionFocus(sAiChatSessionId);
                    SetAiChatRecordSelectionLabel(sAiChatSessionId, response.SelectedProjectName);
                    RegisterAiChatWindowRecord(sAiChatSessionId, SelectedRecordId);
                    RefreshAiChatSessionContent(sAiChatSessionId);
                }
            }
            else {
                // Failed
                ToastError(AjaxAiSelectPropertiesChange, response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiSelectPropertiesChange", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}



function OnAiChatSelectPropertiesChange(PropertyType, ContentType, SelectedDataType, SelectedTable, SelectedRecordId, SelectedCellTable, SelectedCellColumnId, PropertyValuesJsonString, FilterJsonString) {

    // Is there an ai chat window open
    var sAiChatSessionId = GetActiveAiChatSessionId();
    if (!sAiChatSessionId) return;

    var sAiModelSetId = AiChatWindows[sAiChatSessionId].modelId;

    // Ask the user if they want to allow ai to analyze the selected project
    // If yes, send that project to the ai chat
    AjaxAiSelectPropertiesChange(sAiChatSessionId, PropertyType, ContentType, SelectedDataType, SelectedTable, SelectedRecordId, sAiModelSetId, true/*AskFirst*/);
}





function AjaxAiSelectRecordIdsChange(sAiChatSessionId, PropertyType, ContentType, SelectedDataType, RecordIds, SelectedAiModelSetId, AskFirst) {

    // Turn on the "Thinking" label
    SetAiIsThinking();

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "PropertyType": PropertyType,
        "ContentType": ContentType,
        "SelectedDataType": SelectedDataType,
        "RecordIds": RecordIds,
        "SelectedAiModelSetId": SelectedAiModelSetId,
        "AskFirst": AskFirst,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiSelectRecordIdsChange",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            if (response.Success) {
                if (response.Prompt) {
                    // Prompt Yes/No
                    // Ask the user if they want to allow AI to analyze the selected project
                    // If yes, send that project to the ai chat
                    SetAiChatSessionFocus(sAiChatSessionId);
                    OpenAiChatPopup(sAiChatSessionId, response.PromptTitle, response.PromptMessage, function () {
                        SetAiChatSessionFocus(sAiChatSessionId);
                        RefreshAiChatSessionContent(sAiChatSessionId);
                        AjaxAiSelectRecordIdsChange(sAiChatSessionId, PropertyType, ContentType, SelectedDataType, RecordIds, SelectedAiModelSetId, false/*AskFirst*/);
                    });
                }
                else if (response.Refresh) {
                    SetAiChatSessionFocus(sAiChatSessionId);
                    SetAiChatRecordSelectionLabel(sAiChatSessionId, response.RecordIdsName);
                    RegisterAiChatWindowRecord(sAiChatSessionId, HashRecordIds(RecordIds));
                    RefreshAiChatSessionContent(sAiChatSessionId);
                }
            }
            else {
                // Failed
                ToastError(AjaxAiSelectRecordIdsChange, response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiSelectRecordIdsChange", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}



function AjaxAiQueryForRecordIds(sAiChatSessionId, SqlScript, proceedcallback, norecordscallback) {

    // Turn on the "Thinking" label
    SetAiIsThinking();

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "SqlScript": SqlScript,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiQueryForRecordIds",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            if (response.Success) {
                if (response.RecordIds && proceedcallback)
                    proceedcallback(response.RecordIds);
                else if (!response.RecordIds && norecordscallback)
                    norecordscallback();
            }
            else {
                // Failed
                ToastError(AjaxAiQueryForRecordIds, response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiQueryForRecordIds", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}



function AjaxAiUpdateRecordIds(sAiChatSessionId, sAiModelSetId, AskFirst) {

    if (!sAiModelSetId)
        return;
    if (!sAiModelSetId.length)
        return;

    var params = {
        "AiChatSessionId": sAiChatSessionId,
        "AiModelSetId": sAiModelSetId,
        "AskFirst": AskFirst,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxAiUpdateRecordIds",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {

            // Update the ST content from the previous ai action (using the last Json results from ai)
            // This normally involves showing an updated project in the content pane
            // Enter text into an ai chat, and then instantly see the results in ST
            // The Project Tasks page is the most common place to observe these updates
            if (response.Success) {
                if (response.Prompt) {
                    // Prompt Yes/No
                    // Ask the user if they want to allow AI to update the selected project
                    // If yes, rebuild the project with the last ai json results

                    window.focus();

                    // Prompt the user Yes/No
                    OpenAiChatPopup(sAiChatSessionId, response.PromptTitle, response.PromptMessage, function () {
                        window.focus();
                        SetAiIsThinking();

                        // Apply the Json results now
                        AjaxAiUpdateRecordIds(sAiChatSessionId, sAiModelSetId, false/*AskFirst*/);
                    });
                }
                else if (response.Refresh) {
                    window.focus();
                    RefreshSelectedContent();

                    // Tell user that change as occurred
                    (window.opener || window).ToastMessage("Items updated");
                    (window.opener || window).RefreshSelectedContent();
                }

                // Display a new project using this filter string
                // Show project tasks for the newly created project
                if (response.FilterJsonString)
                    (window.opener || window).ShowProjectTaskGridViewWithFilter(response.FilterJsonString);
            }
            else {
                (window.opener || window).ToastError("AI Update Items Error", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + sAiChatText;
            (window.opener || window).ToastError("AjaxAiUpdateRecordIds", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}








