/**
 * File: comfy_shared.js
 * Project: comfy_mtb
 * Author: Mel Massadian
 *
 * Copyright (c) 2023-2024 Mel Massadian
 *
 */

// Reference the shared typedefs file
/// <reference path="../types/typedefs.js" />

import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'

// #region base utils

// - crude uuid
export function makeUUID() {
  let dt = new Date().getTime()
  const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = ((dt + Math.random() * 16) % 16) | 0
    dt = Math.floor(dt / 16)
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
  })
  return uuid
}

//- local storage manager
export class LocalStorageManager {
  constructor(namespace) {
    this.namespace = namespace
  }

  _namespacedKey(key) {
    return `${this.namespace}:${key}`
  }

  set(key, value) {
    const serializedValue = JSON.stringify(value)
    localStorage.setItem(this._namespacedKey(key), serializedValue)
  }

  get(key, default_val = null) {
    const value = localStorage.getItem(this._namespacedKey(key))
    return value ? JSON.parse(value) : default_val
  }

  remove(key) {
    localStorage.removeItem(this._namespacedKey(key))
  }

  clear() {
    for (const key of Object.keys(localStorage).filter((k) =>
      k.startsWith(`${this.namespace}:`),
    )) {
      localStorage.removeItem(key)
    }
  }
}

// - log utilities

function createLogger(emoji, color, consoleMethod = 'log') {
  return (message, ...args) => {
    if (window.MTB?.DEBUG) {
      console[consoleMethod](
        `%c${emoji} ${message}`,
        `color: ${color};`,
        ...args,
      )
    }
  }
}

export const infoLogger = createLogger('ℹ️', 'yellow')
export const warnLogger = createLogger('⚠️', 'orange', 'warn')
export const errorLogger = createLogger('🔥', 'red', 'error')
export const successLogger = createLogger('✅', 'green')

export const log = (...args) => {
  if (window.MTB?.DEBUG) {
    console.debug(...args)
  }
}

/**
 * Deep merge two objects.
 * @param {Object} target - The target object to merge into.
 * @param {...Object} sources - The source objects to merge from.
 * @returns {Object} - The merged object.
 */
export function deepMerge(target, ...sources) {
  if (!sources.length) return target
  const source = sources.shift()

  for (const key in source) {
    if (source[key] instanceof Object) {
      if (!target[key]) Object.assign(target, { [key]: {} })
      deepMerge(target[key], source[key])
    } else {
      Object.assign(target, { [key]: source[key] })
    }
  }

  return deepMerge(target, ...sources)
}

// #endregion

// #region widget utils
export const CONVERTED_TYPE = 'converted-widget'

export function hideWidget(node, widget, suffix = '') {
  widget.origType = widget.type
  widget.hidden = true
  widget.origComputeSize = widget.computeSize
  widget.origSerializeValue = widget.serializeValue
  widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically
  widget.type = CONVERTED_TYPE + suffix
  widget.serializeValue = () => {
    // Prevent serializing the widget if we have no input linked
    const { link } = node.inputs.find((i) => i.widget?.name === widget.name)
    if (link == null) {
      return undefined
    }
    return widget.origSerializeValue
      ? widget.origSerializeValue()
      : widget.value
  }

  // Hide any linked widgets, e.g. seed+seedControl
  if (widget.linkedWidgets) {
    for (const w of widget.linkedWidgets) {
      hideWidget(node, w, `:${widget.name}`)
    }
  }
}

/**
 * Show widget
 *
 * @param {import("../../../web/types/litegraph.d.ts").IWidget} widget - target widget
 */
export function showWidget(widget) {
  widget.type = widget.origType
  widget.computeSize = widget.origComputeSize
  widget.serializeValue = widget.origSerializeValue

  delete widget.origType
  delete widget.origComputeSize
  delete widget.origSerializeValue

  // Hide any linked widgets, e.g. seed+seedControl
  if (widget.linkedWidgets) {
    for (const w of widget.linkedWidgets) {
      showWidget(w)
    }
  }
}

export function convertToWidget(node, widget) {
  showWidget(widget)
  const sz = node.size
  node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name))

  for (const widget of node.widgets) {
    widget.last_y -= LiteGraph.NODE_SLOT_HEIGHT
  }

  // Restore original size but grow if needed
  node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])])
}

export function convertToInput(node, widget, config) {
  hideWidget(node, widget)

  const { linkType } = getWidgetType(config)

  // Add input and store widget config for creating on primitive node
  const sz = node.size
  node.addInput(widget.name, linkType, {
    widget: { name: widget.name, config },
  })

  for (const widget of node.widgets) {
    widget.last_y += LiteGraph.NODE_SLOT_HEIGHT
  }

  // Restore original size but grow if needed
  node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])])
}

export function hideWidgetForGood(node, widget, suffix = '') {
  widget.origType = widget.type
  widget.origComputeSize = widget.computeSize
  widget.origSerializeValue = widget.serializeValue
  widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically
  widget.type = CONVERTED_TYPE + suffix
  // widget.serializeValue = () => {
  //     // Prevent serializing the widget if we have no input linked
  //     const w = node.inputs?.find((i) => i.widget?.name === widget.name);
  //     if (w?.link == null) {
  //         return undefined;
  //     }
  //     return widget.origSerializeValue ? widget.origSerializeValue() : widget.value;
  // };

  // Hide any linked widgets, e.g. seed+seedControl
  if (widget.linkedWidgets) {
    for (const w of widget.linkedWidgets) {
      hideWidgetForGood(node, w, `:${widget.name}`)
    }
  }
}

export function fixWidgets(node) {
  if (node.inputs) {
    for (const input of node.inputs) {
      log(input)
      if (input.widget || node.widgets) {
        // if (newTypes.includes(input.type)) {
        const matching_widget = node.widgets.find((w) => w.name === input.name)
        if (matching_widget) {
          // if (matching_widget.hidden) {
          //     log(`Already hidden skipping ${matching_widget.name}`)
          //     continue
          // }
          const w = node.widgets.find((w) => w.name === matching_widget.name)
          if (w && w.type !== CONVERTED_TYPE) {
            log(w)
            log(`hidding ${w.name}(${w.type}) from ${node.type}`)
            log(node)
            hideWidget(node, w)
          } else {
            log(`converting to widget ${w}`)

            convertToWidget(node, input)
          }
        }
      }
    }
  }
}
export function inner_value_change(widget, val, event = undefined) {
  let value = val
  if (widget.type === 'number' || widget.type === 'BBOX') {
    value = Number(value)
  } else if (widget.type === 'BOOL') {
    value = Boolean(value)
  }
  widget.value = corrected_value
  if (
    widget.options?.property &&
    node.properties[widget.options.property] !== undefined
  ) {
    node.setProperty(widget.options.property, value)
  }
  if (widget.callback) {
    widget.callback(widget.value, app.canvas, node, pos, event)
  }
}

export const getNamedWidget = (node, ...names) => {
  const out = {}

  for (const name of names) {
    out[name] = node.widgets.find((w) => w.name === name)
  }

  return out
}

/**
 * @param {LGraphNode} node
 * @param {LLink} link
 * @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
 */
export const nodesFromLink = (node, link) => {
  const fromNode = app.graph.getNodeById(link.origin_id)
  const toNode = app.graph.getNodeById(link.target_id)

  let tp = 'error'

  if (fromNode.id === node.id) {
    tp = 'outgoing'
  } else if (toNode.id === node.id) {
    tp = 'incoming'
  }

  return { to: toNode, from: fromNode, type: tp }
}

export const hasWidgets = (node) => {
  if (!node.widgets || !node.widgets?.[Symbol.iterator]) {
    return false
  }
  return true
}

export const cleanupNode = (node) => {
  if (!hasWidgets(node)) {
    return
  }
  for (const w of node.widgets) {
    if (w.canvas) {
      w.canvas.remove()
    }
    if (w.inputEl) {
      w.inputEl.remove()
    }
    // calls the widget remove callback
    w.onRemoved?.()
  }
}

export function offsetDOMWidget(
  widget,
  ctx,
  node,
  widgetWidth,
  widgetY,
  height,
) {
  const margin = 10
  const elRect = ctx.canvas.getBoundingClientRect()
  const transform = new DOMMatrix()
    .scaleSelf(
      elRect.width / ctx.canvas.width,
      elRect.height / ctx.canvas.height,
    )
    .multiplySelf(ctx.getTransform())
    .translateSelf(margin, margin + widgetY)

  const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)
  Object.assign(widget.inputEl.style, {
    transformOrigin: '0 0',
    transform: scale,
    left: `${transform.a + transform.e}px`,
    top: `${transform.d + transform.f}px`,
    width: `${widgetWidth - margin * 2}px`,
    // height: `${(widget.parent?.inputHeight || 32) - (margin * 2)}px`,
    height: `${(height || widget.parent?.inputHeight || 32) - margin * 2}px`,

    position: 'absolute',
    background: !node.color ? '' : node.color,
    color: !node.color ? '' : 'white',
    zIndex: 5, //app.graph._nodes.indexOf(node),
  })
}

/**
 * Extracts the type and link type from a widget config object.
 * @param {*} config
 * @returns
 */
export function getWidgetType(config) {
  // Special handling for COMBO so we restrict links based on the entries
  let type = config?.[0]
  let linkType = type
  if (Array.isArray(type)) {
    type = 'COMBO'
    linkType = linkType.join(',')
  }
  return { type, linkType }
}

// #endregion

// #region dynamic connections
/**
 * @param {NodeType} nodeType The nodetype to attach the documentation to
 * @param {str} prefix A prefix added to each dynamic inputs
 * @param {str | [str]} inputType The datatype(s) of those dynamic inputs
 * @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} opts
 * @returns
 */
export const setupDynamicConnections = (nodeType, prefix, inputType, opts) => {
  infoLogger(
    'Setting up dynamic connections for',
    Object.getOwnPropertyDescriptors(nodeType).title.value,
  )

  /** @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} */
  const options = opts || {}
  const onNodeCreated = nodeType.prototype.onNodeCreated
  const inputList = typeof inputType === 'object'

  nodeType.prototype.onNodeCreated = function () {
    const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined
    this.addInput(`${prefix}_1`, inputList ? '*' : inputType)
    return r
  }

  const onConnectionsChange = nodeType.prototype.onConnectionsChange
  /**
   * @param {OnConnectionsChangeParams} args
   */
  nodeType.prototype.onConnectionsChange = function (...args) {
    const [type, slotIndex, isConnected, link, ioSlot] = args

    options.link = link
    options.ioSlot = ioSlot
    const r = onConnectionsChange
      ? onConnectionsChange.apply(this, [
          type,
          slotIndex,
          isConnected,
          link,
          ioSlot,
        ])
      : undefined
    options.DEBUG = {
      node: this,
      type,
      slotIndex,
      isConnected,
      link,
      ioSlot,
    }

    dynamic_connection(
      this,
      slotIndex,
      isConnected,
      `${prefix}_`,
      inputType,
      options,
    )
    return r
  }
}

/**
 * Main logic around dynamic inputs
 *
 * @param {LGraphNode} node - The target node
 * @param {number} index - The slot index of the currently changed connection
 * @param {bool} connected - Was this event connecting or disconnecting
 * @param {string} [connectionPrefix] - The common prefix of the dynamic inputs
 * @param {string|[string]} [connectionType] - The type of the dynamic connection
 * @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options
 */
export const dynamic_connection = (
  node,
  index,
  connected,
  connectionPrefix = 'input_',
  connectionType = '*',
  opts = undefined,
) => {
  /* @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options*/
  const options = opts || {}

  if (
    node.inputs.length > 0 &&
    !node.inputs[index].name.startsWith(connectionPrefix)
  ) {
    return
  }

  const listConnection = typeof connectionType === 'object'

  const conType = listConnection ? '*' : connectionType
  const nameArray = options.nameArray || []

  const clean_inputs = () => {
    if (node.inputs.length === 0) return

    let w_count = node.widgets?.length || 0
    let i_count = node.inputs?.length || 0
    infoLogger(`Cleaning inputs: [BEFORE] (w: ${w_count} | inputs: ${i_count})`)

    const to_remove = []
    for (let n = 1; n < node.inputs.length; n++) {
      const element = node.inputs[n]
      if (!element.link) {
        if (node.widgets) {
          const w = node.widgets.find((w) => w.name === element.name)
          if (w) {
            w.onRemoved?.()
            node.widgets.length = node.widgets.length - 1
          }
        }
        infoLogger(`Removing input ${n}`)
        to_remove.push(n)
      }
    }
    for (let i = 0; i < to_remove.length; i++) {
      const id = to_remove[i]

      node.removeInput(id)
      i_count -= 1
    }
    node.inputs.length = i_count

    w_count = node.widgets?.length || 0
    i_count = node.inputs?.length || 0
    infoLogger(`Cleaning inputs: [AFTER] (w: ${w_count} | inputs: ${i_count})`)

    infoLogger('Cleaning inputs: making it sequential again')
    // make inputs sequential again
    for (let i = 0; i < node.inputs.length; i++) {
      let name = `${connectionPrefix}${i + 1}`

      if (nameArray.length > 0) {
        name = i < nameArray.length ? nameArray[i] : name
      }

      node.inputs[i].label = name
      node.inputs[i].name = name
    }
  }
  if (!connected) {
    if (!options.link) {
      infoLogger('Disconnecting', { options })

      clean_inputs()
    } else {
      if (!options.ioSlot.link) {
        node.connectionTransit = true
      } else {
        node.connectionTransit = false
        clean_inputs()
      }
      infoLogger('Reconnecting', { options })
    }
  }

  if (connected) {
    if (options.link) {
      const { from, to, type } = nodesFromLink(node, options.link)
      if (type === 'outgoing') return
      infoLogger('Connecting', { options, from, to, type })
    } else {
      infoLogger('Connecting', { options })
    }

    if (node.connectionTransit) {
      infoLogger('In Transit')
      node.connectionTransit = false
    }

    // Remove inputs and their widget if not linked.
    clean_inputs()

    if (node.inputs.length === 0) return
    // add an extra input
    if (node.inputs[node.inputs.length - 1].link !== null) {
      const nextIndex = node.inputs.length
      const name =
        nextIndex < nameArray.length
          ? nameArray[nextIndex]
          : `${connectionPrefix}${nextIndex + 1}`

      infoLogger(`Adding input ${nextIndex + 1} (${name})`)
      node.addInput(name, conType)
    }
  }
}
// #endregion

// #region color utils
export function isColorBright(rgb, threshold = 240) {
  const brightess = getBrightness(rgb)
  return brightess > threshold
}

function getBrightness(rgbObj) {
  return Math.round(
    (Number.parseInt(rgbObj[0]) * 299 +
      Number.parseInt(rgbObj[1]) * 587 +
      Number.parseInt(rgbObj[2]) * 114) /
      1000,
  )
}
// #endregion

// #region html/css utils

/**
 * Calculate total height of DOM element child
 *
 * @param {HTMLElement} parentElement - The target dom element
 * @returns {number} the total height
 */
export function calculateTotalChildrenHeight(parentElement) {
  let totalHeight = 0

  for (const child of parentElement.children) {
    const style = window.getComputedStyle(child)

    // Get height as an integer (without 'px')
    const height = Number.parseInt(style.height, 10)

    // Get vertical margin as integers
    const marginTop = Number.parseInt(style.marginTop, 10)
    const marginBottom = Number.parseInt(style.marginBottom, 10)

    // Sum up height and vertical margins
    totalHeight += height + marginTop + marginBottom
  }

  return totalHeight
}

export const loadScript = (
  FILE_URL,
  async = true,
  type = 'text/javascript',
) => {
  return new Promise((resolve, reject) => {
    try {
      // Check if the script already exists
      const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
      if (existingScript) {
        resolve({ status: true, message: 'Script already loaded' })
        return
      }

      const scriptEle = document.createElement('script')
      scriptEle.type = type
      scriptEle.async = async
      scriptEle.src = FILE_URL

      scriptEle.addEventListener('load', (_ev) => {
        resolve({ status: true })
      })

      scriptEle.addEventListener('error', (_ev) => {
        reject({
          status: false,
          message: `Failed to load the script ${FILE_URL}`,
        })
      })

      document.body.appendChild(scriptEle)
    } catch (error) {
      reject(error)
    }
  })
}

// #endregion

// #region documentation widget

const create_documentation_stylesheet = () => {
  const tag = 'mtb-documentation-stylesheet'

  let styleTag = document.head.querySelector(tag)

  if (!styleTag) {
    styleTag = document.createElement('style')
    styleTag.type = 'text/css'
    styleTag.id = tag

    styleTag.innerHTML = `
.documentation-popup {
    background: var(--comfy-menu-bg);
    position: absolute;
    color: var(--fg-color);
    font: 12px monospace;
    line-height: 1.5em;
    padding: 10px;
    border-radius: 6px;
    pointer-events: "inherit";
    z-index: 5;
    overflow: hidden;
}
.documentation-wrapper {
    padding: 0 2em;
    overflow: auto;
    max-height: 100%;
    /* Scrollbar styling for Chrome */
    &::-webkit-scrollbar {
       width: 6px;
    }
    &::-webkit-scrollbar-track {
       background: var(--bg-color);
    }
    &::-webkit-scrollbar-thumb {
       background-color: var(--fg-color);
       border-radius: 6px;
       border: 3px solid var(--bg-color);
    }

    /* Scrollbar styling for Firefox */
    scrollbar-width: thin;
    scrollbar-color: var(--fg-color) var(--bg-color);
    a {
      color: yellow;
    }
    a:visited {
      color: orange;
    }
    a:hover {
      color: red;
    }
}

.documentation-popup img {
  max-width: 100%;
}
.documentation-popup table {
  border-collapse: collapse;
  border: 1px var(--border-color) solid;
}
.documentation-popup th,
.documentation-popup td {
  border: 1px var(--border-color) solid;
}
.documentation-popup th {
  background-color: var(--comfy-input-bg);
}`
    document.head.appendChild(styleTag)
  }
}
let parserPromise
const callbackQueue = []

function runQueuedCallbacks() {
  while (callbackQueue.length) {
    const cb = callbackQueue.shift()
    cb(window.MTB.mdParser)
  }
}

function loadParser(shiki) {
  if (!parserPromise) {
    parserPromise = import(
      shiki
        ? '/mtb_async/mtb_markdown_plus.umd.js'
        : '/mtb_async/mtb_markdown.umd.js'
    )
      .then((_module) =>
        shiki ? MTBMarkdownPlus.getParser() : MTBMarkdown.getParser(),
      )
      .then((instance) => {
        window.MTB.mdParser = instance
        runQueuedCallbacks()
        return instance
      })
      .catch((error) => {
        console.error('Error loading the parser:', error)
      })
  }
  return parserPromise
}

export const ensureMarkdownParser = async (callback) => {
  infoLogger('Ensuring md parser')
  let use_shiki = false
  try {
    use_shiki = await api.getSetting('mtb.Use Shiki')
  } catch (e) {
    console.warn('Option not available yet', e)
  }

  if (window.MTB?.mdParser) {
    infoLogger('Markdown parser found')
    callback?.(window.MTB.mdParser)
    return window.MTB.mdParser
  }

  if (!parserPromise) {
    infoLogger('Running promise to fetch parser')

    try {
      loadParser(use_shiki) //.then(() => {
      // callback?.(window.MTB.mdParser)
      // })
    } catch (error) {
      console.error('Error loading the parser:', error)
    }
  } else {
    infoLogger('A similar promise is already running, waiting for it to finish')
  }
  if (callback) {
    callbackQueue.push(callback)
  }

  await parserPromise
  await parserPromise

  return window.MTB.mdParser
}

/**
 * Add documentation widget to the given node.
 *
 * This method will add a `docCtrl` property to the node
 * that contains the AbortController that manages all the events
 * defined inside it (global and instance ones) without explicit
 * cleanup method for each.
 *
 * @param {NodeData} nodeData
 * @param {NodeType}  nodeType
 * @param {DocumentationOptions} opts
 */
export const addDocumentation = (
  nodeData,
  nodeType,
  opts = { icon_size: 14, icon_margin: 4 },
) => {
  if (!nodeData.description) {
    infoLogger(
      `Skipping ${nodeData.name} doesn't have a description, skipping...`,
    )
    return
  }

  const options = opts || {}
  const iconSize = options.icon_size || 14
  const iconMargin = options.icon_margin || 4

  let docElement = null
  let wrapper = null

  const onRem = nodeType.prototype.onRemoved

  nodeType.prototype.onRemoved = function () {
    const r = onRem ? onRem.apply(this, []) : undefined

    if (docElement) {
      docElement.remove()
      docElement = null
    }

    if (wrapper) {
      wrapper.remove()
      wrapper = null
    }
    return r
  }

  const drawFg = nodeType.prototype.onDrawForeground

  /**
   * @param {OnDrawForegroundParams} args
   */
  nodeType.prototype.onDrawForeground = function (...args) {
    const [ctx, _canvas] = args
    const r = drawFg ? drawFg.apply(this, args) : undefined

    if (this.flags.collapsed) return r

    // icon position
    const x = this.size[0] - iconSize - iconMargin

    let resizeHandle
    // create it
    if (this.show_doc && docElement === null) {
      create_documentation_stylesheet()

      docElement = document.createElement('div')
      docElement.classList.add('documentation-popup')
      document.body.appendChild(docElement)

      wrapper = document.createElement('div')
      wrapper.classList.add('documentation-wrapper')
      docElement.appendChild(wrapper)

      // wrapper.innerHTML = documentationConverter.makeHtml(nodeData.description)

      ensureMarkdownParser().then(() => {
        MTB.mdParser.parse(nodeData.description).then((e) => {
          wrapper.innerHTML = e
          // resize handle
          resizeHandle = document.createElement('div')
          resizeHandle.classList.add('doc-resize-handle')
          resizeHandle.style.width = '0'
          resizeHandle.style.height = '0'
          resizeHandle.style.position = 'absolute'
          resizeHandle.style.bottom = '0'
          resizeHandle.style.right = '0'

          resizeHandle.style.cursor = 'se-resize'
          resizeHandle.style.userSelect = 'none'

          resizeHandle.style.borderWidth = '15px'
          resizeHandle.style.borderStyle = 'solid'

          resizeHandle.style.borderColor =
            'transparent var(--border-color) var(--border-color) transparent'

          wrapper.appendChild(resizeHandle)
          let isResizing = false

          let startX
          let startY
          let startWidth
          let startHeight

          resizeHandle.addEventListener(
            'mousedown',
            (e) => {
              e.stopPropagation()
              isResizing = true
              startX = e.clientX
              startY = e.clientY
              startWidth = Number.parseInt(
                document.defaultView.getComputedStyle(docElement).width,
                10,
              )
              startHeight = Number.parseInt(
                document.defaultView.getComputedStyle(docElement).height,
                10,
              )
            },

            { signal: this.docCtrl.signal },
          )

          document.addEventListener(
            'mousemove',
            (e) => {
              if (!isResizing) return
              const scale = app.canvas.ds.scale
              const newWidth = startWidth + (e.clientX - startX) / scale
              const newHeight = startHeight + (e.clientY - startY) / scale

              docElement.style.width = `${newWidth}px`
              docElement.style.height = `${newHeight}px`

              this.docPos = {
                width: `${newWidth}px`,
                height: `${newHeight}px`,
              }
            },
            { signal: this.docCtrl.signal },
          )

          document.addEventListener(
            'mouseup',
            () => {
              isResizing = false
            },
            { signal: this.docCtrl.signal },
          )
        })
      })
    } else if (!this.show_doc && docElement !== null) {
      docElement.remove()
      docElement = null
    }

    // reposition
    if (this.show_doc && docElement !== null) {
      const rect = ctx.canvas.getBoundingClientRect()

      const dpi = Math.max(1.0, window.devicePixelRatio)
      const scaleX = rect.width / ctx.canvas.width
      const scaleY = rect.height / ctx.canvas.height
      const transform = new DOMMatrix()
        .scaleSelf(scaleX, scaleY)
        .multiplySelf(ctx.getTransform())
        .translateSelf(this.size[0] * scaleX * dpi, 0)
        .translateSelf(10, -32)

      const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)

      Object.assign(docElement.style, {
        transformOrigin: '0 0',
        transform: scale,
        left: `${transform.a + rect.x + transform.e}px`,
        top: `${transform.d + rect.y + transform.f}px`,
        width: this.docPos ? this.docPos.width : `${this.size[0] * 1.5}px`,
        height: this.docPos?.height,
      })

      if (this.docPos === undefined) {
        this.docPos = {
          width: docElement.style.width,
          height: docElement.style.height,
        }
      }
    }

    ctx.save()
    ctx.translate(x, iconSize - 34)
    ctx.scale(iconSize / 32, iconSize / 32)
    ctx.strokeStyle = 'rgba(255,255,255,0.3)'

    ctx.lineCap = 'round'
    ctx.lineJoin = 'round'

    ctx.lineWidth = 2.4
    ctx.font = 'bold 36px monospace'
    ctx.fillText('?', 0, 24)

    // ctx.font = `bold ${this.show_doc ? 36 : 24}px monospace`
    // ctx.fillText(`${this.show_doc ? '▼' : '▶'}`, 24, 24)
    ctx.restore()

    return r
  }
  const mouseDown = nodeType.prototype.onMouseDown

  /**
   * @param {OnMouseDownParams} args
   */
  nodeType.prototype.onMouseDown = function (...args) {
    const [_event, localPos, _graphCanvas] = args
    const r = mouseDown ? mouseDown.apply(this, args) : undefined
    const iconX = this.size[0] - iconSize - iconMargin
    const iconY = iconSize - 34
    if (
      localPos[0] > iconX &&
      localPos[0] < iconX + iconSize &&
      localPos[1] > iconY &&
      localPos[1] < iconY + iconSize
    ) {
      // Pencil icon was clicked, open the editor
      // this.openEditorDialog();
      if (this.show_doc === undefined) {
        this.show_doc = true
      } else {
        this.show_doc = !this.show_doc
      }
      if (this.show_doc) {
        this.docCtrl = new AbortController()
      } else {
        this.docCtrl.abort()
      }
      return true // Return true to indicate the event was handled
    }

    return r // Return false to let the event propagate

    // return r;
  }
}

// #endregion

// #region node extensions

/**
 * Extend an object, either replacing the original property or extending it.
 * @param {Object} object - The object to which the property belongs.
 * @param {string} property - The name of the property to chain the callback to.
 * @param {Function} callback - The callback function to be chained.
 */
export function extendPrototype(object, property, callback) {
  if (object === undefined) {
    console.error('Could not extend undefined object', { object, property })
    return
  }
  if (property in object) {
    const callback_orig = object[property]
    object[property] = function (...args) {
      const r = callback_orig.apply(this, args)
      callback.apply(this, args)
      return r
    }
  } else {
    object[property] = callback
  }
}

/**
 * Appends a callback to the extra menu options of a given node type.
 * @param {NodeType} nodeType
 * @param {(app,options) => ContextMenuItem[]} cb
 */
export function addMenuHandler(nodeType, cb) {
  const getOpts = nodeType.prototype.getExtraMenuOptions
  /**
   * @returns {ContextMenuItem[]} items
   */
  nodeType.prototype.getExtraMenuOptions = function (app, options) {
    const r = getOpts.apply(this, [app, options]) || []
    const newItems = cb.apply(this, [app, options]) || []
    return [...r, ...newItems]
  }
}

/** Prefixes the node title with '[DEPRECATED]' and log the deprecation reason to the console.*/
export const addDeprecation = (nodeType, reason) => {
  const title = nodeType.title
  nodeType.title = `[DEPRECATED] ${title}`
  // console.log(nodeType)

  const styles = {
    title: 'font-size:1.3em;font-weight:900;color:yellow; background: black',
    reason: 'font-size:1.2em',
  }
  console.log(
    `%c!  ${title} is deprecated:%c ${reason}`,
    styles.title,
    styles.reason,
  )
}

// #endregion

// #region API / graph utilities
export const getAPIInputs = () => {
  const inputs = {}
  let counter = 1
  for (const node of getNodes(true)) {
    const widgets = node.widgets

    if (node.properties.mtb_api && node.properties.useAPI) {
      if (node.properties.mtb_api.inputs) {
        for (const currentName in node.properties.mtb_api.inputs) {
          const current = node.properties.mtb_api.inputs[currentName]
          if (current.enabled) {
            const inputName = current.name || currentName
            const widget = widgets.find((w) => w.name === currentName)
            if (!widget) continue
            if (!(inputName in inputs)) {
              inputs[inputName] = {
                ...current,
                id: counter,
                name: inputName,
                type: current.type,
                node_id: node.id,
                widgets: [],
              }
            }
            inputs[inputName].widgets.push(widget)
            counter = counter + 1
          }
        }
      }
    }
  }
  return inputs
}

export const getNodes = (skip_unused) => {
  const nodes = []
  for (const outerNode of app.graph.computeExecutionOrder(false)) {
    const skipNode =
      (outerNode.mode === 2 || outerNode.mode === 4) && skip_unused
    const innerNodes =
      !skipNode && outerNode.getInnerNodes
        ? outerNode.getInnerNodes()
        : [outerNode]
    for (const node of innerNodes) {
      if ((node.mode === 2 || node.mode === 4) && skip_unused) {
        continue
      }
      nodes.push(node)
    }
  }
  return nodes
}
