/**
 * floatingViewerManager  -  singleton controller for the Majoor Floating Viewer (MFV).
 *
 * Responsibilities:
 *  - Instantiate/reuse the FloatingViewer DOM element (lazy).
 *  - Expose install/remove helpers for MFV global events; Vue now owns when
 *    those handlers are active.
 *  - On open: immediately load the currently-selected assets from the grid.
 *  - Subscribe to window "mjr:selection-changed" and update the viewer when open.
 *  - Expose `upsertWithContent(fileData)` for LiveStreamTracker.
 */

import { EVENTS } from "../../app/events.js";
import { APP_CONFIG } from "../../app/config.js";
import { getAssetsBatch } from "../../api/client.js";
import { observeHostCanvasSelection } from "../../app/hostAdapter.js";
import { getActiveGridContainer } from "../panel/panelRuntimeRefs.js";
import { getSelectedIdSet } from "../grid/GridSelectionManager.js";
import { getHotkeysState, isHotkeysSuspended } from "../panel/controllers/hotkeysState.js";
import { reportError } from "../../utils/logging.js";
import { NODE_STREAM_FEATURE_ENABLED } from "./nodeStream/nodeStreamFeatureFlag.js";
import { appendFloatingViewerNode } from "./viewerRuntimeHosts.js";

// Lazy-loaded modules  -  loaded on first use to avoid blocking startup.
/** @type {typeof import("./FloatingViewer.js").FloatingViewer | null} */
let _FloatingViewerClass: any = null;
let _floatingViewerLoadPromise: any = null;

async function _loadFloatingViewer() {
    if (_FloatingViewerClass) return _FloatingViewerClass;
    if (!_floatingViewerLoadPromise) {
        _floatingViewerLoadPromise = import("./FloatingViewer.js").then((m: any) => {
            _FloatingViewerClass = m.FloatingViewer;
            return _FloatingViewerClass;
        });
    }
    return _floatingViewerLoadPromise;
}

/** @type {((active: boolean) => void) | null} */
let _setControllerNodeStreamActive: any = null;
let _nodeStreamModPromise: any = null;

async function _loadNodeStreamController() {
    if (_setControllerNodeStreamActive) return;
    if (!_nodeStreamModPromise) {
        _nodeStreamModPromise = import("./nodeStream/NodeStreamController.js").then((m: any) => {
            _setControllerNodeStreamActive = m.setNodeStreamActive;
        });
    }
    return _nodeStreamModPromise;
}

// Inline MFV mode constants (avoids eager import of FloatingViewer.js).
const MFV_MODES = Object.freeze({
    SIMPLE: "simple",
    AB: "ab",
    SIDE: "side",
    GRID: "grid",
    GRAPH: "graph",
});

function _normalizeRequestedMode(mode: any) {
    const normalized = String(mode || "")
        .trim()
        .toLowerCase();
    if (normalized === "sidebyside") return MFV_MODES.SIDE;
    if (Object.values(MFV_MODES).includes(normalized as any)) return normalized as any;
    return "";
}

// -- Module state --------------------------------------------------------------

/** @type {FloatingViewer | null} */
let _instance: any = null;
function _getDefaultLiveActive() {
    return APP_CONFIG.MFV_LIVE_DEFAULT !== false;
}

function _getDefaultPreviewActive() {
    return APP_CONFIG.MFV_PREVIEW_DEFAULT !== false;
}

let _liveActive = _getDefaultLiveActive();
let _previewActive = _getDefaultPreviewActive();
let _nodeStreamActive = false;
let _nodeStreamSelectionState: any = null;
let _selectionListenerBound = false;
let _fetchAC: any = null; // AbortController for the latest in-flight batch fetch
let _loadSeq = 0; // Sequence counter to discard stale _loadFromIds responses

function _syncToggleDefaultsFromConfig() {
    _liveActive = _getDefaultLiveActive();
    _previewActive = _getDefaultPreviewActive();
    _instance?.setLiveActive(_liveActive);
    _instance?.setPreviewActive(_previewActive);
}

// -- Internal helpers ----------------------------------------------------------

async function _getInstance() {
    if (!_instance) {
        const FV = await _loadFloatingViewer();
        if (!_instance) {
            // re-check after await
            _instance = new FV({
                controller: {
                    close: () => floatingViewerManager.close(),
                    toggle: () => floatingViewerManager.toggle(),
                    toggleLive: () => floatingViewerManager.toggleLive(),
                    togglePreview: () => floatingViewerManager.togglePreview(),
                    toggleNodeStream: () => floatingViewerManager.toggleNodeStream(),
                    popOut: () => floatingViewerManager.popOut(),
                    onModeChanged: (mode: any) => {
                        if (!_instance?.isVisible) return;
                        if (mode === MFV_MODES.SIMPLE) return;
                        _syncCurrentGridSelection();
                    },
                    handleForwardedKeydown: (event: any) => _onGlobalKeydown(event),
                },
            });
            appendFloatingViewerNode(_instance.render());
        }
    }
    try {
        const node = _instance?.element || null;
        if (node?.isConnected === false) {
            appendFloatingViewerNode(node);
        }
    } catch (e: any) {
        console.debug?.(e);
    }
    return _instance;
}

function _cancelFetch() {
    try {
        _fetchAC?.abort();
    } catch (e: any) {
        console.debug?.(e);
    }
    _fetchAC = null;
}

function _getSelectionSourceGrid() {
    try {
        const lastGrid = (window as any).__MJR_LAST_SELECTION_GRID__;
        if (lastGrid?.isConnected) return lastGrid;
    } catch (e: any) {
        console.debug?.(e);
    }
    return getActiveGridContainer();
}

function _disposeInstance() {
    if (!_instance) return;
    try {
        _instance.dispose?.();
    } catch (e: any) {
        console.debug?.(e);
    }
    _instance = null;
}

function _emitVisibilityChanged(visible: any) {
    if (typeof window === "undefined") return;
    window.dispatchEvent(
        new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, {
            detail: { visible: Boolean(visible) },
        }),
    );
}

function _syncViewerControls(inst: any) {
    if (!inst) return;
    inst.setLiveActive(_liveActive);
    inst.setPreviewActive(_previewActive);
    inst.setNodeStreamActive?.(NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false);
}

function _applyNodeStreamSelection(inst: any) {
    if (!inst?.setNodeStreamSelection) return;
    inst.setNodeStreamSelection(_nodeStreamSelectionState || null);
}

function _streamAssetIntoCurrentViewerSlots(inst: any, fileData: any) {
    if (!inst) return;
    const mode = inst._mode;
    const inCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE || mode === MFV_MODES.GRID;
    if (!inCompare) {
        inst.loadMediaA(fileData, { autoMode: true });
        return;
    }

    const pins = inst.getPinnedSlots();
    if (mode === MFV_MODES.GRID) {
        const freeSlot = ["A", "B", "C", "D"].find((slot: any) => !pins.has(slot));
        if (!freeSlot) return;
        const slotMedia = {
            A: inst._mediaA,
            B: inst._mediaB,
            C: inst._mediaC,
            D: inst._mediaD,
        };
        (slotMedia as Record<string, any>)[freeSlot] = fileData;
        inst.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
        return;
    }

    const pinA = pins.has("A");
    const pinB = pins.has("B");
    // Both slots pinned -> no free slot, regardless of whether media has loaded yet.
    if (pinA && pinB) return;
    if (pinB) {
        inst.loadMediaPair(fileData, inst._mediaB);
        return;
    }
    inst.loadMediaPair(inst._mediaA, fileData);
}

/**
 * When only 1 asset is selected and the MFV is in a compare mode,
 * look up the adjacent card in the rendered grid DOM to use as slot B.
 * This provides a "compare with next" fallback without requiring Ctrl+click.
 * @param {string} selectedId
 * @returns {string | null}
 */
function _findAdjacentGridId(selectedId: any) {
    try {
        const grid = _getSelectionSourceGrid();
        if (!grid) return null;
        const cards = Array.from(grid.querySelectorAll("[data-mjr-asset-id]")) as HTMLElement[];
        const idx = cards.findIndex((c: any) => c.dataset.mjrAssetId === String(selectedId));
        if (idx < 0) return null;
        // Prefer the next card; fall back to the previous one
        const adjacent = cards[idx + 1] ?? cards[idx - 1] ?? null;
        const adjId = adjacent?.dataset?.mjrAssetId ?? null;
        return adjId && adjId !== String(selectedId) ? adjId : null;
    } catch (e: any) {
        console.debug?.("[MFV] _findAdjacentGridId error", e);
        return null;
    }
}

/**
 * Fetch up to 4 assets by ID and load them into the viewer.
 * In grid mode, up to 4 are loaded; in compare modes up to 2.
 * If only 1 ID is provided and the viewer is in a compare mode,
 * the adjacent grid asset is automatically used as slot B.
 * @param {string[]} selectedIds
 */
async function _loadFromIds(selectedIds: any) {
    if (!selectedIds.length || !_instance) return;
    _cancelFetch();

    const seq = ++_loadSeq;
    const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
    _fetchAC = ac;

    try {
        const pins = _instance.getPinnedSlots();
        const hasPins = pins.size > 0;
        const mode = _instance._mode;
        const isGrid = mode === MFV_MODES.GRID;
        const isCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE;
        const maxSlots = isGrid ? 4 : 2;
        let ids = selectedIds.slice(0, maxSlots);

        if (hasPins && (isCompare || isGrid)) {
            // With pinned slots, only load new assets into non-pinned slots.
            const freeCount = maxSlots - pins.size;
            ids = ids.slice(0, Math.max(1, freeCount));
        } else if (ids.length === 1 && isCompare) {
            // Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
            const adjId = _findAdjacentGridId(ids[0]);
            if (adjId) ids = [ids[0], adjId];
        }

        const result = await getAssetsBatch(ids, ac ? { signal: ac.signal } : {});
        if (ac?.signal.aborted) return;
        if (_loadSeq !== seq) return; // stale  -  a newer _loadFromIds call was made
        if (!result?.ok || !Array.isArray(result.data) || !result.data.length) return;
        if (!_instance) return; // disposed while fetching

        const assets = result.data;

        // Grid mode: load up to 4 assets
        if (isGrid) {
            if (hasPins) {
                // Keep all pinned slots, fill others from new assets
                const slotMedia = {
                    A: _instance._mediaA,
                    B: _instance._mediaB,
                    C: _instance._mediaC,
                    D: _instance._mediaD,
                };
                const freeSlots = ["A", "B", "C", "D"].filter((s: any) => !pins.has(s));
                let ai = 0;
                for (const slot of freeSlots) {
                    if (ai < assets.length) (slotMedia as Record<string, any>)[slot] = assets[ai++];
                }
                _instance.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
            } else if (assets.length >= 3) {
                _instance.loadMediaQuad(assets[0], assets[1], assets[2], assets[3] || null);
            } else if (assets.length >= 2) {
                _instance.loadMediaPair(assets[0], assets[1]);
            } else {
                _instance.loadMediaA(assets[0], { autoMode: true });
            }
            return;
        }

        // AB / Side / Simple modes
        if (pins.has("A") && pins.has("B") && _instance._mediaA && _instance._mediaB) {
            // Both pinned  -  nothing to replace
            return;
        } else if (pins.has("A") && _instance._mediaA) {
            _instance.loadMediaPair(_instance._mediaA, assets[0]);
        } else if (pins.has("B") && _instance._mediaB) {
            _instance.loadMediaPair(assets[0], _instance._mediaB);
        } else if (ids.length >= 2 && assets.length >= 2) {
            _instance.loadMediaPair(assets[0], assets[1]);
        } else {
            _instance.loadMediaA(assets[0], { autoMode: true });
        }
    } catch (e: any) {
        if (e?.name !== "AbortError") {
            reportError(e, "floatingViewerManager._loadFromIds");
        }
    } finally {
        if (_fetchAC === ac) _fetchAC = null;
    }
}

/**
 * Read the current grid selection and immediately populate the viewer.
 * Called when the MFV opens so the user sees content right away.
 */
function _syncCurrentGridSelection() {
    try {
        const grid = _getSelectionSourceGrid();
        if (!grid) return;
        const selected = getSelectedIdSet(grid);
        if (!selected.size) return;
        void _loadFromIds(Array.from(selected));
    } catch (e: any) {
        console.debug?.("[MFV] Error reading current grid selection", e);
    }
}

// -- Selection listener (active while MFV is visible) -------------------------

function _onSelectionChanged(e: any) {
    if (!_instance?.isVisible) return;
    if (_instance._mode === MFV_MODES.GRAPH) return;
    // Filter out folder cards  -  they have no previewable media.
    const selectedAssets = Array.isArray(e?.detail?.selectedAssets) ? e.detail.selectedAssets : [];
    const folderIds = new Set(
        selectedAssets
            .filter((a: any) => String(a?.kind || "").toLowerCase() === "folder")
            .map((a: any) => String(a?.id || ""))
            .filter(Boolean),
    );
    const selectedIds = Array.isArray(e?.detail?.selectedIds)
        ? e.detail.selectedIds.map(String).filter((id: any) => Boolean(id) && !folderIds.has(id))
        : [];
    if (selectedIds.length) {
        void _loadFromIds(selectedIds);
        return;
    }
    // Fallback: if payload is missing/empty, read latest selection directly from grid dataset.
    try {
        const grid = _getSelectionSourceGrid();
        if (!grid) return;
        const ids = Array.from(getSelectedIdSet(grid)).map(String).filter(Boolean);
        if (!ids.length) return;
        void _loadFromIds(ids);
    } catch (err: any) {
        console.debug?.("[MFV] selection fallback failed", err);
    }
}

function _bindSelectionListener() {
    if (_selectionListenerBound) return;
    if (typeof window === "undefined") return;
    window.addEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
    _selectionListenerBound = true;
}

function _unbindSelectionListener() {
    if (typeof window !== "undefined") {
        window.removeEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
    }
    _selectionListenerBound = false;
    _cancelFetch();
}

// -- Node selection hooks (canvas -> sidebar sync) -----------------------------

let _nodeSelectionBound = false;
let _disposeNodeSelectionBinding: any = null;
let _sidebarRefreshTimer: any = null;
let _sidebarRefreshTimerKind = "";

function _onCanvasNodeSelection() {
    // Refresh the sidebar when canvas node selection changes
    if (_instance?.isVisible) _instance.refreshSidebar?.();
}

// Debounced version for DOM-level fallback  -  avoids double-refresh when
// LiteGraph callbacks already fired for the same interaction.
function _scheduleCanvasNodeSelection() {
    _clearScheduledCanvasNodeSelection();
    const host = typeof window !== "undefined" ? window : globalThis;
    if (typeof host.requestAnimationFrame === "function") {
        _sidebarRefreshTimerKind = "raf";
        _sidebarRefreshTimer = host.requestAnimationFrame(() => {
            _sidebarRefreshTimer = null;
            _sidebarRefreshTimerKind = "";
            _onCanvasNodeSelection();
        });
        return;
    }
    _sidebarRefreshTimerKind = "timeout";
    _sidebarRefreshTimer = host.setTimeout(() => {
        _sidebarRefreshTimer = null;
        _sidebarRefreshTimerKind = "";
        _onCanvasNodeSelection();
    }, 16);
}

function _clearScheduledCanvasNodeSelection() {
    if (_sidebarRefreshTimer == null) return;
    const host = typeof window !== "undefined" ? window : globalThis;
    try {
        if (_sidebarRefreshTimerKind === "raf" && typeof host.cancelAnimationFrame === "function") {
            host.cancelAnimationFrame(_sidebarRefreshTimer);
        } else if (typeof host.clearTimeout === "function") {
            host.clearTimeout(_sidebarRefreshTimer);
        }
    } catch (e: any) {
        console.debug?.(e);
    }
    _sidebarRefreshTimer = null;
    _sidebarRefreshTimerKind = "";
}

function _bindNodeSelectionListener() {
    if (_nodeSelectionBound) return;
    try {
        _disposeNodeSelectionBinding = observeHostCanvasSelection(_scheduleCanvasNodeSelection, {
            includePointerFallback: true,
        });
        _nodeSelectionBound = typeof _disposeNodeSelectionBinding === "function";
    } catch (e: any) {
        console.debug?.("[MFV] _bindNodeSelectionListener error", e);
    }
}

function _unbindNodeSelectionListener() {
    if (!_nodeSelectionBound) return;
    _clearScheduledCanvasNodeSelection();
    try {
        _disposeNodeSelectionBinding?.();
    } catch (e: any) {
        console.debug?.("[MFV] _unbindNodeSelectionListener error", e);
    }
    _disposeNodeSelectionBinding = null;
    _nodeSelectionBound = false;
}

// -- Public API ----------------------------------------------------------------

export const floatingViewerManager = {
    isGraphModeVisible() {
        return Boolean(_instance?.isVisible && _instance?._mode === MFV_MODES.GRAPH);
    },

    /**
     * Open the MFV with explicit assets instead of syncing from the current grid selection.
     * Useful for context-menu actions where the right-clicked asset is the target.
     */
    async openAssets({ assets = [] as any[], asset = null as any, index = 0, mode = "" } = {}) {
        const list = Array.isArray(assets) ? assets.filter(Boolean) : asset ? [asset] : [];
        if (!list.length) return false;

        const inst = await _getInstance();
        const wasVisible = Boolean(inst.isVisible);
        const safeIndex = Math.max(0, Math.min(Number(index) || 0, list.length - 1));
        const requestedMode = _normalizeRequestedMode(mode);

        if (requestedMode) inst.setMode(requestedMode);
        inst.show();
        _syncViewerControls(inst);
        _bindSelectionListener();
        _bindNodeSelectionListener();

        const currentMode = inst._mode;
        if (currentMode === MFV_MODES.GRID && list.length >= 3) {
            inst.loadMediaQuad(list[0], list[1], list[2], list[3] || null);
        } else if (
            (currentMode === MFV_MODES.AB || currentMode === MFV_MODES.SIDE) &&
            list.length >= 2
        ) {
            inst.loadMediaPair(list[0], list[1]);
        } else {
            inst.loadMediaA(list[safeIndex], { autoMode: false });
        }

        if (!wasVisible) _emitVisibilityChanged(true);
        return true;
    },

    async open() {
        const inst = await _getInstance();
        inst.show();
        _syncViewerControls(inst);
        _bindSelectionListener();
        _bindNodeSelectionListener();
        // If canvas wasn't ready yet (ComfyUI still loading), retry once
        // via rAF so the node selection listener is installed as soon as possible.
        if (!_nodeSelectionBound) {
            requestAnimationFrame(() => _bindNodeSelectionListener());
        }
        // KEY FIX: immediately show whatever is selected in the grid.
        _syncCurrentGridSelection();
        // Restore node stream overlay if a node was already selected before opening.
        if (NODE_STREAM_FEATURE_ENABLED && _nodeStreamActive) {
            _applyNodeStreamSelection(inst);
        }
        _emitVisibilityChanged(true);
    },

    close() {
        if (_instance) {
            try {
                if (_instance.isPopped) _instance.popIn();
                _instance.hide();
            } catch (e: any) {
                console.debug?.(e);
            }
        }
        _unbindSelectionListener();
        _instance?.setNodeStreamSelection?.(null);
        _unbindNodeSelectionListener();
        _emitVisibilityChanged(false);
    },

    async toggle() {
        if (_instance?.isVisible) {
            floatingViewerManager.close();
        } else {
            await floatingViewerManager.open();
        }
    },

    toggleLive() {
        floatingViewerManager.setLiveActive(!_liveActive);
    },

    togglePreview() {
        floatingViewerManager.setPreviewActive(!_previewActive);
    },

    async toggleCompareAB() {
        const inst = await _getInstance();
        const wasVisible = Boolean(inst.isVisible);

        if (!wasVisible) {
            inst.setMode(MFV_MODES.AB);
            inst.show();
            _syncViewerControls(inst);
            _bindSelectionListener();
            _syncCurrentGridSelection();
            _emitVisibilityChanged(true);
            return;
        }

        // Cycle: AB -> Side -> Grid -> Simple -> AB
        const cycle = {
            [MFV_MODES.AB]: MFV_MODES.SIDE,
            [MFV_MODES.SIDE]: MFV_MODES.SIMPLE,
            [MFV_MODES.GRID]: MFV_MODES.SIMPLE,
            [MFV_MODES.SIMPLE]: MFV_MODES.AB,
        };
        const next = (cycle as Record<string, any>)[inst._mode] || MFV_MODES.AB;
        inst.setMode(next);
        // Re-sync grid selection when entering a multi-asset mode so the viewer
        // is populated with the current selection (not stale from a previous mode).
        if (next !== MFV_MODES.SIMPLE) {
            _syncCurrentGridSelection();
        }
    },

    /**
     * Open the MFV (if needed) and load a single file/asset.
     * Used by LiveStreamTracker for the live generation feed.
     * @param {object} fileData  Raw output { filename, subfolder, type } or full asset object.
     */
    async upsertWithContent(fileData: any) {
        const inst = await _getInstance();
        const wasVisible = Boolean(inst.isVisible);
        if (!wasVisible && APP_CONFIG.MFV_LIVE_AUTO_OPEN === false) return;
        inst.show();
        _syncViewerControls(inst);
        _bindSelectionListener();

        _streamAssetIntoCurrentViewerSlots(inst, fileData);

        if (!wasVisible) _emitVisibilityChanged(true);
    },

    setLiveActive(active: any) {
        _liveActive = Boolean(active);
        _instance?.setLiveActive(_liveActive);
    },

    getLiveActive() {
        return _liveActive;
    },

    /**
     * Toggle the viewer between the expanded dialog overlay and the floating panel.
     * If the viewer isn't open yet, it is opened first so there's something to see.
     */
    async popOut() {
        const inst = await _getInstance();
        if (inst.isPopped) {
            inst.popIn();
        } else {
            // Ensure the viewer is visible and loaded before popping out
            if (!inst.isVisible) {
                await floatingViewerManager.open();
            }
            inst.popOut();
        }
    },

    // -- Preview stream (KSampler denoising steps) -------------------------

    setPreviewActive(active: any) {
        _previewActive = Boolean(active);
        _instance?.setPreviewActive(_previewActive);
    },

    getPreviewActive() {
        return _previewActive;
    },

    /**
     * Feed a preview blob from the KSampler WebSocket into the viewer.
     * If preview mode is off or the viewer is not visible, the blob is ignored.
     * @param {Blob} blob  JPEG/PNG Blob from the ComfyUI `b_preview` event.
     */
    async feedPreviewBlob(blob: any, opts = {}) {
        if (!_previewActive) return;
        const inst = await _getInstance();
        const wasVisible = Boolean(inst.isVisible);
        if (!wasVisible && APP_CONFIG.MFV_PREVIEW_AUTO_OPEN === false) return;
        if (!inst.isVisible) {
            inst.show();
        }
        _syncViewerControls(inst);
        inst.loadPreviewBlob(blob, ...(Object.keys(opts).length ? [opts] : []));
        if (!wasVisible) _emitVisibilityChanged(true);
    },

    // -- Node Stream (intermediate node outputs) ---------------------------

    toggleNodeStream() {
        if (!NODE_STREAM_FEATURE_ENABLED) return;
        floatingViewerManager.setNodeStreamActive(!_nodeStreamActive);
    },

    setNodeStreamActive(active: any) {
        if (!NODE_STREAM_FEATURE_ENABLED) {
            void active;
            _nodeStreamActive = false;
            _nodeStreamSelectionState = null;
            if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
            _instance?.setNodeStreamActive?.(false);
            return;
        }

        _nodeStreamActive = Boolean(active);
        if (!_nodeStreamActive) {
            _nodeStreamSelectionState = null;
        }
        // Lazy-load NodeStreamController then apply state.
        void _loadNodeStreamController().then(() => {
            if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(_nodeStreamActive);
        });
        _instance?.setNodeStreamActive?.(_nodeStreamActive);
        if (!_nodeStreamActive) {
            _instance?.setNodeStreamSelection?.(null);
        } else if (_instance) {
            _applyNodeStreamSelection(_instance);
        }
    },

    getNodeStreamActive() {
        return NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false;
    },

    /**
     * Forward the canvas-selected node info to the viewer overlay.
     * Called by NodeStreamController.onStatus when the selection changes.
     * Only takes effect when the feature flag is on.
     * @param {string} nodeId
     * @param {string} classType
     * @param {string} [title]
     */
    setNodeStreamSelection(nodeId: any, classType: any, title: any) {
        if (!NODE_STREAM_FEATURE_ENABLED) return;
        _nodeStreamSelectionState =
            nodeId == null || nodeId === "" ? null : { nodeId, classType, title };
        const inst = _instance;
        if (!inst) return;
        _applyNodeStreamSelection(inst);
    },

    /**
     * Feed an intermediate node output into the viewer.
     * Called by the NodeStreamController when a watched node produces output.
     * @param {object} fileData  { filename, subfolder, type, kind?, _nodeId?, _classType? }
     */
    async feedNodeStream(fileData: any) {
        if (!NODE_STREAM_FEATURE_ENABLED) {
            void fileData;
            return;
        }
        if (!_nodeStreamActive) return;
        const inst = await _getInstance();
        const wasVisible = Boolean(inst.isVisible);
        if (!wasVisible && APP_CONFIG.MFV_NODE_STREAM_AUTO_OPEN === false) return;
        if (!inst.isVisible) {
            inst.show();
            _bindSelectionListener();
        }
        _syncViewerControls(inst);
        _applyNodeStreamSelection(inst);

        _streamAssetIntoCurrentViewerSlots(inst, fileData);

        if (!wasVisible) _emitVisibilityChanged(true);
    },
};

// -- Global event wiring (NM-3: named references so teardown can remove them) --
// Using named handler functions prevents duplicate listeners from accumulating
// on hot-reload. entry.js calls teardownFloatingViewerManager() in its cleanup
// path before re-registering, mirroring the pattern used for API handlers.

let _globalHandlersInstalled = false;

const _onMfvOpen = () => floatingViewerManager.open();
const _onMfvClose = () => floatingViewerManager.close();
const _onMfvToggle = () => floatingViewerManager.toggle();
const _onMfvLiveToggle = () => floatingViewerManager.toggleLive();
const _onMfvPreviewToggle = () => floatingViewerManager.togglePreview();
const _onMfvNodeStreamToggle = () => floatingViewerManager.toggleNodeStream();
const _onMfvPopout = () => floatingViewerManager.popOut();
const _onBeforeUnload = () => {
    try {
        if (_instance?.isPopped) _instance.popIn();
    } catch {
        /* noop */
    }
};
const _onSettingsChanged = (event: any) => {
    const key = String(event?.detail?.key || "");
    if (
        !key ||
        key === "viewer" ||
        key === "viewer.mfvLiveDefault" ||
        key === "viewer.mfvPreviewDefault"
    ) {
        _syncToggleDefaultsFromConfig();
    }
};

function _isMfvPlayerShortcut(event: any) {
    const key = String(event?.key || "");
    return key === " " || key === "Spacebar" || key === "ArrowLeft" || key === "ArrowRight";
}

function _handleMfvPlayerShortcut(event: any) {
    if (!_instance?.isVisible || !_isMfvPlayerShortcut(event)) return false;
    try {
        const target = event?.target;
        const instanceEl = _instance?.element || null;
        const targetInsideViewer = Boolean(instanceEl?.contains?.(target));

        // 1) Simple player (GIF / image / animated-webp) keydown forwarding.
        const targetPlayer = target?.closest?.(".mjr-mfv-simple-player") || null;
        const player =
            targetPlayer ||
            (targetInsideViewer || target == null
                ? instanceEl?.querySelector?.(".mjr-mfv-simple-player")
                : null);
        const handler = player?._mjrSimplePlayerHandleKeydown;
        if (typeof handler === "function") {
            handler(event);
            return Boolean(event?.defaultPrevented);
        }

        // 2) Full video/audio controls host (.mjr-mfv-player-host).
        const targetHost = target?.closest?.(".mjr-mfv-player-host") || null;
        const host =
            targetHost ||
            (targetInsideViewer || target == null
                ? instanceEl?.querySelector?.(".mjr-mfv-player-host")
                : null);
        const controls = host?._mjrMediaControlsHandle;
        if (controls && typeof controls === "object") {
            const key = String(event?.key || "");
            const consume = () => {
                event.preventDefault?.();
                event.stopPropagation?.();
                event.stopImmediatePropagation?.();
            };
            if ((key === " " || key === "Spacebar") && typeof controls.togglePlay === "function") {
                controls.togglePlay();
                consume();
                return true;
            }
            if (key === "ArrowLeft" && typeof controls.stepFrames === "function") {
                controls.stepFrames(-1);
                consume();
                return true;
            }
            if (key === "ArrowRight" && typeof controls.stepFrames === "function") {
                controls.stepFrames(1);
                consume();
                return true;
            }
        }
        return false;
    } catch (e: any) {
        console.debug?.(e);
        return false;
    }
}

const _onGlobalKeydown = (event: any) => {
    if (!_instance?.isVisible) return;
    if (isHotkeysSuspended()) return;
    if (getHotkeysState().scope === "viewer") return;

    const lower = event?.key?.toLowerCase?.() || "";
    const isTypingTarget =
        event?.target?.isContentEditable ||
        event?.target?.closest?.("input, textarea, select, [contenteditable='true']");
    if (isTypingTarget && _handleMfvPlayerShortcut(event)) return;
    if (isTypingTarget) return;

    if (_handleMfvPlayerShortcut(event)) return;

    const consume = () => {
        event.preventDefault?.();
        event.stopPropagation?.();
        event.stopImmediatePropagation?.();
    };

    if (!event?.ctrlKey && !event?.metaKey && !event?.altKey && !event?.shiftKey) {
        if (lower === "v") {
            consume();
            floatingViewerManager.toggle();
            return;
        }
        if (lower === "k") {
            consume();
            floatingViewerManager.togglePreview();
            return;
        }
        if (lower === "l") {
            consume();
            floatingViewerManager.toggleLive();
            return;
        }
        if (NODE_STREAM_FEATURE_ENABLED && lower === "n") {
            consume();
            floatingViewerManager.toggleNodeStream();
            return;
        }
        if (lower === "c") {
            consume();
            floatingViewerManager.toggleCompareAB();
        }
        return;
    }
};

export function installFloatingViewerGlobalHandlers(): void {
    if (_globalHandlersInstalled) return;
    if (typeof window === "undefined" || !window?.addEventListener) return;
    window.addEventListener(EVENTS.MFV_OPEN, _onMfvOpen);
    window.addEventListener(EVENTS.MFV_CLOSE, _onMfvClose);
    window.addEventListener(EVENTS.MFV_TOGGLE, _onMfvToggle);
    window.addEventListener(EVENTS.MFV_LIVE_TOGGLE, _onMfvLiveToggle);
    window.addEventListener(EVENTS.MFV_PREVIEW_TOGGLE, _onMfvPreviewToggle);
    if (NODE_STREAM_FEATURE_ENABLED) {
        window.addEventListener(EVENTS.MFV_NODESTREAM_TOGGLE, _onMfvNodeStreamToggle);
    }
    window.addEventListener(EVENTS.MFV_POPOUT, _onMfvPopout);
    window.addEventListener(EVENTS.SETTINGS_CHANGED, _onSettingsChanged);
    window.addEventListener("keydown", _onGlobalKeydown, true);
    window.addEventListener("beforeunload", _onBeforeUnload);
    _globalHandlersInstalled = true;
}

export function removeFloatingViewerGlobalHandlers(): void {
    if (typeof window === "undefined" || !window?.removeEventListener) {
        _globalHandlersInstalled = false;
        return;
    }
    window.removeEventListener(EVENTS.MFV_OPEN, _onMfvOpen);
    window.removeEventListener(EVENTS.MFV_CLOSE, _onMfvClose);
    window.removeEventListener(EVENTS.MFV_TOGGLE, _onMfvToggle);
    window.removeEventListener(EVENTS.MFV_LIVE_TOGGLE, _onMfvLiveToggle);
    window.removeEventListener(EVENTS.MFV_PREVIEW_TOGGLE, _onMfvPreviewToggle);
    if (NODE_STREAM_FEATURE_ENABLED) {
        window.removeEventListener(EVENTS.MFV_NODESTREAM_TOGGLE, _onMfvNodeStreamToggle);
    }
    window.removeEventListener(EVENTS.MFV_POPOUT, _onMfvPopout);
    window.removeEventListener(EVENTS.SETTINGS_CHANGED, _onSettingsChanged);
    window.removeEventListener("keydown", _onGlobalKeydown, true);
    window.removeEventListener("beforeunload", _onBeforeUnload);
    _globalHandlersInstalled = false;
}

/**
 * Fully tear down the singleton and its global listeners.
 * Called from entry.js during hot-reload cleanup so the next module instance
 * starts from a clean slate.
 */
export function teardownFloatingViewerManager({ reinstallGlobalHandlers = false }: { reinstallGlobalHandlers?: boolean } = {}): void {
    const wasVisible = Boolean(_instance?.isVisible);
    // If the viewer is popped out to a separate window, bring it back first
    try {
        if (_instance?.isPopped) _instance.popIn();
    } catch (e: any) {
        console.debug?.(e);
    }
    removeFloatingViewerGlobalHandlers();
    _unbindSelectionListener();
    _unbindNodeSelectionListener();
    _cancelFetch();
    _loadSeq += 1;
    _liveActive = _getDefaultLiveActive();
    _previewActive = _getDefaultPreviewActive();
    _nodeStreamActive = false;
    try {
        if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
    } catch (e: any) {
        console.debug?.(e);
    }
    _disposeInstance();
    if (wasVisible) _emitVisibilityChanged(false);
    if (reinstallGlobalHandlers) {
        installFloatingViewerGlobalHandlers();
    }
}
