/**
 * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
 * @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode
 * @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor
 * @typedef {import('./WorkspaceInlineEditorManager.js').WorkspaceInlineEditorManager} WorkspaceInlineEditorManager
 */

import { WorkspaceGeometry } from "./WorkspaceGeometry.js";

/**
 * @typedef {"input"|"output"} PinDirection
 */

/**
 * Coordinates node DOM rendering, pin creation, and connection class updates.
 */
export class WorkspaceNodeRenderer {
  #workspace;
  #inlineEditors;
  #callbacks;

  /**
   * @param {BlueprintWorkspace} workspace Owning workspace instance.
   * @param {{
   *   inlineEditorManager: WorkspaceInlineEditorManager,
   *   applySelectionState: () => void,
   *   selectNode: (nodeId: string) => void,
   *   startNodeDrag: (event: PointerEvent, nodeId: string) => void,
   *   refreshVariableNode: (nodeId: string) => boolean,
  *   refreshLocalVariableNode: (nodeId: string) => boolean,
   *   refreshCustomEventNode: (nodeId: string) => boolean,
   *   refreshCustomEventCallNode: (nodeId: string) => boolean,
   *   renderConnections: () => void,
   *   shouldHighlightPin: (nodeId: string, pinId: string, direction: PinDirection) => boolean,
   *   beginConnection: (event: PointerEvent, nodeId: string, pinId: string, direction: PinDirection) => void,
   *   finalizeConnection: (nodeId: string, pinId: string, direction: PinDirection) => void,
   *   getPinElement: (nodeId: string, pinId: string, direction: PinDirection) => HTMLElement | null,
   * }} options Manager dependencies.
   */
  constructor(workspace, options) {
    this.#workspace = workspace;
    this.#inlineEditors = options.inlineEditorManager;
    this.#callbacks = {
      applySelectionState: options.applySelectionState,
      selectNode: options.selectNode,
      startNodeDrag: options.startNodeDrag,
      refreshVariableNode: options.refreshVariableNode,
      refreshLocalVariableNode: options.refreshLocalVariableNode,
      refreshCustomEventNode: options.refreshCustomEventNode,
      refreshCustomEventCallNode: options.refreshCustomEventCallNode,
      renderConnections: options.renderConnections,
      shouldHighlightPin: options.shouldHighlightPin,
      beginConnection: options.beginConnection,
      finalizeConnection: options.finalizeConnection,
      getPinElement: options.getPinElement,
    };
  }

  /**
   * Renders a node article, wires pin events, and updates inline editors.
   *
   * @param {BlueprintNode} node Target node.
   */
  renderNode(node) {
    const fragment = this.#workspace.nodeTemplate.content.cloneNode(true);
    const article = /** @type {HTMLElement} */ (
      fragment.querySelector(".blueprint-node")
    );
    const title = /** @type {HTMLElement} */ (
      article.querySelector(".node-title")
    );
    const inputContainer = /** @type {HTMLElement} */ (
      article.querySelector(".node-inputs")
    );
    const outputContainer = /** @type {HTMLElement} */ (
      article.querySelector(".node-outputs")
    );

    title.textContent = node.title;
    article.dataset.nodeId = node.id;
    article.dataset.nodeType = node.type;
    article.style.transform = WorkspaceGeometry.positionToTransform(
      node.position
    );

    this.#inlineEditors.setupLiteralEditor(node, article, inputContainer);

    inputContainer.innerHTML = "";
    node.inputs.forEach((pin) => {
      const pinElement = this.#createPinElement(node, pin);
      pinElement.dataset.direction = "input";
      inputContainer.appendChild(pinElement);
      this.#inlineEditors.setupPinEditor(node, pin, pinElement);
    });

    outputContainer.innerHTML = "";
    node.outputs.forEach((pin) => {
      const pinElement = this.#createPinElement(node, pin);
      pinElement.dataset.direction = "output";
      outputContainer.appendChild(pinElement);
      this.#inlineEditors.setupPinEditor(node, pin, pinElement);
    });

    article.addEventListener("pointerdown", (event) => {
      const target = /** @type {EventTarget | null} */ (event.target);
      if (!(target instanceof HTMLElement)) {
        return;
      }
      if (target.closest(".pin-handle")) {
        return;
      }

      const selectedIds = this.#workspace.selectedNodeIds;
      if (selectedIds.has(node.id)) {
        if (this.#workspace.selectedNodeId !== node.id) {
          this.#workspace.selectedNodeId = node.id;
          this.#callbacks.applySelectionState();
        }
      } else {
        this.#callbacks.selectNode(node.id);
      }

      const isMouse =
        event.pointerType === "mouse" || event.pointerType === "";
      if (isMouse && event.button !== 0) {
        return;
      }
      if (
        !isMouse &&
        event.pointerType !== "touch" &&
        event.pointerType !== "pen"
      ) {
        return;
      }

      if (target.closest(".pin")) {
        return;
      }

      if (this.#isInteractiveElement(target)) {
        return;
      }

      this.#callbacks.startNodeDrag(event, node.id);
    });

    article.addEventListener("pointerenter", () => {
      article.classList.add("is-hovered");
    });

    article.addEventListener("pointerleave", () => {
      article.classList.remove("is-hovered");
    });

    article.addEventListener("pointerdown", (event) => {
      if (event.button === 2) {
        this.#workspace.navigationManager?.beginPan(event);
      }
    });

    article.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      event.stopPropagation();

      if (this.#workspace.navigationManager?.isPanRecent(300)) {
        return;
      }

      const target = /** @type {EventTarget | null} */ (event.target);
      if (!(target instanceof HTMLElement)) {
        return;
      }

      if (target.closest(".pin")) {
        return;
      }

      if (!this.#workspace.selectedNodeIds.has(node.id)) {
        this.#callbacks.selectNode(node.id);
      }

      this.#workspace.nodeContextMenu.show(event.clientX, event.clientY, node.id);
    });

    this.#workspace.nodeLayer.appendChild(article);
    this.#workspace.nodeElements.set(node.id, article);
    this.#inlineEditors.syncNode(node.id);

    let connectionsMutated = false;
    connectionsMutated =
      this.#callbacks.refreshVariableNode(node.id) || connectionsMutated;
    if (this.#callbacks.refreshLocalVariableNode) {
      connectionsMutated =
        this.#callbacks.refreshLocalVariableNode(node.id) ||
        connectionsMutated;
    }
    if (node.type === "custom_event") {
      connectionsMutated =
        this.#callbacks.refreshCustomEventNode(node.id) || connectionsMutated;
    } else if (node.type === "call_custom_event") {
      connectionsMutated =
        this.#callbacks.refreshCustomEventCallNode(node.id) ||
        connectionsMutated;
    }
    if (connectionsMutated) {
      this.#callbacks.renderConnections();
    }

    this.updatePinConnectionsForNode(node.id);
  }

  /**
   * Rebuilds DOM pin containers for a node when its descriptors change.
   *
   * @param {BlueprintNode} node Target node.
   * @param {{ inputs?: boolean, outputs?: boolean }} [options] Pin refresh options.
   */
  rebuildNodePins(node, options = {}) {
    const { inputs = true, outputs = true } = options;
    const article = this.#workspace.nodeElements.get(node.id);
    if (!article) {
      return;
    }

    if (inputs) {
      const inputContainer = article.querySelector(".node-inputs");
      if (inputContainer instanceof HTMLElement) {
        inputContainer.innerHTML = "";
        node.inputs.forEach((pin) => {
          const pinElement = this.#createPinElement(node, pin);
          pinElement.dataset.direction = "input";
          inputContainer.appendChild(pinElement);
          this.#inlineEditors.setupPinEditor(node, pin, pinElement);
        });
      }
      this.#inlineEditors.prunePinEditors(node);
    }

    if (outputs) {
      const outputContainer = article.querySelector(".node-outputs");
      if (outputContainer instanceof HTMLElement) {
        outputContainer.innerHTML = "";
        node.outputs.forEach((pin) => {
          const pinElement = this.#createPinElement(node, pin);
          pinElement.dataset.direction = "output";
          outputContainer.appendChild(pinElement);
          this.#inlineEditors.setupPinEditor(node, pin, pinElement);
        });
      }
    }

    this.updatePinConnectionsForNode(node.id);
  }

  /**
   * Applies connection state classes to every pin associated with a node.
   *
   * @param {string} nodeId Node identifier.
   */
  updatePinConnectionsForNode(nodeId) {
    const node = this.#workspace.graph.nodes.get(nodeId);
    if (!node) {
      return;
    }

    node.inputs.forEach((pin) => {
      this.#applyPinConnectionState(nodeId, pin.id, "input");
    });
    node.outputs.forEach((pin) => {
      this.#applyPinConnectionState(nodeId, pin.id, "output");
    });
  }

  /**
   * Refreshes connection state classes for every pin in the workspace.
   */
  refreshAllPinConnections() {
    this.#workspace.graph.nodes.forEach((node) => {
      this.updatePinConnectionsForNode(node.id);
    });
  }

  /**
   * Creates and wires a pin element for the provided descriptor.
   *
   * @param {BlueprintNode} node Parent node reference.
   * @param {PinDescriptor} pin Pin descriptor.
   * @returns {HTMLElement}
   */
  #createPinElement(node, pin) {
    const fragment = this.#workspace.pinTemplate.content.cloneNode(true);
    const container = /** @type {HTMLElement} */ (fragment.firstElementChild);
    const label = /** @type {HTMLElement} */ (
      container.querySelector(".pin-label")
    );
    const handle = /** @type {HTMLElement} */ (
      container.querySelector(".pin-handle")
    );

    container.dataset.pinId = pin.id;
    container.dataset.nodeId = node.id;
    container.dataset.type = pin.kind;
    const isStandardExec =
      pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
    if (isStandardExec) {
      label.textContent = "";
      label.classList.add("is-hidden");
    } else {
      label.textContent = pin.name;
    }

    const shouldIgnoreForConnection = (target) => {
      if (!(target instanceof HTMLElement)) {
        return false;
      }
      return Boolean(target.closest(".pin-inline-control"));
    };

    const handlePointerDown = (event) => {
      if (event.button !== 0) {
        return;
      }
      if (shouldIgnoreForConnection(event.target)) {
        return;
      }

      if (event.altKey) {
        event.preventDefault();
        event.stopPropagation();
        this.#workspace.graph.removeConnectionsForPin({
          nodeId: node.id,
          pinId: pin.id,
        });
        return;
      }

      event.stopPropagation();
      event.preventDefault();
      this.#callbacks.beginConnection(event, node.id, pin.id, pin.direction);
    };

    const finalizeConnection = (event) => {
      if (shouldIgnoreForConnection(event.target)) {
        return;
      }
      if (!this.#workspace.pendingConnection) {
        return;
      }
      event.stopPropagation();
      this.#callbacks.finalizeConnection(node.id, pin.id, pin.direction);
    };

    handle.addEventListener("pointerdown", handlePointerDown);
    handle.addEventListener("pointerup", finalizeConnection);

    container.addEventListener("pointerdown", handlePointerDown);
    container.addEventListener("pointerup", finalizeConnection);

    container.addEventListener("pointerenter", () => {
      container.classList.add("is-hovered");
      if (
        this.#callbacks.shouldHighlightPin(node.id, pin.id, pin.direction)
      ) {
        container.classList.add("is-drop-hover");
      }
    });

    container.addEventListener("pointerleave", () => {
      container.classList.remove("is-hovered");
      container.classList.remove("is-drop-hover");
    });

    const handleContextMenu = (event) => {
      if (shouldIgnoreForConnection(event.target)) {
        return;
      }
      event.preventDefault();
      this.#workspace.graph.removeConnectionsForPin({
        nodeId: node.id,
        pinId: pin.id,
      });
    };

    handle.addEventListener("contextmenu", handleContextMenu);
    container.addEventListener("contextmenu", handleContextMenu);

    this.#applyPinConnectionState(node.id, pin.id, pin.direction);
    return container;
  }

  /**
   * Applies connection state classes to the targeted pin element.
   *
   * @param {string} nodeId Node identifier.
   * @param {string} pinId Pin identifier.
   * @param {PinDirection} direction Pin direction.
   */
  #applyPinConnectionState(nodeId, pinId, direction) {
    const element = this.#callbacks.getPinElement(nodeId, pinId, direction);
    if (!element) {
      return;
    }

    const connected = this.#hasConnection(nodeId, pinId);
    element.classList.toggle("is-disconnected", !connected);
    element.classList.toggle("is-connected", connected);
  }

  /**
   * Determines whether a pin currently participates in any connection.
   *
   * @param {string} nodeId Node identifier.
   * @param {string} pinId Pin identifier.
   * @returns {boolean}
   */
  #hasConnection(nodeId, pinId) {
    return this.#workspace.graph
      .getConnectionsForNode(nodeId, pinId)
      .some((connection) => {
        const isTarget =
          connection.to.nodeId === nodeId && connection.to.pinId === pinId;
        const isSource =
          connection.from.nodeId === nodeId &&
          connection.from.pinId === pinId;
        return isTarget || isSource;
      });
  }

  /**
   * Determines whether a node event target should block drag initiation.
   *
   * @param {HTMLElement} element Candidate event target.
   * @returns {boolean}
   */
  #isInteractiveElement(element) {
    if (element.closest(".pin-inline-control")) {
      return true;
    }
    if (element.closest(".literal-inline-editor")) {
      return true;
    }
    if (element.closest('[contenteditable="true"]')) {
      return true;
    }
    if (element.closest("input, textarea, select, button")) {
      return true;
    }
    return false;
  }
}
