/**
 * Helpers for the graph editor.
 *
 * @require PlotBoilerplate
 *
 * @author   Ikaros Kappler
 * @date     2023-07-28
 * @version  1.0.0
 **/

import { MouseHandler, PlotBoilerplate, XMouseEvent, XYCoords, XYDimension } from "plotboilerplate";
import { IDialogueConfig, IMiniQuestionaire, IMiniQuestionaireWithPosition, IOptionIdentifyer } from "./interfaces";
import { RPGDOMHelpers } from "./domHelpers";
import { EditorRenderer } from "./editorRenderer";
import { Editor } from "./Editor";
import { DialogueMetaHelpers } from "./metaHelpers";

export class EditorHelper {
  editor: Editor;
  pb: PlotBoilerplate;
  boxSize: XYDimension;

  // TODO: convert into node identifyer
  /**
   * The highlighted node's name or null if none is highlighted.
   * Used to highlight nodes when the mouse is over.
   */
  highlightedNodeName: string | null = null;

  /**
   * The highlighted node itself or null if none is highligted.
   * Used to determine rendering colors.
   */
  highlightedNode: IMiniQuestionaireWithPosition | null = null;

  /**
   * The selected node's name or null if none is selected.
   * Used to determine the node editor's contents.
   */
  selectedNodeName: string | null = null;

  /**
   * The selected node itself or null if none is selected.
   * Used to determine the node editor's contents.
   */
  selectedNode: IMiniQuestionaireWithPosition | null = null;

  /**
   * The currently selected option or null if none is selected.
   * Used to re-connect an option with a new successor node.
   */
  selectedOption: IOptionIdentifyer | null = null;

  /**
   * The currently highlighted option.
   * Used to draw on-mouse-over options with a different color.
   */
  hightlightedOption: IOptionIdentifyer | null = null;

  /**
   * The current mouse position (or null if mouse is not on canvas).
   * In local relative coordinate system.
   */
  relativeMousePosition: XYCoords | null = null;

  domHelper: RPGDOMHelpers;

  dialogConfigWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition>;

  metaHelpers: DialogueMetaHelpers;

  constructor(editor: Editor, pb: PlotBoilerplate, boxSize: XYDimension) {
    this.editor = editor;
    this.pb = pb;
    this.boxSize = boxSize;
    this.selectedNodeName = null;
    this.domHelper = new RPGDOMHelpers(this);
    this.metaHelpers = new DialogueMetaHelpers(this);
  }

  setDialogConfig(dialogConfigWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition>) {
    this.dialogConfigWithPositions = dialogConfigWithPositions;
  }

  setSelectedOption(selectedOption: IOptionIdentifyer | null, noRedraw?: boolean) {
    // console.log("Set selected option", selectedOption);
    this.selectedOption = selectedOption;
    if (!noRedraw) {
      this.pb.redraw();
    }
  }

  setHighlightedOption(hightlightedOption: IOptionIdentifyer | null) {
    // const isRedrawRequired = this.hightlightedOption !== hightlightedOption;
    const isRedrawRequired = !this.isEqualOptionIdentifyer(this.hightlightedOption, hightlightedOption);

    this.hightlightedOption = hightlightedOption;
    if (isRedrawRequired) {
      this.pb.redraw();
    }
  }

  setHighlightedNode(nodeName: string | null, noRedraw?: boolean) {
    this.highlightedNodeName = nodeName;
    this.highlightedNode = nodeName ? this.dialogConfigWithPositions.graph[nodeName] : null;
    if (!noRedraw) {
      this.pb.redraw();
    }
  }

  /**
   * A helper function to create random safe positions in the viewport area.
   * @param {PlotBoilerplate} pb
   * @param {XYDimension} boxSize
   * @returns
   */
  getRandomPosition(): XYCoords {
    const viewport = this.pb.viewport();
    return {
      x: viewport.min.x + this.boxSize.width + (viewport.width - 2 * this.boxSize.width) * Math.random(),
      y: viewport.min.y + this.boxSize.height + (viewport.height - 2 * this.boxSize.height) * Math.random()
    };
  }

  setSelectedNode(nodeName: string | null, node: IMiniQuestionaireWithPosition | null) {
    this.selectedNodeName = nodeName;
    this.selectedNode = node;

    if (nodeName && node) {
      // this.domHelper.editorElement.classList.remove("d-none");
      this.domHelper.toggleVisibility(true);
      this.domHelper.showAnswerOptions(nodeName, this.selectedNode);
    } else {
      // this.domHelper.editorElement.classList.add("d-none");
      this.domHelper.toggleVisibility(false);
      this.domHelper.showAnswerOptions(null, null);
    }
    this.pb.redraw();
  }

  /**
   * A helper function to make sure all graph nodes have valid positions. Those without
   * valid positions (eg like those being loaded from an incomplete JSON file) will be
   * assigned to a random position inside the viewport.
   *
   * @param {PlotBoilerplate} pb
   * @param {XYDimension} boxSize
   * @returns
   */
  enrichPositions(baseConfig: IDialogueConfig<IMiniQuestionaire>): IDialogueConfig<IMiniQuestionaireWithPosition> {
    // Clone?
    const configWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition> =
      baseConfig as IDialogueConfig<IMiniQuestionaireWithPosition>;

    for (var nodeName in configWithPositions.graph) {
      const graphNode: IMiniQuestionaireWithPosition = configWithPositions.graph[nodeName];
      if (!graphNode) {
        console.warn(`Warning: graph node ${nodeName} is null or undefined!`);
        continue;
      }
      // Anonymous member check
      if (!graphNode.hasOwnProperty("editor")) {
        graphNode.editor = { position: this.getRandomPosition() };
      } else if (!(graphNode.editor as IMiniQuestionaireWithPosition).hasOwnProperty("position")) {
        (graphNode.editor as any).position = this.getRandomPosition();
      } else {
        if (!(graphNode.editor as any).position.hasOwnProperty("x") || isNaN(graphNode.editor?.position?.x ?? NaN)) {
          if (graphNode.editor?.position) {
            graphNode.editor.position.x = this.getRandomPosition().x;
          }
        }
        if (!(graphNode.editor as any).position.hasOwnProperty("y") || isNaN(graphNode.editor?.position?.y ?? NaN)) {
          if (graphNode.editor?.position) {
            graphNode.editor.position.y = this.getRandomPosition().y;
          }
        }
      }
    }
    return configWithPositions;
  }

  /**
   * Check if the meta data is valid and – if not – add missing default fields.
   * @param dialogueConfig
   */
  enrichMetaData(dialogueConfig: Object) {
    const result = dialogueConfig as IDialogueConfig<IMiniQuestionaireWithPosition>;
    if (!dialogueConfig.hasOwnProperty("meta")) {
      result.meta = { name: "noname", npcs: [] };
    }
    if (!result.meta.npcs) {
      result.meta.npcs = [];
    }
    if (result.meta.npcs.length === 0) {
      result.meta.npcs.push({ name: "NPC #0" });
    }
  }

  isPosInGraphNodeBox(pos: XYCoords, graphNode: IMiniQuestionaireWithPosition): boolean {
    return Boolean(
      graphNode.editor &&
        graphNode.editor.position &&
        graphNode.editor.position.x <= pos.x &&
        graphNode.editor.position.y <= pos.y &&
        graphNode.editor.position.x + this.boxSize.width > pos.x &&
        graphNode.editor.position.y + this.boxSize.height > pos.y
    );
  }

  isPosInOptionNodeBox(pos: XYCoords, graphNode: IMiniQuestionaireWithPosition, optionIndex: number): boolean {
    // EditorRenderer.OPTION_OFFSET_X;
    return Boolean(
      graphNode.editor &&
        graphNode.editor.position &&
        graphNode.editor.position.x + EditorRenderer.OPTION_OFFSET_X <= pos.x &&
        graphNode.editor.position.y + (optionIndex + 1) * this.boxSize.height <= pos.y &&
        graphNode.editor.position.x + EditorRenderer.OPTION_OFFSET_X + this.boxSize.width > pos.x &&
        graphNode.editor.position.y + (optionIndex + 1) * this.boxSize.height + this.boxSize.height > pos.y
    );
  }

  locateNodeBoxNameAtPos(pos: XYCoords): string | null {
    for (var nodeName in this.dialogConfigWithPositions.graph) {
      const graphNode: IMiniQuestionaireWithPosition = this.dialogConfigWithPositions.graph[nodeName];
      if (this.isPosInGraphNodeBox(pos, graphNode)) {
        return nodeName;
      }
    }
    return null;
  }

  locateOptionBoxNameAtPos(pos: XYCoords): IOptionIdentifyer | null {
    for (var nodeName in this.dialogConfigWithPositions.graph) {
      const graphNode: IMiniQuestionaireWithPosition = this.dialogConfigWithPositions.graph[nodeName];
      for (var i = 0; i < graphNode.o.length; i++) {
        if (this.isPosInOptionNodeBox(pos, graphNode, i)) {
          return { nodeName: nodeName, node: graphNode, optionIndex: i };
        }
      }
    }
    return null;
  }

  isNodeHighlighted(nodName: string) {
    return this.highlightedNodeName === nodName;
  }

  isOptionHighlighted(nodeName: string, optionIndex: number): boolean {
    return Boolean(
      this.hightlightedOption &&
        this.hightlightedOption.nodeName === nodeName &&
        this.hightlightedOption.optionIndex === optionIndex
    );
  }

  isOptionSelected(nodeName: string, optionIndex: number): boolean {
    return Boolean(
      this.selectedOption && this.selectedOption.nodeName === nodeName && this.selectedOption.optionIndex === optionIndex
    );
  }

  addNewDialogueNode() {
    // Place two box units to the right if currently there is a selected node.
    // Otherwise random position.
    const position: XYCoords =
      this.selectedNode && this.selectedNode.editor && this.selectedNode.editor.position
        ? {
            x: this.selectedNode.editor.position.x + 2 * this.boxSize.width,
            y: this.selectedNode.editor.position.y + this.boxSize.height
          }
        : this.getRandomPosition();
    const nodeName = this.randomNodeKey();
    const newNode: IMiniQuestionaireWithPosition = {
      q: "",
      o: [{ a: "", next: null }],
      editor: {
        position: position
      }
    };
    this.dialogConfigWithPositions.graph[nodeName] = newNode;
    this.selectedNodeName = nodeName;
    this.selectedNode = newNode;
    this.domHelper.showAnswerOptions(nodeName, newNode);
    this.pb.redraw();
  }

  removeNewDialogueNode(nodeName: string) {
    delete this.dialogConfigWithPositions.graph[nodeName];
    this.selectedNodeName = null;
    this.selectedNode = null;
    this.domHelper.showAnswerOptions(null, null);
    this.pb.redraw();
  }

  boxMovehandler() {
    const _self = this;
    // +---------------------------------------------------------------------------------
    // | Add a mouse listener to track the mouse position.
    // +-------------------------------
    var mouseDownPos: XYCoords | null = null;
    var lastMouseDownPos: XYCoords | null = null;
    var draggingNode: IMiniQuestionaireWithPosition | null = null;
    var draggingNodeName: string | null = null;
    const handler = new MouseHandler(this.pb.eventCatcher)
      .down((evt: XMouseEvent) => {
        mouseDownPos = this.pb.transformMousePosition(evt.params.mouseDownPos.x, evt.params.mouseDownPos.y);
        lastMouseDownPos = { x: evt.params.mouseDownPos.x, y: evt.params.mouseDownPos.y };
        draggingNodeName = this.locateNodeBoxNameAtPos(mouseDownPos);
        if (draggingNodeName) {
          draggingNode = this.dialogConfigWithPositions.graph[draggingNodeName];
        }
      })
      .up((_evt: XMouseEvent) => {
        mouseDownPos = null;
        draggingNode = null;
      })
      .drag((evt: XMouseEvent) => {
        if (!mouseDownPos || !draggingNode || !draggingNode.editor || !draggingNode.editor.position) {
          return;
        }
        // const diff = evt.params.dragAmount;
        draggingNode.editor.position.x += evt.params.dragAmount.x / this.pb.draw.scale.x;
        draggingNode.editor.position.y += evt.params.dragAmount.y / this.pb.draw.scale.y;
      })
      .move((evt: XMouseEvent) => {
        // console.log("move", evt);
        // Check if mouse pointer hovers over an option -> set highlighted
        const mouseMovePos = this.pb.transformMousePosition(evt.params.pos.x, evt.params.pos.y);
        _self.relativeMousePosition = { x: mouseMovePos.x, y: mouseMovePos.y };
        const hoveringOptionIdentifyer: IOptionIdentifyer | null = this.locateOptionBoxNameAtPos(mouseMovePos);
        // Can be null
        _self.setHighlightedOption(hoveringOptionIdentifyer);
        if (!hoveringOptionIdentifyer) {
          // Check if hover on graph node
          const hoveringNodeName = this.locateNodeBoxNameAtPos(mouseMovePos);
          this.setHighlightedNode(hoveringNodeName);
        } else {
          this.setHighlightedNode(null);
        }
      })
      .click((evt: XMouseEvent) => {
        // Stop if mouse was moved
        // console.log("lastMouseDownPos", lastMouseDownPos, " evt.params.pos", evt.params.pos);
        if (lastMouseDownPos && (lastMouseDownPos.x !== evt.params.pos.x || lastMouseDownPos.y !== evt.params.pos.y)) {
          return;
        }
        // Check if mouse pointer hovers over an option -> set selected AND select node
        const mouseClickPos = this.pb.transformMousePosition(evt.params.pos.x, evt.params.pos.y);
        _self.handleClick(mouseClickPos);
      });

    return handler;
  }

  /**
   * Check if a question box or an answer box was clicked.
   * @param mouseClickPos
   */
  handleClick(mouseClickPos: XYCoords) {
    const clickedOptionIdentifyer: IOptionIdentifyer | null = this.locateOptionBoxNameAtPos(mouseClickPos);
    if (clickedOptionIdentifyer) {
      this.setSelectedOption(clickedOptionIdentifyer);
    } else {
      // Otherwise (no option was clicked) check if a node was clicked directly.
      const clickedNodeName = this.locateNodeBoxNameAtPos(mouseClickPos);
      // console.log("Click", clickedNodeName);
      if (clickedNodeName) {
        if (this.selectedOption) {
          this.handleOptionReconnect(clickedNodeName);
          this.pb.redraw();
        } else {
          this.setSelectedNode(clickedNodeName, this.dialogConfigWithPositions.graph[clickedNodeName]);
        }
      } else {
        this.setSelectedNode(null, null);
      }
      this.setSelectedOption(null, false);
    }
  }

  handleOptionReconnect(clickedNodeName: string) {
    if (!this.selectedOption) {
      // && !this.selectedNodeName) {
      // Actually this fuction should not be called at all in that case.
      console.warn("Warn: cannot reconnect option when no option is selected.");
      return;
    }
    const graph = this.dialogConfigWithPositions.graph;
    const clickedNode: IMiniQuestionaireWithPosition = graph[clickedNodeName];
    const sourceNode = this.selectedOption.node;
    // console.log("Reconnect");
    sourceNode.o[this.selectedOption.optionIndex].next = clickedNodeName;

    this.domHelper.showAnswerOptions(this.selectedNodeName, this.selectedNode);
  }

  isEqualOptionIdentifyer(identA: IOptionIdentifyer | null, identB: IOptionIdentifyer | null): boolean {
    if ((!identA && identB) || (identA && !identB)) {
      return false;
    }
    if (
      (typeof identA === "undefined" && typeof identB !== "undefined") ||
      (typeof identA !== "undefined" && typeof identB === "undefined")
    ) {
      return false;
    }
    if (identA === identB || (typeof identA === "undefined" && typeof identB === "undefined")) {
      return true;
    }
    return identA?.nodeName === identB?.nodeName && identA?.optionIndex === identB?.optionIndex;
  }

  renameGraphNode(oldName: string, newName: string): boolean {
    if (!this.dialogConfigWithPositions.graph.hasOwnProperty(oldName)) {
      console.warn("Warning: cannot rename node, because old name does not exist.", oldName);
      return false;
    }
    if (oldName === "intro") {
      console.warn("Warning: cannot rename node, because 'intro' must not be renamed'.");
      return false;
    }
    if (this.dialogConfigWithPositions.graph.hasOwnProperty(newName)) {
      console.warn("Warning: cannot rename node, because new name already exists.", newName);
      return false;
    }
    if (newName === oldName) {
      console.warn("Warning: cannot rename node, because old name and new name are the same.", oldName);
      return false;
    }
    const graphNode = this.dialogConfigWithPositions.graph[oldName];
    this.dialogConfigWithPositions.graph[newName] = graphNode;
    delete this.dialogConfigWithPositions.graph[oldName];

    // Update all references
    for (var nodeName in this.dialogConfigWithPositions.graph) {
      if (!this.dialogConfigWithPositions.graph.hasOwnProperty(nodeName)) {
        continue;
      }
      const tmpNode = this.dialogConfigWithPositions.graph[nodeName];
      for (var j = 0; j < tmpNode.o.length; j++) {
        if (tmpNode.o[j].next === oldName) {
          tmpNode.o[j].next = newName;
        }
      }
    }

    // Update local selected fields
    if (oldName === this.selectedNodeName) {
      this.selectedNodeName = newName;
      this.selectedNode = this.dialogConfigWithPositions.graph[newName];
    }

    this.pb.redraw();
    return true;
  }

  static ellipsify(text: string, maxLength: number): string {
    if (!text || text.length <= maxLength) {
      return text;
    }
    return `${text.substring(0, maxLength)}...`;
  }

  static fromObject(object: object): IDialogueConfig<IMiniQuestionaire> {
    // Must be of type object
    if (typeof object !== "object") {
      throw `Cannot convert non-objects to dialogue config: type is ${typeof object}.`;
    }

    // Must have a 'graph' member.
    if (!object.hasOwnProperty("graph")) {
      throw "Cannot convert object to dialogue config: object missing member `graph`.";
    }

    const graph = object["graph"];
    // Check if 'intro' is present?

    // All members must be of correct type
    for (var key in object) {
      if (!object.hasOwnProperty(key)) {
        continue;
      }
      const questionaire = object[key];
      if (typeof questionaire !== "object") {
        throw "Cannot converto bject to dialogue config: all graph members must be objects.";
      }

      // Check if 'q' (string) and 'o' (array) attributes are present?
    }

    return object as IDialogueConfig<IMiniQuestionaire>;
  }

  static removePositions(dialogueConfig: IDialogueConfig<IMiniQuestionaireWithPosition>): IDialogueConfig<IMiniQuestionaire> {
    const clone: IDialogueConfig<IMiniQuestionaire> = JSON.parse(JSON.stringify(dialogueConfig));
    for (var nodeName in clone.graph) {
      const node = clone.graph[nodeName];
      if (node.hasOwnProperty("editor")) {
        delete node["editor"];
      }
    }
    return clone;
  }

  private randomNodeKey(): string {
    const keys = Object.keys(this.dialogConfigWithPositions.graph);
    var count = keys.length;
    let key = "dia_" + count;
    while (this.dialogConfigWithPositions.graph.hasOwnProperty(key)) {
      key = "dia_" + count;
      count++;
    }
    return key;
  }
}
