import type {
  LGraphCanvas as TLGraphCanvas,
  LGraphNode,
  SerializedLGraphNode,
  serializedLGraph,
  ContextMenuItem,
  LGraph as TLGraph,
  AdjustedMouseEvent,
  IContextMenuOptions,
} from "typings/litegraph.js";
import type {ComfyApiFormat, ComfyApiPrompt, ComfyApp} from "typings/comfy.js";
import {app} from "scripts/app.js";
import {api} from "scripts/api.js";
import {SERVICE as CONFIG_SERVICE} from "./services/config_service.js";
import {SERVICE as KEY_EVENT_SERVICE} from "./services/key_events_services.js";
import {fixBadLinks} from "rgthree/common/link_fixer.js";
import {injectCss, wait} from "rgthree/common/shared_utils.js";
import {replaceNode, waitForCanvas, waitForGraph} from "./utils.js";
import {NodeTypesString, addRgthree, getNodeTypeStrings, stripRgthree} from "./constants.js";
import {RgthreeProgressBar} from "rgthree/common/progress_bar.js";
import {RgthreeConfigDialog} from "./config.js";
import {
  iconGear,
  iconNode,
  iconReplace,
  iconStarFilled,
  logoRgthree,
} from "rgthree/common/media/svgs.js";
import type {Bookmark} from "./bookmark.js";
import {createElement, query, queryOne} from "rgthree/common/utils_dom.js";

export enum LogLevel {
  IMPORTANT = 1,
  ERROR,
  WARN,
  INFO,
  DEBUG,
  DEV,
}

const LogLevelKeyToLogLevel: {[key: string]: LogLevel} = {
  IMPORTANT: LogLevel.IMPORTANT,
  ERROR: LogLevel.ERROR,
  WARN: LogLevel.WARN,
  INFO: LogLevel.INFO,
  DEBUG: LogLevel.DEBUG,
  DEV: LogLevel.DEV,
};

type ConsoleLogFns = "log" | "error" | "warn" | "debug" | "info";
const LogLevelToMethod: {[key in LogLevel]: ConsoleLogFns} = {
  [LogLevel.IMPORTANT]: "log",
  [LogLevel.ERROR]: "error",
  [LogLevel.WARN]: "warn",
  [LogLevel.INFO]: "info",
  [LogLevel.DEBUG]: "log",
  [LogLevel.DEV]: "log",
};
const LogLevelToCSS: {[key in LogLevel]: string} = {
  [LogLevel.IMPORTANT]: "font-weight: bold; color: blue;",
  [LogLevel.ERROR]: "",
  [LogLevel.WARN]: "",
  [LogLevel.INFO]: "font-style: italic; color: blue;",
  [LogLevel.DEBUG]: "font-style: italic; color: #444;",
  [LogLevel.DEV]: "color: #004b68;",
};

let GLOBAL_LOG_LEVEL = LogLevel.ERROR;

/**
 * At some point in Summer of 2024 ComfyUI broke third-party api calls by assuming api paths follow
 * a certain structure. However, rgthree-comfy wants an `/rgthree/` prefix for that same reason, so
 * we overwrite the apiUrl method to fix.
 */
const apiURL = api.apiURL;
api.apiURL = function (route: string): string {
  if (route.includes("rgthree/")) {
    return (this.api_base + "/" + route).replace(/\/\//g, "/");
  }
  return apiURL.apply(this, arguments as any);
};

/**
 * A blocklist of extensions to disallow hooking into rgthree's base classes when calling the
 * `rgthree.invokeExtensionsAsync` method (which runs outside of ComfyNode's
 * `app.invokeExtensionsAsync` which is private).
 *
 * In Apr 2024 the base rgthree node class added support for other extensions using `nodeCreated`
 * and `beforeRegisterNodeDef` which allows other extensions to modify the class. However, since it
 * had been months since divorcing the ComfyNode in rgthree-comfy due to instability and
 * inflexibility, this was a bit risky as other extensions hadn't ever run with this ability. This
 * list attempts to block extensions from being able to call into rgthree-comfy nodes via the
 * `nodeCreated` and `beforeRegisterNodeDef` callbacks now that rgthree-comfy is utilizing them
 * because they do not work. Oddly, it's ComfyUI's own extension that is broken.
 */
const INVOKE_EXTENSIONS_BLOCKLIST = [
  {
    name: "Comfy.WidgetInputs",
    reason:
      "Major conflict with rgthree-comfy nodes' inputs causing instability and " +
      "repeated link disconnections.",
  },
  {
    name: "efficiency.widgethider",
    reason:
      "Overrides value getter before widget getter is prepared. Can be lifted if/when " +
      "https://github.com/jags111/efficiency-nodes-comfyui/pull/203 is pulled.",
  },
];

/** A basic wrapper around logger. */
class Logger {
  /** Logs a message to the console if it meets the current log level. */
  log(level: LogLevel, message: string, ...args: any[]) {
    const [n, v] = this.logParts(level, message, ...args);
    console[n]?.(...v);
  }

  /**
   * Returns a tuple of the console function and its arguments. Useful for callers to make the
   * actual console.<fn> call to gain benefits of DevTools knowing the source line.
   *
   * If the input is invalid or the level doesn't meet the configuration level, then the return
   * value is an unknown function and empty set of values. Callers can use optionla chaining
   * successfully:
   *
   *     const [fn, values] = logger.logPars(LogLevel.INFO, 'my message');
   *     console[fn]?.(...values); // Will work even if INFO won't be logged.
   *
   */
  logParts(level: LogLevel, message: string, ...args: any[]): [ConsoleLogFns, any[]] {
    if (level <= GLOBAL_LOG_LEVEL) {
      const css = LogLevelToCSS[level] || "";
      if (level === LogLevel.DEV) {
        message = `🔧 ${message}`;
      }
      return [LogLevelToMethod[level], [`%c${message}`, css, ...args]];
    }
    return ["none" as "info", []];
  }
}

/**
 * A log session, with the name as the prefix. A new session will stack prefixes.
 */
class LogSession {
  readonly logger = new Logger();
  readonly logsCache: {[key: string]: {lastShownTime: number}} = {};

  constructor(readonly name?: string) {}

  /**
   * Returns the console log method to use and the arguments to pass so the call site can log from
   * there. This extra work at the call site allows for easier debugging in the dev console.
   *
   *     const [logMethod, logArgs] = logger.logParts(LogLevel.DEBUG, message, ...args);
   *     console[logMethod]?.(...logArgs);
   */
  logParts(level: LogLevel, message?: string, ...args: any[]): [ConsoleLogFns, any[]] {
    message = `${this.name || ""}${message ? " " + message : ""}`;
    return this.logger.logParts(level, message, ...args);
  }

  logPartsOnceForTime(
    level: LogLevel,
    time: number,
    message?: string,
    ...args: any[]
  ): [ConsoleLogFns, any[]] {
    message = `${this.name || ""}${message ? " " + message : ""}`;
    const cacheKey = `${level}:${message}`;
    const cacheEntry = this.logsCache[cacheKey];
    const now = +new Date();
    if (cacheEntry && cacheEntry.lastShownTime + time > now) {
      return ["none" as "info", []];
    }
    const parts = this.logger.logParts(level, message, ...args);
    if (console[parts[0]]) {
      this.logsCache[cacheKey] = this.logsCache[cacheKey] || ({} as {lastShownTime: number});
      this.logsCache[cacheKey]!.lastShownTime = now;
    }
    return parts;
  }

  debugParts(message?: string, ...args: any[]) {
    return this.logParts(LogLevel.DEBUG, message, ...args);
  }

  infoParts(message?: string, ...args: any[]) {
    return this.logParts(LogLevel.INFO, message, ...args);
  }

  warnParts(message?: string, ...args: any[]) {
    return this.logParts(LogLevel.WARN, message, ...args);
  }

  newSession(name?: string) {
    return new LogSession(`${this.name}${name}`);
  }
}

export type RgthreeUiMessage = {
  id: string;
  message: string;
  type?: "warn" | "info" | "success" | null;
  timeout?: number;
  // closeable?: boolean; // TODO
  actions?: Array<{
    label: string;
    href?: string;
    callback?: (event: MouseEvent) => void;
  }>;
};

/**
 * A global class as 'rgthree'; exposed on wiindow. Lots can go in here.
 */
class Rgthree extends EventTarget {
  /** Exposes the ComfyUI api instance on rgthree. */
  readonly api = api;
  private settingsDialog: RgthreeConfigDialog | null = null;
  private progressBarEl: RgthreeProgressBar | null = null;
  private rgthreeCssPromise: Promise<void>;

  /** Stores a node id that we will use to queu only that output node (with `queueOutputNode`). */
  private queueNodeIds: number[] | null = null;

  logger = new LogSession("[rgthree]");

  monitorBadLinksAlerted = false;
  monitorLinkTimeout: number | null = null;

  processingQueue = false;
  loadingApiJson = false;
  replacingReroute: number | null = null;
  processingMouseDown = false;
  processingMouseUp = false;
  processingMouseMove = false;
  lastAdjustedMouseEvent: AdjustedMouseEvent | null = null;

  // Comfy/LiteGraph states so nodes and tell what the hell is going on.
  canvasCurrentlyCopyingToClipboard = false;
  canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
  initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff: any = null;

  private readonly isMac: boolean = !!(
    navigator.platform?.toLocaleUpperCase().startsWith("MAC") ||
    (navigator as any).userAgentData?.platform?.toLocaleUpperCase().startsWith("MAC")
  );

  constructor() {
    super();

    const logLevel =
      LogLevelKeyToLogLevel[CONFIG_SERVICE.getConfigValue("log_level")] ?? GLOBAL_LOG_LEVEL;
    this.setLogLevel(logLevel);

    this.initializeGraphAndCanvasHooks();
    this.initializeComfyUIHooks();
    this.initializeContextMenu();

    this.rgthreeCssPromise = injectCss("extensions/rgthree-comfy/rgthree.css");

    this.initializeProgressBar();

    CONFIG_SERVICE.addEventListener("config-change", ((e: CustomEvent) => {
      if (e.detail?.key?.includes("features.progress_bar")) {
        this.initializeProgressBar();
      }
    }) as EventListener);

    if (CONFIG_SERVICE.getConfigValue("debug.keys_down.enabled")) {
      const elDebugKeydowns = createElement<HTMLDivElement>("div.rgthree-debug-keydowns", {
        parent: document.body,
      });
      const updateDebugKeyDown = () => {
        elDebugKeydowns.innerText = Object.keys(KEY_EVENT_SERVICE.downKeys).join(" ");
      }
      KEY_EVENT_SERVICE.addEventListener("keydown", updateDebugKeyDown);
      KEY_EVENT_SERVICE.addEventListener("keyup", updateDebugKeyDown);
    }
  }

  /**
   * Initializes the top progress bar, if it's configured.
   */
  async initializeProgressBar() {
    if (CONFIG_SERVICE.getConfigValue("features.progress_bar.enabled")) {
      await this.rgthreeCssPromise;
      if (!this.progressBarEl) {
        this.progressBarEl = RgthreeProgressBar.create();
        this.progressBarEl.setAttribute(
          "title",
          "Progress Bar by rgthree. right-click for rgthree menu.",
        );

        this.progressBarEl.addEventListener("contextmenu", async (e) => {
          e.stopPropagation();
          e.preventDefault();
        });

        this.progressBarEl.addEventListener("pointerdown", async (e) => {
          LiteGraph.closeAllContextMenus();
          if (e.button == 2) {
            const canvas = await waitForCanvas();
            new LiteGraph.ContextMenu(
              this.getRgthreeContextMenuItems(),
              {
                title: `<div class="rgthree-contextmenu-item rgthree-contextmenu-title-rgthree-comfy">${logoRgthree} rgthree-comfy</div>`,
                left: e.clientX,
                top: 5,
              },
              canvas.getCanvasWindow(),
            );
            return;
          }
          if (e.button == 0) {
            const nodeId = this.progressBarEl?.currentNodeId;
            if (nodeId) {
              const [canvas, graph] = await Promise.all([waitForCanvas(), waitForGraph()]);
              const node = graph.getNodeById(Number(nodeId));
              if (node) {
                canvas.centerOnNode(node);
                e.stopPropagation();
                e.preventDefault();
              }
            }
            return;
          }
        });
      }
      // Handle both cases in case someone hasn't updated. Can probably just assume
      // `isUpdatedComfyBodyClasses` is true in the near future.
      const isUpdatedComfyBodyClasses = !!queryOne(".comfyui-body-top");
      const position = CONFIG_SERVICE.getConfigValue("features.progress_bar.position");
      this.progressBarEl.classList.toggle("rgthree-pos-bottom", position === "bottom");
      // If ComfyUI is updated with the body segments, then use that.
      if (isUpdatedComfyBodyClasses) {
        if (position === "bottom") {
          queryOne(".comfyui-body-bottom")!.appendChild(this.progressBarEl);
        } else {
          queryOne(".comfyui-body-top")!.appendChild(this.progressBarEl);
        }
      } else {
        document.body.appendChild(this.progressBarEl);
      }
      const height = CONFIG_SERVICE.getConfigValue("features.progress_bar.height") || 14;
      this.progressBarEl.style.height = `${height}px`;
      const fontSize = Math.max(10, Number(height) - 10);
      this.progressBarEl.style.fontSize = `${fontSize}px`;
      this.progressBarEl.style.fontWeight = fontSize <= 12 ? "bold" : "normal";
    } else {
      this.progressBarEl?.remove();
    }
  }

  /**
   * Initialize a bunch of hooks into LiteGraph itself so we can either keep state or context on
   * what's happening so nodes can respond appropriately. This is usually to fix broken assumptions
   * in the unowned code [🤮], but sometimes to add features or enhancements too [⭐].
   */
  private async initializeGraphAndCanvasHooks() {
    const rgthree = this;

    // [🤮] To mitigate changes from https://github.com/rgthree/rgthree-comfy/issues/69
    // and https://github.com/comfyanonymous/ComfyUI/issues/2193 we can try to store the workflow
    // node so our nodes can find the seralized node. Works with method
    // `getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff` to find a node
    // while serializing. What a way to work around...
    const graphSerialize = LGraph.prototype.serialize;
    LGraph.prototype.serialize = function () {
      const response = graphSerialize.apply(this, [...arguments] as any) as any;
      rgthree.initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff = response;
      return response;
    };

    // Overrides LiteGraphs' processMouseDown to both keep state as well as dispatch a custom event.
    const processMouseDown = LGraphCanvas.prototype.processMouseDown;
    LGraphCanvas.prototype.processMouseDown = function (e: AdjustedMouseEvent) {
      rgthree.processingMouseDown = true;
      const returnVal = processMouseDown.apply(this, [...arguments] as any);
      rgthree.dispatchCustomEvent("on-process-mouse-down", {originalEvent: e});
      rgthree.processingMouseDown = false;
      return returnVal;
    };

    // Overrides LiteGraph's `adjustMouseEvent` to capture the last even coming in and out. Useful
    // to capture the last `canvasX` and `canvasY` properties, which are not the same as LiteGraph's
    // `canvas.last_mouse_position`, unfortunately.
    const adjustMouseEvent = LGraphCanvas.prototype.adjustMouseEvent;
    LGraphCanvas.prototype.adjustMouseEvent = function (e: PointerEvent) {
      adjustMouseEvent.apply(this, [...arguments] as any);
      rgthree.lastAdjustedMouseEvent = e as AdjustedMouseEvent;
    };

    // [🤮] Copying to clipboard clones nodes and then manipulats the linking data manually which
    // does not allow a node to handle connections. This harms nodes that manually handle inputs,
    // like our any-input nodes that may start with one input, and manually add new ones when one is
    // attached.
    const copyToClipboard = LGraphCanvas.prototype.copyToClipboard;
    LGraphCanvas.prototype.copyToClipboard = function (nodes: LGraphNode[]) {
      rgthree.canvasCurrentlyCopyingToClipboard = true;
      rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes =
        Object.values(nodes || this.selected_nodes || []).length > 1;
      copyToClipboard.apply(this, [...arguments] as any);
      rgthree.canvasCurrentlyCopyingToClipboard = false;
      rgthree.canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
    };

    // [⭐] Make it so when we add a group, we get to name it immediately.
    const onGroupAdd = LGraphCanvas.onGroupAdd;
    LGraphCanvas.onGroupAdd = function (...args: any[]) {
      const graph = app.graph as TLGraph;
      onGroupAdd.apply(this, [...args] as any);
      LGraphCanvas.onShowPropertyEditor(
        {},
        null,
        null,
        null,
        graph._groups[graph._groups.length - 1],
      );
    };
  }

  /**
   * [🤮] Handles the same exact thing as ComfyApp's `invokeExtensionsAsync`, but done here since
   * it is #private in ComfyApp because... of course it us. This is necessary since we purposefully
   * avoid using the ComfyNode due to historical instability and inflexibility for all the advanced
   * ui stuff rgthree-comfy nodes do, but we can still have other custom nodes know what's happening
   * with rgthree-comfy; specifically, for `nodeCreated` as of now.
   */
  async invokeExtensionsAsync(method: "nodeCreated", ...args: any[]) {
    const comfyapp = app as ComfyApp;
    if (CONFIG_SERVICE.getConfigValue("features.invoke_extensions_async.node_created") === false) {
      const [m, a] = this.logParts(
        LogLevel.INFO,
        `Skipping invokeExtensionsAsync for applicable rgthree-comfy nodes`,
      );
      console[m]?.(...a);
      return Promise.resolve();
    }
    return await Promise.all(
      comfyapp.extensions.map(async (ext) => {
        if (ext?.[method]) {
          try {
            const blocked = INVOKE_EXTENSIONS_BLOCKLIST.find((block) =>
              ext.name.toLowerCase().startsWith(block.name.toLowerCase()),
            );
            if (blocked) {
              const [n, v] = this.logger.logPartsOnceForTime(
                LogLevel.WARN,
                5000,
                `Blocked extension '${ext.name}' method '${method}' for rgthree-nodes because: ${blocked.reason}`,
              );
              console[n]?.(...v);
              return Promise.resolve();
            }
            return await (ext[method] as Function)(...args, comfyapp);
          } catch (error) {
            const [n, v] = this.logParts(
              LogLevel.ERROR,
              `Error calling extension '${ext.name}' method '${method}' for rgthree-node.`,
              {error},
              {extension: ext},
              {args},
            );
            console[n]?.(...v);
          }
        }
      }),
    );
  }

  /**
   * Wraps `dispatchEvent` for easier CustomEvent dispatching.
   */
  private dispatchCustomEvent(event: string, detail?: any) {
    if (detail != null) {
      return this.dispatchEvent(new CustomEvent(event, {detail}));
    }
    return this.dispatchEvent(new CustomEvent(event));
  }

  /**
   * Initializes hooks specific to an rgthree-comfy context menu on the root menu.
   */
  private async initializeContextMenu() {
    const that = this;
    setTimeout(async () => {
      const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
      LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
        let existingOptions = getCanvasMenuOptions.apply(this, [...args] as any);

        const options = [];
        options.push(null); // Divider
        options.push(null); // Divider
        options.push(null); // Divider
        options.push({
          content: logoRgthree + `rgthree-comfy`,
          className: "rgthree-contextmenu-item rgthree-contextmenu-main-item-rgthree-comfy",
          submenu: {
            options: that.getRgthreeContextMenuItems(),
          },
        });
        options.push(null); // Divider
        options.push(null); // Divider

        let idx = null;
        idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Queue Group")) + 1;
        idx =
          idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Queue Selected")) + 1;
        idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Convert to Group"));
        idx = idx || existingOptions.findIndex((o) => o?.content?.startsWith?.("Arrange ("));
        idx = idx || existingOptions.findIndex((o) => !o) + 1;
        idx = idx || 3;
        existingOptions.splice(idx, 0, ...options);
        for (let i = existingOptions.length; i > 0; i--) {
          if (existingOptions[i] === null && existingOptions[i + 1] === null) {
            existingOptions.splice(i, 1);
          }
        }

        return existingOptions;
      };
    }, 1016);
  }

  /**
   * Returns the standard menu items for an rgthree-comfy context menu.
   */
  private getRgthreeContextMenuItems(): ContextMenuItem[] {
    const [canvas, graph] = [app.canvas as TLGraphCanvas, app.graph as TLGraph];
    const selectedNodes = Object.values(canvas.selected_nodes || {});
    let rerouteNodes: LGraphNode[] = [];
    if (selectedNodes.length) {
      rerouteNodes = selectedNodes.filter((n) => n.type === "Reroute");
    } else {
      rerouteNodes = graph._nodes.filter((n) => n.type == "Reroute");
    }
    const rerouteLabel = selectedNodes.length ? "selected" : "all";

    const showBookmarks = CONFIG_SERVICE.getFeatureValue("menu_bookmarks.enabled");
    const bookmarkMenuItems = showBookmarks ? getBookmarks() : [];

    return [
      {
        content: "Nodes",
        disabled: true,
        className: "rgthree-contextmenu-item rgthree-contextmenu-label",
      },
      {
        content: iconNode + "All",
        className: "rgthree-contextmenu-item",
        has_submenu: true,
        submenu: {
          options: getNodeTypeStrings() as unknown as ContextMenuItem[],
          callback: (
            value: string | ContextMenuItem,
            options: IContextMenuOptions,
            event: MouseEvent,
          ) => {
            const node = LiteGraph.createNode(addRgthree(value as string));
            node.pos = [
              rgthree.lastAdjustedMouseEvent!.canvasX,
              rgthree.lastAdjustedMouseEvent!.canvasY,
            ];
            canvas.graph.add(node);
            canvas.selectNode(node);
            app.graph.setDirtyCanvas(true, true);
          },
          extra: {rgthree_doNotNest: true},
        },
      },

      {
        content: "Actions",
        disabled: true,
        className: "rgthree-contextmenu-item rgthree-contextmenu-label",
      },
      {
        content: iconGear + "Settings (rgthree-comfy)",
        disabled: !!this.settingsDialog,
        className: "rgthree-contextmenu-item",
        callback: (...args: any[]) => {
          this.settingsDialog = new RgthreeConfigDialog().show();
          this.settingsDialog.addEventListener("close", (e) => {
            this.settingsDialog = null;
          });
        },
      },
      {
        content: iconReplace + ` Convert ${rerouteLabel} Reroutes`,
        disabled: !rerouteNodes.length,
        className: "rgthree-contextmenu-item",
        callback: (...args: any[]) => {
          const msg =
            `Convert ${rerouteLabel} ComfyUI Reroutes to Reroute (rgthree) nodes? \n` +
            `(First save a copy of your workflow & check reroute connections afterwards)`;
          if (!window.confirm(msg)) {
            return;
          }
          (async () => {
            for (const node of [...rerouteNodes]) {
              if (node.type == "Reroute") {
                this.replacingReroute = node.id;
                await replaceNode(node, NodeTypesString.REROUTE);
                this.replacingReroute = null;
              }
            }
          })();
        },
      },
      ...bookmarkMenuItems,
      {
        content: "More...",
        disabled: true,
        className: "rgthree-contextmenu-item rgthree-contextmenu-label",
      },
      {
        content: iconStarFilled + "Star on Github",
        className: "rgthree-contextmenu-item rgthree-contextmenu-github",
        callback: (...args: any[]) => {
          window.open("https://github.com/rgthree/rgthree-comfy", "_blank");
        },
      },
    ];
  }

  /**
   * Wraps an `app.queuePrompt` call setting a specific node id that we will inspect and change the
   * serialized graph right before being sent (below, in our `api.queuePrompt` override).
   */
  async queueOutputNodes(nodeIds: number[]) {
    try {
      this.queueNodeIds = nodeIds;
      await app.queuePrompt();
    } catch (e) {
      const [n, v] = this.logParts(
        LogLevel.ERROR,
        `There was an error queuing nodes ${nodeIds}`,
        e,
      );
      console[n]?.(...v);
    } finally {
      this.queueNodeIds = null;
    }
  }

  /**
   * Recusively walks backwards from a node adding its inputs to the `newOutput` from `oldOutput`.
   */
  private recursiveAddNodes(nodeId: string, oldOutput: ComfyApiFormat, newOutput: ComfyApiFormat) {
    let currentId = nodeId;
    let currentNode = oldOutput[currentId]!;
    if (newOutput[currentId] == null) {
      newOutput[currentId] = currentNode;
      for (const inputValue of Object.values(currentNode.inputs || [])) {
        if (Array.isArray(inputValue)) {
          this.recursiveAddNodes(inputValue[0], oldOutput, newOutput);
        }
      }
    }
    return newOutput;
  }

  /**
   * Initialize a bunch of hooks into ComfyUI and/or LiteGraph itself so we can either keep state or
   * context on what's happening so nodes can respond appropriately. This is usually to fix broken
   * assumptions in the unowned code [🤮], but sometimes to add features or enhancements too [⭐].
   */
  private initializeComfyUIHooks() {
    const rgthree = this;

    // Keep state for when the app is queuing the prompt. For instance, this is used for seed to
    // understand if we're serializing because we're queueing (and return the random seed to use) or
    // for saving the workflow (and keep -1, etc.).
    const queuePrompt = app.queuePrompt as Function;
    app.queuePrompt = async function () {
      rgthree.processingQueue = true;
      rgthree.dispatchCustomEvent("queue");
      try {
        await queuePrompt.apply(app, [...arguments]);
      } finally {
        rgthree.processingQueue = false;
        rgthree.dispatchCustomEvent("queue-end");
      }
    };

    // Keep state for when the app is in the middle of loading from an api JSON file.
    const loadApiJson = app.loadApiJson;
    app.loadApiJson = async function () {
      rgthree.loadingApiJson = true;
      try {
        loadApiJson.apply(app, [...arguments] as any);
      } finally {
        rgthree.loadingApiJson = false;
      }
    };

    // Keep state for when the app is serizalizing the graph to prompt.
    const graphToPrompt = app.graphToPrompt;
    app.graphToPrompt = async function () {
      rgthree.dispatchCustomEvent("graph-to-prompt");
      let promise = graphToPrompt.apply(app, [...arguments] as any);
      await promise;
      rgthree.dispatchCustomEvent("graph-to-prompt-end");
      return promise;
    };

    // Override the queuePrompt for api to intercept the prompt output and, if queueNodeIds is set,
    // then we only want to queue those nodes, by rewriting the api format (prompt 'output' field)
    // so only those are evaluated.
    const apiQueuePrompt = api.queuePrompt as Function;
    api.queuePrompt = async function (index: number, prompt: ComfyApiPrompt) {
      if (rgthree.queueNodeIds?.length && prompt.output) {
        const oldOutput = prompt.output;
        let newOutput = {};
        for (const queueNodeId of rgthree.queueNodeIds) {
          rgthree.recursiveAddNodes(String(queueNodeId), oldOutput, newOutput);
        }
        prompt.output = newOutput;
      }
      rgthree.dispatchCustomEvent("comfy-api-queue-prompt-before", {
        workflow: prompt.workflow,
        output: prompt.output,
      });
      const response = apiQueuePrompt.apply(app, [index, prompt]);
      rgthree.dispatchCustomEvent("comfy-api-queue-prompt-end");
      return response;
    };

    // Hook into a clean call; allow us to clear and rgthree messages.
    const clean = app.clean;
    app.clean = function () {
      rgthree.clearAllMessages();
      clean && clean.apply(app, [...arguments] as any);
    };

    // Hook into a data load, like from an image or JSON drop-in. This is (currently) used to
    // monitor for bad linking data.
    const loadGraphData = app.loadGraphData;
    app.loadGraphData = function (graph: serializedLGraph) {
      if (rgthree.monitorLinkTimeout) {
        clearTimeout(rgthree.monitorLinkTimeout);
        rgthree.monitorLinkTimeout = null;
      }
      rgthree.clearAllMessages();
      // Try to make a copy to use, because ComfyUI's loadGraphData will modify it.
      let graphCopy: serializedLGraph | null;
      try {
        graphCopy = JSON.parse(JSON.stringify(graph));
      } catch (e) {
        graphCopy = null;
      }
      setTimeout(() => {
        const wasLoadingAborted = document
          .querySelector(".comfy-modal-content")
          ?.textContent?.includes("Loading aborted due");
        const graphToUse = wasLoadingAborted ? graphCopy || graph : app.graph;
        const fixBadLinksResult = fixBadLinks(graphToUse as unknown as TLGraph);
        if (fixBadLinksResult.hasBadLinks) {
          const [n, v] = rgthree.logParts(
            LogLevel.WARN,
            `The workflow you've loaded has corrupt linking data. Open ${
              new URL(location.href).origin
            }/rgthree/link_fixer to try to fix.`,
          );
          console[n]?.(...v);
          if (CONFIG_SERVICE.getConfigValue("features.show_alerts_for_corrupt_workflows")) {
            rgthree.showMessage({
              id: "bad-links",
              type: "warn",
              message:
                "The workflow you've loaded has corrupt linking data that may be able to be fixed.",
              actions: [
                {
                  label: "Open fixer",
                  href: "/rgthree/link_fixer",
                },
                {
                  label: "Fix in place",
                  href: "/rgthree/link_fixer",
                  callback: (event) => {
                    event.stopPropagation();
                    event.preventDefault();
                    if (
                      confirm(
                        "This will attempt to fix in place. Please make sure to have a saved copy of your workflow.",
                      )
                    ) {
                      try {
                        const fixBadLinksResult = fixBadLinks(
                          graphToUse as unknown as TLGraph,
                          true,
                        );
                        if (!fixBadLinksResult.hasBadLinks) {
                          rgthree.hideMessage("bad-links");
                          alert(
                            "Success! It's possible some valid links may have been affected. Please check and verify your workflow.",
                          );
                          wasLoadingAborted && app.loadGraphData(fixBadLinksResult.graph);
                          if (
                            CONFIG_SERVICE.getConfigValue("features.monitor_for_corrupt_links") ||
                            CONFIG_SERVICE.getConfigValue("features.monitor_bad_links")
                          ) {
                            rgthree.monitorLinkTimeout = setTimeout(() => {
                              rgthree.monitorBadLinks();
                            }, 5000);
                          }
                        }
                      } catch (e) {
                        console.error(e);
                        alert("Unsuccessful at fixing corrupt data. :(");
                        rgthree.hideMessage("bad-links");
                      }
                    }
                  },
                },
              ],
            });
          }
        } else if (
          CONFIG_SERVICE.getConfigValue("features.monitor_for_corrupt_links") ||
          CONFIG_SERVICE.getConfigValue("features.monitor_bad_links")
        ) {
          rgthree.monitorLinkTimeout = setTimeout(() => {
            rgthree.monitorBadLinks();
          }, 5000);
        }
      }, 100);
      return loadGraphData && loadGraphData.apply(app, [...arguments] as any);
    };
  }

  /**
   * [🤮] Finds a node in the currently serializing workflow from the hook setup above. This is to
   * mitigate breakages from https://github.com/comfyanonymous/ComfyUI/issues/2193 we can try to
   * store the workflow node so our nodes can find the seralized node.
   */
  getNodeFromInitialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff(
    node: LGraphNode,
  ): SerializedLGraphNode | null {
    return (
      this.initialGraphToPromptSerializedWorkflowBecauseComfyUIBrokeStuff?.nodes?.find(
        (n: SerializedLGraphNode) => n.id === node.id,
      ) ?? null
    );
  }

  /**
   * Shows a message in the UI.
   */
  async showMessage(data: RgthreeUiMessage) {
    let container = document.querySelector(".rgthree-top-messages-container");
    if (!container) {
      container = document.createElement("div");
      container.classList.add("rgthree-top-messages-container");
      document.body.appendChild(container);
    }
    // If we have a dialog open then we want to append the message to the dialog so they show over
    // the modal.
    const dialogs = query<HTMLDialogElement>("dialog[open]");
    if (dialogs.length) {
      let dialog = dialogs[dialogs.length - 1]!;
      dialog.appendChild(container);
      dialog.addEventListener("close", (e) => {
        document.body.appendChild(container!);
      });
    }
    // Hide if we exist.
    await this.hideMessage(data.id);

    const messageContainer = document.createElement("div");
    messageContainer.setAttribute("type", data.type || "info");

    const message = document.createElement("span");
    message.innerHTML = data.message;
    messageContainer.appendChild(message);

    for (let a = 0; a < (data.actions || []).length; a++) {
      const action = data.actions![a]!;
      if (a > 0) {
        const sep = document.createElement("span");
        sep.innerHTML = "&nbsp;|&nbsp;";
        messageContainer.appendChild(sep);
      }

      const actionEl = document.createElement("a");
      actionEl.innerText = action.label;
      if (action.href) {
        actionEl.target = "_blank";
        actionEl.href = action.href;
      }
      if (action.callback) {
        actionEl.onclick = (e) => {
          return action.callback!(e);
        };
      }
      messageContainer.appendChild(actionEl);
    }

    const messageAnimContainer = document.createElement("div");
    messageAnimContainer.setAttribute("msg-id", data.id);
    messageAnimContainer.appendChild(messageContainer);
    container.appendChild(messageAnimContainer);

    // Add. Wait. Measure. Wait. Anim.
    await wait(64);
    messageAnimContainer.style.marginTop = `-${messageAnimContainer.offsetHeight}px`;
    await wait(64);
    messageAnimContainer.classList.add("-show");

    if (data.timeout) {
      await wait(data.timeout);
      this.hideMessage(data.id);
    }
  }

  /**
   * Hides a message in the UI.
   */
  async hideMessage(id: string) {
    const msg = document.querySelector(`.rgthree-top-messages-container > [msg-id="${id}"]`);
    if (msg?.classList.contains("-show")) {
      msg.classList.remove("-show");
      await wait(750);
    }
    msg && msg.remove();
  }

  /**
   * Clears all messages in the UI.
   */
  async clearAllMessages() {
    let container = document.querySelector(".rgthree-top-messages-container");
    container && (container.innerHTML = "");
  }

  setLogLevel(level?: LogLevel | string) {
    if (typeof level === "string") {
      level = LogLevelKeyToLogLevel[CONFIG_SERVICE.getConfigValue("log_level")];
    }
    if (level != null) {
      GLOBAL_LOG_LEVEL = level;
    }
  }

  logParts(level: LogLevel, message?: string, ...args: any[]) {
    return this.logger.logParts(level, message, ...args);
  }

  newLogSession(name?: string) {
    return this.logger.newSession(name);
  }

  isDebugMode() {
    if (window.location.href.includes("rgthree-debug=false")) {
      return false;
    }
    return GLOBAL_LOG_LEVEL >= LogLevel.DEBUG || window.location.href.includes("rgthree-debug");
  }

  isDevMode() {
    if (window.location.href.includes("rgthree-dev=false")) {
      return false;
    }
    return GLOBAL_LOG_LEVEL >= LogLevel.DEV || window.location.href.includes("rgthree-dev");
  }

  monitorBadLinks() {
    const badLinksFound = fixBadLinks(app.graph);
    if (badLinksFound.hasBadLinks && !this.monitorBadLinksAlerted) {
      this.monitorBadLinksAlerted = true;
      alert(
        `Problematic links just found in live data. Can you save your workflow and file a bug with ` +
          `the last few steps you took to trigger this at ` +
          `https://github.com/rgthree/rgthree-comfy/issues. Thank you!`,
      );
    } else if (!badLinksFound.hasBadLinks) {
      // Clear the alert once fixed so we can alert again.
      this.monitorBadLinksAlerted = false;
    }
    this.monitorLinkTimeout = setTimeout(() => {
      this.monitorBadLinks();
    }, 5000);
  }
}

function getBookmarks(): ContextMenuItem[] {
  const graph: TLGraph = app.graph;

  // Sorts by Title.
  // I could see an option to sort by either Shortcut, Title, or Position.
  const bookmarks = graph._nodes
    .filter((n): n is Bookmark => n.type === NodeTypesString.BOOKMARK)
    .sort((a, b) => a.title.localeCompare(b.title))
    .map((n) => ({
      content: `[${n.shortcutKey}] ${n.title}`,
      className: "rgthree-contextmenu-item",
      callback: () => {
        n.canvasToBookmark();
      },
    }));

  return !bookmarks.length
    ? []
    : [
        {
          content: "🔖 Bookmarks",
          disabled: true,
          className: "rgthree-contextmenu-item rgthree-contextmenu-label",
        },
        ...bookmarks,
      ];
}

export const rgthree = new Rgthree();
// Expose it on window because, why not.
(window as any).rgthree = rgthree;
