import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";

const DEBOUNCE_DELAY = 500;

const LORA_NODE_CLASSES = new Set([
    "Lora Loader (LoraManager)",
    "Lora Stacker (LoraManager)",
    "WanVideo Lora Select (LoraManager)",
    "Create Hook LoRA (LoraManager)",
]);

const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);

// Node classes whose "text" widget is a prompt text input (not LoRA syntax, notes, etc.)
const TEXT_CAPABLE_CLASSES = new Set([
    "Prompt (LoraManager)",
    "Text (LoraManager)",
    "CLIPTextEncode",
]);

/**
 * Parse a hex color (#RGB or #RRGGBB) into an [r, g, b] tuple.
 */
function hexToRgb(hex) {
    let h = hex.slice(1);
    if (h.length === 3) {
        h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
    }
    const n = parseInt(h, 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

/**
 * Linearly interpolate between two [r, g, b] tuples.
 */
function lerpColor(from, to, t) {
    return [
        Math.round(from[0] + (to[0] - from[0]) * t),
        Math.round(from[1] + (to[1] - from[1]) * t),
        Math.round(from[2] + (to[2] - from[2]) * t),
    ];
}

/**
 * Run a short rAF-driven color fade on a canvas-drawn widget's text_color.
 * Sets text_color to an interpolated rgb() string each frame. Returns a
 * cancel function.
 *
 * @param widget   the widget instance (must have a configurable text_color)
 * @param fromColor  [r, g, b] start color
 * @param toColor    [r, g, b] end color
 * @param duration  fade duration in ms
 * @returns {function} cancel function — stops the fade immediately.
 */
function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
    let rafId = null;
    const start = performance.now();
    const tick = () => {
        const elapsed = performance.now() - start;
        const t = Math.min(1, elapsed / duration);
        // Ease-out cubic for a smooth deceleration.
        const eased = 1 - Math.pow(1 - t, 3);
        const [r, g, b] = lerpColor(fromColor, toColor, eased);
        Object.defineProperty(widget, 'text_color', {
            value: `rgb(${r},${g},${b})`,
            writable: true,
            configurable: true,
        });
        if (t < 1) {
            rafId = requestAnimationFrame(tick);
        }
    };
    rafId = requestAnimationFrame(tick);
    return () => { if (rafId) cancelAnimationFrame(rafId); };
}

// ---------------------------------------------------------------------------
// Primitive node helpers
// ---------------------------------------------------------------------------

/**
 * Set of node type names that represent Primitive value nodes.
 * Includes both the dynamic PrimitiveNode (created by double-clicking
 * a widget input) and the static typed primitives from the node library.
 */
const PRIMITIVE_NODE_TYPES = new Set([
    "PrimitiveNode",          // dynamic (double-click a widget input)
    "PrimitiveInt",
    "PrimitiveFloat",
    "PrimitiveString",
    "PrimitiveBoolean",
    "PrimitiveStringMultiline",
]);

/**
 * Return true when `node` is any flavour of Primitive node.
 * @param {Object} node - LiteGraph node instance
 * @returns {boolean}
 */
function isPrimitiveNodeType(node) {
    return PRIMITIVE_NODE_TYPES.has(node?.type);
}

/**
 * Find the 0-based input slot index whose widget name matches `widgetName`.
 * Returns -1 when no matching input is found.
 *
 * Matching strategy (in order):
 *  1. `input.widget?.name === widgetName` — direct widget ref (preferred)
 *  2. `input.name === widgetName`        — fallback by slot name
 *
 * @param {Object} node       - LiteGraph node instance
 * @param {string} widgetName
 * @returns {number}
 */
function findInputSlotForWidget(node, widgetName) {
    if (!node || !Array.isArray(node.inputs)) {
        return -1;
    }
    return node.inputs.findIndex(
        (inp) => inp?.widget?.name === widgetName || inp?.name === widgetName
    );
}

/**
 * If the input slot that backs `widgetName` on `node` is connected to a
 * Primitive node, return that Primitive node. Otherwise return null.
 *
 * This is the key bridge for the "send gen params → Primitive" flow:
 * when a KSampler widget (e.g. "steps") has an incoming wire from a
 * Primitive node, we want to update the Primitive's value instead of the
 * KSampler widget, because ComfyUI's execution engine reads from the
 * connected input, not the widget.
 *
 * @param {Object} node       - the target node (e.g. KSampler)
 * @param {string} widgetName - e.g. "steps", "cfg", "seed"
 * @returns {Object|null}     - the connected Primitive node, or null
 */
function tryResolvePrimitiveConnection(node, widgetName) {
    const slotIndex = findInputSlotForWidget(node, widgetName);
    if (slotIndex === -1) return null;

    const input = node.inputs[slotIndex];
    if (input?.link == null) return null;

    const link = getLinkFromGraph(node.graph, input.link);
    if (!link) return null;

    const originNode = node.graph?.getNodeById?.(link.origin_id);
    if (!originNode) return null;

    return isPrimitiveNodeType(originNode) ? originNode : null;
}

/**
 * Resolve the widget that "send prompt" targets on `node`: the first
 * string-typed widget, falling back to the first non-hidden widget.
 * Shared by `isPromptWidgetConnected` and `applyWidgetUpdate` so the
 * candidate-set logic and the write path cannot drift apart.
 *
 * @param {Object} node - LiteGraph node instance
 * @returns {Object|null} - the target widget, or null when none is suitable
 */
function resolveTextWidget(node) {
    if (!node || !Array.isArray(node.widgets)) {
        return null;
    }

    const TEXT_TYPES = new Set(["string", "customtext"]);
    return (
        node.widgets.find((w) => {
            const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
            return TEXT_TYPES.has(t) || t.includes("string");
        }) ??
        node.widgets.find((w) => w?.name && !w.name.startsWith("_")) ??
        null
    );
}

/**
 * True when the widget that "send prompt" would update on `node` is backed by
 * a connected input — ComfyUI execution reads the linked input, so updating
 * the widget would be a silent no-op. Such nodes must not be offered as
 * prompt/embedding send targets.
 *
 * @param {Object} node - LiteGraph node instance
 * @returns {boolean}
 */
function isPromptWidgetConnected(node) {
    if (!node || !Array.isArray(node.inputs)) {
        return false;
    }

    const targetWidget = resolveTextWidget(node);
    if (!targetWidget?.name) {
        return false;
    }

    const slotIndex = findInputSlotForWidget(node, targetWidget.name);
    return slotIndex >= 0 && node.inputs[slotIndex]?.link != null;
}

app.registerExtension({
    name: "LoraManager.WorkflowRegistry",

    setup() {
        ensureLmStyles();
        this._log("extension initialized, clientId=%s", api.clientId ?? api.initialClientId ?? "(pending)");

        api.addEventListener("lora_registry_refresh", () => {
            this.refreshRegistry(true);
        });

        api.addEventListener("lm_widget_update", (event) => {
            this.applyWidgetUpdate(event?.detail ?? {});
        });

        api.addEventListener("lm_load_workflow", (event) => {
            this.loadWorkflowFromMessage(event?.detail ?? {});
        });

        window.addEventListener("lm_marker_changed", () => {
            this.refreshRegistry();
        });

        this._hookGraphChanges();
    },

    async afterConfigureGraph(_missingNodeTypes, _app) {
        this._log("afterConfigureGraph: workflow loaded (%s missing types)", _missingNodeTypes?.length ?? 0);
        await this.refreshRegistry();
    },

    _hookGraphChanges() {
        const graph = app.graph;
        if (!graph) {
            this._log("app.graph not available, skipping proactive hooks");
            return;
        }

        let hooksInstalled = 0;

        const scheduleRefresh = (source) => {
            if (this._debounceTimer != null) {
                clearTimeout(this._debounceTimer);
            }
            this._debounceTimer = setTimeout(() => {
                this._debounceTimer = null;
                this.refreshRegistry();
            }, DEBOUNCE_DELAY);
        };

        try {
            chainCallback(graph, "onNodeAdded", () => scheduleRefresh("onNodeAdded"));
            chainCallback(graph, "onNodeRemoved", () => scheduleRefresh("onNodeRemoved"));
            hooksInstalled += 2;
        } catch (e) {
            this._log("failed to chain LiteGraph hooks: %s", e.message);
        }

        // Link connect/disconnect changes whether a widget is externally driven
        // (e.g. CLIP Text Encode "text" wired to another node), which affects
        // the text-send candidate set — re-register on connection changes.
        const hookLinkChanges = (targetGraph) => {
            if (!targetGraph) {
                return false;
            }
            if (typeof targetGraph.events?.addEventListener === "function") {
                targetGraph.events.addEventListener("node:slot-links:changed", () =>
                    scheduleRefresh("link")
                );
                return true;
            }
            // Classic litegraph: structural edits (incl. link connect/disconnect)
            // flow through beforeChange/afterChange.
            chainCallback(targetGraph, "onAfterChange", () => scheduleRefresh("afterChange"));
            return true;
        };

        try {
            if (hookLinkChanges(graph)) {
                hooksInstalled += 1;
                // Links wired inside a subgraph dispatch on that subgraph's own
                // graph events, not the root's — hook existing and future subgraphs.
                for (const subgraph of getChildGraphs(graph)) {
                    if (hookLinkChanges(subgraph)) {
                        hooksInstalled += 1;
                    }
                }
                graph.events?.addEventListener?.("subgraph-created", (event) => {
                    if (hookLinkChanges(event?.subgraph)) {
                        hooksInstalled += 1;
                    }
                });
            }
        } catch (e) {
            this._log("failed to hook graph link changes: %s", e.message);
        }

        if (typeof api.addEventListener === "function") {
            try {
                api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
                hooksInstalled += 1;
            } catch (_e) {
                // graphChanged may not be available on older ComfyUI versions
            }
        }

        this._log("%s proactive hooks installed on graph", hooksInstalled);
    },

    _log(format, ...args) {
        const ts = new Date().toISOString().slice(11, 23);
        let msg = format;
        for (const arg of args) {
            msg = msg.replace(/%s/, String(arg));
        }
        console.debug(`[LM:Registry ${ts}] ${msg}`);
    },

    async refreshRegistry(force = false) {
        try {
            const workflowNodes = [];
            const nodeEntries = getAllGraphNodes(app.graph);

            for (const { graph, node } of nodeEntries) {
                if (!node) {
                    continue;
                }

                const widgetNames = Array.isArray(node.widgets)
                    ? node.widgets
                          .map((widget) => widget?.name)
                          .filter((name) => typeof name === "string" && name.length > 0)
                    : [];

                const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
                const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
                const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
                const markerRole = node.properties?.lm_marker_role ?? null;

                // A prompt-capable node whose text widget is wired to another
                // node cannot have its text changed via the widget — execution
                // reads the linked input. Drop it from text-send candidates.
                const textWidgetConnected =
                    hasTextWidget || markerRole === "send_prompt_target"
                        ? isPromptWidgetConnected(node)
                        : false;

                if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
                    continue;
                }

                const reference = getNodeReference(node);
                if (!reference) {
                    continue;
                }

                const graphName =
                    typeof graph?.name === "string" && graph.name.trim() ? graph.name : null;

                workflowNodes.push({
                    node_id: reference.node_id,
                    graph_id: reference.graph_id,
                    graph_name: graphName,
                    bgcolor: node.bgcolor ?? node.color ?? null,
                    title: node.title || node.comfyClass,
                    type: node.comfyClass,
                    comfy_class: node.comfyClass,
                    mode: node.mode,
                    marker_role: markerRole,
                    capabilities: {
                        supports_lora: supportsLora,
                        has_text_widget: hasTextWidget && !textWidgetConnected,
                        text_widget_connected: textWidgetConnected,
                        widget_names: widgetNames,
                    },
                });
            }

            const clientId = api.clientId ?? api.initialClientId ?? "";

            // Content-based dedup: skip POST if identical to last sent payload,
            // unless forced (e.g. responding to a lora_registry_refresh WS message
            // where the backend explicitly requests a re-registration).
            // text_widget_connected is part of the fingerprint so that link
            // connect/disconnect changes re-register the affected nodes.
            const fingerprint = JSON.stringify(
                workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}|${n.capabilities.text_widget_connected}`).sort()
            );
            if (!force && fingerprint === this._lastFingerprint) {
                return;
            }
            this._lastFingerprint = fingerprint;

            const response = await fetch("/api/lm/register-nodes", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({
                    nodes: workflowNodes,
                    client_id: clientId,
                }),
            });

            if (!response.ok) {
                console.warn("LoRA Manager: failed to register workflow nodes", response.statusText);
            } else {
                console.debug(
                    `LoRA Manager: registered ${workflowNodes.length} workflow nodes`
                );
            }
        } catch (error) {
            console.error("LoRA Manager: error refreshing workflow registry", error);
        }
    },

    /**
     * Load a workflow received from the backend onto the ComfyUI canvas.
     *
     * The payload (`detail`) is expected to be:
     *   { workflow: <workflow JSON>, name: <string>, recipe_id: <string> }
     *
     * Two formats are supported, detected by the presence of `class_type`
     * entries (API/prompt format: { node_id: { class_type, inputs } }) versus
     * the UI format ({ nodes: [...], links: [...] }).
     *
     * @param {Object} detail - message payload from the backend
     */
    async loadWorkflowFromMessage(detail) {
        let workflow = detail?.workflow;
        if (!workflow) {
            console.warn("LoRA Manager: lm_load_workflow received without a workflow payload", detail);
            return;
        }

        if (typeof workflow === "string") {
            try {
                workflow = JSON.parse(workflow);
            } catch (error) {
                console.warn("LoRA Manager: lm_load_workflow carried a non-JSON workflow string", error);
                return;
            }
        }

        // Mirror ComfyUI_frontend's isApiJson detection: API format maps every
        // node id to an object with a string class_type; UI format has arrays
        // of nodes/links instead.
        const values = Object.values(workflow);
        const isApiFormat = values.length > 0 && values.every((v) => v && typeof v.class_type === "string");

        const workflowName = detail.name || "Recipe Workflow";

        try {
            if (isApiFormat) {
                await app.loadApiJson(workflow, workflowName);
            } else {
                await app.loadGraphData(workflow, true, true, workflowName, { openSource: "file_button" });
            }
            this._log("workflow loaded from backend message (%s): %s", isApiFormat ? "api" : "graph", workflowName);
        } catch (error) {
            console.error("LoRA Manager: failed to load workflow from backend message", error);
        }
    },

    applyWidgetUpdate(message) {
        const nodeId = message?.node_id ?? message?.id;
        const graphId = message?.graph_id;
        const action = message?.action;
        const widgetName = message?.widget_name;
        const value = message?.value;
        const mode = message?.mode ?? "replace";

        if (nodeId == null || (!action && !widgetName)) {
            console.warn("LoRA Manager: invalid widget update payload", message);
            return;
        }

        const node = getNodeFromGraph(graphId, nodeId);
        if (!node) {
            console.warn(
                "LoRA Manager: target node not found for widget update",
                graphId ?? "root",
                nodeId
            );
            return;
        }

        if (!Array.isArray(node.widgets)) {
            console.warn("LoRA Manager: node does not expose widgets", node);
            return;
        }

        // ---- Resolve target widget ----
        let targetWidget = null;

        if (action === "inject_text") {
            targetWidget = resolveTextWidget(node);
            if (!targetWidget) {
                console.warn(
                    "LoRA Manager: no suitable widget for inject_text on node",
                    node.id
                );
                return;
            }

            // The widget is backed by a connected input: ComfyUI execution
            // reads the linked value, so updating the widget is a no-op.
            // Guard against stale registry entries (e.g. a link was just
            // connected before the registry refresh debounce elapsed).
            const slotIndex = findInputSlotForWidget(node, targetWidget.name);
            if (slotIndex >= 0 && node.inputs[slotIndex]?.link != null) {
                console.warn(
                    "LoRA Manager: widget '%s' on node %d is connected to an input; widget value cannot be changed",
                    targetWidget.name,
                    node.id
                );
                // Self-heal the registry so the node drops out of the
                // send-target list instead of being offered again.
                this.refreshRegistry(true);
                return;
            }
        } else if (widgetName) {
            // Legacy: find widget by name
            targetWidget = node.widgets.find((w) => w?.name === widgetName);
            if (!targetWidget) {
                console.warn(
                    "LoRA Manager: target widget not found on node",
                    widgetName,
                    node
                );
                return;
            }
        } else {
            console.warn("LoRA Manager: no action or widget_name in payload", message);
            return;
        }

        // ---- Redirect to connected Primitive node when present ----
        // When a widget input (e.g. "steps", "cfg", "seed" on KSampler)
        // is wired to a Primitive node, the Primitive's value overrides
        // the widget value during execution.  Update the Primitive
        // directly so the change actually takes effect.
        if (widgetName) {
            const primitiveNode = tryResolvePrimitiveConnection(node, widgetName);
            if (primitiveNode) {
                const primWidget = primitiveNode.widgets?.[0];
                if (primWidget) {
                    let primNewValue = value;
                    if (mode === "append") {
                        const sep =
                            primWidget.value && primWidget.value.length > 0
                                ? " "
                                : "";
                        primNewValue = primWidget.value + sep + value;
                    }
                    primWidget.value = primNewValue;
                    if (
                        Array.isArray(primitiveNode.widgets_values) &&
                        primitiveNode.widgets_values.length > 0
                    ) {
                        primitiveNode.widgets_values[0] = primNewValue;
                    }
                    if (typeof primWidget.callback === "function") {
                        try {
                            primWidget.callback(primNewValue);
                        } catch (callbackError) {
                            console.error(
                                "LoRA Manager: primitive widget callback failed",
                                callbackError
                            );
                        }
                    }
                    if (typeof primitiveNode.setDirtyCanvas === "function") {
                        primitiveNode.setDirtyCanvas(true);
                    }
                    if (typeof app.graph?.setDirtyCanvas === "function") {
                        app.graph.setDirtyCanvas(true, true);
                    }
                    this.flashWidget(primitiveNode, primWidget);
                    console.debug(
                        "LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o",
                        primitiveNode.type,
                        primitiveNode.id,
                        widgetName,
                        primNewValue
                    );
                    return;
                }
            }
        }

        // ---- Update widget value ----
        const widgetIndex = node.widgets.indexOf(targetWidget);
        let newValue = value;

        if (mode === "append") {
            const separator =
                targetWidget.value && targetWidget.value.length > 0 ? " " : "";
            newValue = targetWidget.value + separator + value;
        }

        targetWidget.value = newValue;

        if (
            Array.isArray(node.widgets_values) &&
            widgetIndex >= 0 &&
            node.widgets_values.length > widgetIndex
        ) {
            node.widgets_values[widgetIndex] = newValue;
        }

        if (typeof targetWidget.callback === "function") {
            try {
                targetWidget.callback(newValue);
            } catch (callbackError) {
                console.error("LoRA Manager: widget callback failed", callbackError);
            }
        }

        if (typeof node.setDirtyCanvas === "function") {
            node.setDirtyCanvas(true);
        }

        if (typeof app.graph?.setDirtyCanvas === "function") {
            app.graph.setDirtyCanvas(true, true);
        }

        // ---- Visual cue: briefly highlight the updated widget ----
        this.flashWidget(node, targetWidget);
    },

    /**
     * Add a temporary visual highlight to a widget after its value is updated.
     *
     * Both rendering modes shift the value text color to the LM brand accent
     * (#4299E0) with a fade-in/fade-out, then restore it after FLASH_DURATION
     * (3s) or on hover:
     *  - Vue Nodes mode: add a `.lm-flash` class to the widget row. CSS
     *    `transition: color 0.25s` handles fade-in/out. A MutationObserver
     *    re-applies the class if Vue re-renders the row during the flash.
     *  - Canvas mode: DOM widgets (customtext/autocomplete) use inline
     *    `transition` for fade; canvas-drawn widgets (combo/number/toggle) use
     *    a short rAF color interpolation for fade-in (250ms) and fade-out
     *    (400ms). A low-frequency poll checks hover dismissal via
     *    app.canvas.getWidgetAtCursor().
     */
    flashWidget(node, widget) {
        const FLASH_DURATION = 3000;
        const FADE_IN_MS = 250;
        const VALUE_COLOR = '#4299E0'; // LM brand accent — consistent with selection/border/drop-indicator
        const nodeId = node.id;

        // ---- Vue Nodes mode: CSS class for value text color ----
        const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
        if (nodeEl) {
            this._flashVueWidget(nodeEl, widget, node, {
                FLASH_DURATION, VALUE_COLOR,
            });
            return;
        }

        // ---- Canvas mode ----
        this._flashCanvasWidget(node, widget, {
            FLASH_DURATION, FADE_IN_MS, VALUE_COLOR,
        });
    },

    /**
     * Vue/DOM flash: add `.lm-flash` class to the widget row for the value text
     * color shift. Re-applies on re-render via MutationObserver. Removes on
     * timeout / hover.
     */
    _flashVueWidget(nodeEl, widget, graphNode, { FLASH_DURATION, VALUE_COLOR }) {
        const FLASH_CLASS = 'lm-flash';

        // Find the widget row in the DOM. Vue renders widget rows as
        // [data-testid="node-widget"] elements whose order matches node.widgets[].
        // Match strategy (in priority order):
        //  1. By label text via [data-testid="widget-layout-field-label"] (combo/number/toggle)
        //  2. By <label> text (CLIPTextEncode customtext has a bare <label>)
        //  3. By widget index — graph node.widgets[i] ↔ Nth DOM row (text widgets
        //     like Prompt LM have no label at all, so index is the only stable match)
        const widgetIndex = Array.isArray(graphNode?.widgets)
            ? graphNode.widgets.indexOf(widget)
            : -1;

        const findRowEl = () => {
            const rows = nodeEl.querySelectorAll('[data-testid="node-widget"]');
            // Strategy 1: data-testid label
            for (const r of rows) {
                const label = r.querySelector('[data-testid="widget-layout-field-label"]');
                if (label && label.textContent.trim() === widget.name) {
                    return r;
                }
            }
            // Strategy 2: bare <label> element
            for (const r of rows) {
                const label = r.querySelector('label');
                if (label && label.textContent.trim() === widget.name) {
                    return r;
                }
            }
            // Strategy 3: index match
            if (widgetIndex >= 0 && widgetIndex < rows.length) {
                return rows[widgetIndex];
            }
            return null;
        };

        let cleanedUp = false;
        const cleanupFns = [];

        const cleanup = () => {
            if (cleanedUp) return;
            cleanedUp = true;
            for (const fn of cleanupFns) {
                try { fn(); } catch (e) { /* ignore */ }
            }
            // Remove .lm-flash to trigger the CSS color fade-out. Keep
            // .lm-flash-host (which carries the transition rule) until the
            // fade-out completes, then remove it.
            const row = findRowEl();
            if (row) {
                row.classList.remove(FLASH_CLASS);
                // Remove the host class after the transition completes.
                setTimeout(() => {
                    const r = findRowEl();
                    if (r) r.classList.remove('lm-flash-host');
                }, 300);
            }
        };

        // Initial application
        const apply = () => {
            const row = findRowEl();
            if (row && !row.classList.contains(FLASH_CLASS)) {
                // Restart the animation by toggling the class off-on.
                row.classList.remove(FLASH_CLASS);
                // Force reflow so the animation restarts.
                void row.offsetWidth;
                row.classList.add('lm-flash-host');
                row.classList.add(FLASH_CLASS);
            }
        };
        apply();

        // Re-apply if Vue re-renders and drops the class.
        const observer = new MutationObserver(() => {
            if (cleanedUp) return;
            apply();
        });
        observer.observe(nodeEl, { childList: true, subtree: true });
        cleanupFns.push(() => observer.disconnect());

        // Hard timeout: remove the class after FLASH_DURATION.
        const timeoutId = setTimeout(cleanup, FLASH_DURATION + 100);
        cleanupFns.push(() => clearTimeout(timeoutId));

        // Hover dismissal: clear the flash when the user mouses over the row.
        const onHover = (e) => {
            const row = findRowEl();
            if (row && row.contains(e.target)) {
                cleanup();
            }
        };
        nodeEl.addEventListener('mouseover', onHover);
        cleanupFns.push(() => nodeEl.removeEventListener('mouseover', onHover));
    },

    /**
     * Canvas flash: set text_color (canvas-drawn widgets) and inline color
     * (DOM widgets). Canvas-drawn widgets get a rAF-driven color fade-in/out;
     * DOM widgets use CSS transition. A low-frequency poll checks hover
     * dismissal via app.canvas.getWidgetAtCursor().
     */
    _flashCanvasWidget(node, widget, { FLASH_DURATION, FADE_IN_MS, VALUE_COLOR }) {
        const FADE_OUT_MS = 400;
        const FADE_OUT_START = FLASH_DURATION - FADE_OUT_MS;
        const DEFAULT_RGB = hexToRgb('#DDD'); // LiteGraph WIDGET_TEXT_COLOR
        const FLASH_RGB = hexToRgb(VALUE_COLOR);

        /**
         * Check whether a widget is a DOM-based widget (customtext / autocomplete)
         * that renders a real <textarea>/<input> element rather than being
         * canvas-drawn. Evaluated per-widget so batch cleanup handles each
         * widget correctly regardless of when it was added to the batch.
         */
        const isDomWidget = (w) =>
            (w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
            || !!w.element?.querySelector?.('textarea, input');

        /**
         * Get the DOM element for a DOM-based widget.
         */
        const getDomEl = (w) =>
            (w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
                ? w.inputEl
                : w.element?.querySelector?.('textarea, input') ?? null;

        // --- Track fade-out cancellers per widget so batch cleanup can stop
        //     any in-progress fade for ALL widgets in the batch, not just the
        //     latest one. ---
        if (!node._lmFadeCancels) node._lmFadeCancels = {};

        // --- DOM widget color (customtext / autocomplete text) ---
        // CSS transition handles both fade-in and fade-out automatically.
        if (isDomWidget(widget)) {
            const domEl = getDomEl(widget);
            if (domEl) {
                domEl.style.transition = `color ${FADE_IN_MS}ms ease`;
                domEl.style.color = VALUE_COLOR;
            }
        }

        // --- Canvas-drawn widget: kick off fade-in via rAF ---
        if (!isDomWidget(widget)) {
            // Set immediately to start (rAF will refine from first frame).
            Object.defineProperty(widget, 'text_color', {
                value: VALUE_COLOR,
                writable: true,
                configurable: true,
            });
            const cancel = fadeWidgetTextColor(widget, DEFAULT_RGB, FLASH_RGB, FADE_IN_MS);
            node._lmFadeCancels[widget.name] = cancel;
        }

        // --- Track flashed widgets for batch cleanup ---
        if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
        if (!node._lmFlashedWidgets.includes(widget)) {
            node._lmFlashedWidgets.push(widget);
        }

        // --- Track fade-out scheduling per widget ---
        if (!node._lmFadeOutTimers) node._lmFadeOutTimers = {};

        if (typeof node.setDirtyCanvas === 'function') {
            node.setDirtyCanvas(true);
        }

        // --- Poll for hover dismissal ---
        let pollId = null;
        let cleanedUp = false;

        const cleanup = () => {
            if (cleanedUp) return;
            cleanedUp = true;
            if (pollId) clearInterval(pollId);
            pollId = null;

            for (const w of (node._lmFlashedWidgets || [])) {
                // Cancel any pending fade-out timer for this widget
                if (node._lmFadeOutTimers?.[w.name]) {
                    clearTimeout(node._lmFadeOutTimers[w.name]);
                    delete node._lmFadeOutTimers[w.name];
                }
                // Cancel any in-progress fade-in/out rAF for this widget
                if (node._lmFadeCancels?.[w.name]) {
                    node._lmFadeCancels[w.name]();
                    delete node._lmFadeCancels[w.name];
                }
                delete w.text_color;
                delete w.secondary_text_color;
                // Clear DOM widget inline color first (transition plays the
                // fade-out), then remove the transition property after it
                // completes. Keeping transition until then is essential.
                if (isDomWidget(w)) {
                    const el = getDomEl(w);
                    if (el) {
                        el.style.color = '';
                        // Remove the transition property after the fade completes.
                        setTimeout(() => {
                            if (el) el.style.transition = '';
                        }, 300);
                    }
                }
            }
            delete node._lmFlashedWidgets;
            delete node._lmFadeOutTimers;
            delete node._lmFadeCancels;
            delete node._lmFlashCleanup;
            if (typeof node.setDirtyCanvas === 'function') {
                node.setDirtyCanvas(true);
            }
        };

        // Schedule fade-out for canvas-drawn widgets only (DOM widgets fade
        // automatically when we clear the inline color in cleanup).
        if (!isDomWidget(widget)) {
            // Clear any previous fade-out timer for this widget
            if (node._lmFadeOutTimers[widget.name]) {
                clearTimeout(node._lmFadeOutTimers[widget.name]);
            }
            node._lmFadeOutTimers[widget.name] = setTimeout(() => {
                if (cleanedUp) return;
                const cancel = fadeWidgetTextColor(widget, FLASH_RGB, DEFAULT_RGB, FADE_OUT_MS);
                node._lmFadeCancels[widget.name] = cancel;
                delete node._lmFadeOutTimers[widget.name];
            }, FADE_OUT_START);
        }

        // Low-frequency poll (~100ms) for hover dismissal.
        const checkHover = () => {
            if (cleanedUp) return;
            const canvas = window.app?.canvas;
            if (canvas) {
                const hovered = canvas.getWidgetAtCursor?.();
                if (hovered && node._lmFlashedWidgets?.includes(hovered)) {
                    cleanup();
                    return;
                }
            }
        };
        pollId = setInterval(checkHover, 100);

        // Hard timeout fallback.
        if (node._lmFlashCleanup) clearTimeout(node._lmFlashCleanup);
        node._lmFlashCleanup = setTimeout(cleanup, FLASH_DURATION + 50);
    },
});
