// Build this plugin with 'browserify':
//   browserify versioning.js > versioning.browser.js

Draw.loadPlugin(async function(ui) {

  const {Context, History, uuidv4} = require("doh");
  const {UserColors} = require("./UserColors.js")
  const {DragHandler} = require("./DragHandler.js");
  const {GhostOverlays} = require("./GhostOverlays.js");
  const {Controller: StatechartController} = require("./client_compiled.js");
  const {JsEventLoop} = require("statecharts_core");

  const myId = uuidv4();

  const names = ["Billy", "James", "Robert", "John", "Mary", "Patricia", "Jennifer", "Barbara"];
  let myName = window.localStorage.getItem('drawio-username');
  if (!myName) {
    myName = names[Math.floor(Math.random()*names.length)];
  }

  const graph = ui.editor.graph;
  const model = graph.model;

  // Interactive debugging
  window.ui = ui;
  window.graph = graph;
  window.model = model;

  const userColors = new UserColors();

  class EnabledState {
    constructor() {
      this.enabled = false;
    }
    enable() {
      this.enabled = true;
    }
    disable() {
      this.enabled = false;
    }
  }

  function workaroundPluginParameter(location) {
    // If the 'p' URL search parameter contained a ';' character, this will be (correctly) escaped.
    // However, drawio expects an actual ';' character (which is probably illegal in a search param value),
    // so we apply the following workaround:
    return location.replace('%3B', ';');
  }

  class UIState {
    constructor() {
      this.statusTextNode = document.createTextNode("");
      this.shareEnabled = new EnabledState();
      this.joinEnabled = new EnabledState();
      this.leaveEnabled = new EnabledState();
    }

    install(ui) {
      ui.toolbar.addSeparator();
      ui.menus.put('collab', new Menu((menu, parent) => {
        const buttonShare = menu.addItem("Share this diagram",
          null, // image
          () => {
            const ops = historyWrapper.history.getOpsSequence();
            const serialized = ops.map(op => op.serialize());
            controller.addInput("new_share", "in", [serialized], controller.wallclockToSimtime());
          }, // callback
          menu,
          null, // ?
          this.shareEnabled.enabled); // enabled
        const buttonJoin = menu.addItem("Join session",
          null, // image
          () => {
            const sessionId = window.prompt("Enter session ID:");
            if (sessionId) {
              controller.addInput("join", "in", [sessionId], controller.wallclockToSimtime());
            }
          }, // callback
          menu,
          null, // ?
          this.joinEnabled.enabled); // enabled
        const buttonLeave = menu.addItem("Leave session",
          null, // image
          () => {
            controller.addInput("leave", "in", [], controller.wallclockToSimtime());
          }, // callback
          menu,
          null, // ?
          this.leaveEnabled.enabled); // enabled
      }));

      const screenshareMenu = ui.toolbar.addMenu('', "Collaboration", true, 'collab');
      screenshareMenu.style.width = '100px';
      screenshareMenu.showDisabled = true;
      screenshareMenu.style.whiteSpace = 'nowrap';
      screenshareMenu.style.position = 'relative';
      screenshareMenu.style.overflow = 'hidden';
      screenshareMenu.innerHTML = "Collaboration" + ui.toolbar.dropdownImageHtml;

      ui.toolbar.addSeparator();

      const statusEl = document.createElement('div');
      statusEl.classList.add("geLabel");
      statusEl.style = "white-space: nowrap; position: relative;";
      statusEl.appendChild(this.statusTextNode);
      ui.toolbar.container.appendChild(statusEl);
    }

    setSession(sessionId) {
      const searchParams = new URLSearchParams(window.location.search);
      searchParams.set('sessionId', sessionId);
      const newLocation = window.location.origin + window.location.pathname + '?' + workaroundPluginParameter(searchParams.toString()) + window.location.hash;
      window.history.replaceState({}, "", newLocation);
    }

    unsetSession(sessionId) {
      const searchParams = new URLSearchParams(window.location.search);
      searchParams.delete('sessionId');
      const newLocation = window.location.origin + window.location.pathname + '?' + workaroundPluginParameter(searchParams.toString()) + window.location.hash;
      window.history.replaceState({}, "", newLocation);
    }

    setOfflineDisconnected() {
      graph.setEnabled(true);
      dragHandler.setOnline(false);
      this.statusTextNode.textContent = "Offline (no server connection)";

      this.shareEnabled.disable();
      this.joinEnabled.disable();
      this.leaveEnabled.disable();
    }

    setOfflineConnected() {
      graph.setEnabled(true);
      dragHandler.setOnline(false);
      this.statusTextNode.textContent = "Offline";

      this.shareEnabled.enable();
      this.joinEnabled.enable();
      this.leaveEnabled.disable();
    }

    setJoining(sessionId) {
      graph.setEnabled(false);
      this.statusTextNode.textContent = "Joining session...";

      this.shareEnabled.disable();
      this.joinEnabled.disable();
      this.leaveEnabled.enable();
    }

    setCreatingShare() {
      graph.setEnabled(false);
      this.statusTextNode.textContent = "Creating share...";

      this.shareEnabled.disable();
      this.joinEnabled.disable();
      this.leaveEnabled.enable();
    }

    setOnline(sessionId) {
      graph.setEnabled(true);
      dragHandler.setOnline(true);
      this.statusTextNode.textContent = "Online";

      this.shareEnabled.disable();
      this.joinEnabled.disable();
      this.leaveEnabled.enable();

    }

    setReconnecting() {
      graph.setEnabled(false);
      this.statusTextNode.textContent = "(re)Connecting...";

      this.shareEnabled.disable();
      this.joinEnabled.disable();
      this.leaveEnabled.enable();
    }
  }

  const uiState = new UIState();

  const codec = new mxCodec();
  const xmlSerializer = new XMLSerializer();
  const xmlParser = new DOMParser();

  function getCell(cellId) {
    let cell = model.cells[cellId];
    if (cell === undefined) {
      // throw new Error("NO SUCH CELL:", cellId);
    }
    return cell;
  }

  function createCell(cellId, isVertex, isEdge) {
    cell = new mxCell("");
    cell.setId(cellId);
    cell.setVertex(isVertex);
    cell.setEdge(isEdge);
    model.cells[cellId] = cell; // HACK!
    return cell;
  }

  const setState = (key, value) => {
    // console.log("setState", key, value);
    if (key === "root") {
      // the only global 'key'

      const rootId = value;
      const root = getCell(rootId) || createCell(rootId, false, false);
      model.setRoot(root);
    }
    else {
      // key is concatenation: <cellId> + '_' + <attribute>
      const splitPoint = key.lastIndexOf('_');
      const attribute = key.substring(splitPoint+1);
      const cellId = key.substring(0, splitPoint-2);
      const isVertex = key[splitPoint-1] === 'V';
      const isEdge = key[splitPoint-1] === 'E';

      const cell = getCell(cellId) || createCell(cellId, isVertex, isEdge);

      const attrHandlers = {
        geometry: () => {
          new mxGeometryChange(model, )
          const serializedGeometry = value;
          const parsed = xmlParser.parseFromString(serializedGeometry, 'text/xml');
          const geometry = codec.decode(parsed.documentElement);
          model.setGeometry(cell, geometry);
        },
        style: () => {
          const style = value;
          model.setStyle(cell, style);
        },
        parent: () => {
          if (value) {
            const {parentId} = value;
            let parent = getCell(parentId);
            if (parent === undefined) {
              console.log("creating non-existing parent");
              const {isVertex, isEdge} = value;
              parent = createCell(parentId, isVertex, isEdge);
            }
            // Creates a mxChildChange object
            model.add(parent, cell, null);
          } else {
            // Creates a mxChildChange object
            model.remove(cell);
          }
        },
        value: () => {
          let v;
          if (typeof value === 'object') {
            const {xmlEncoded} = value;
            v = xmlParser.parseFromString(xmlEncoded, 'text/xml').documentElement;
          } else {
            v = value;
          }
          model.setValue(cell, v);
        },
        source: () => {
          const sourceId = value;
          if (sourceId === null) {
            model.setTerminal(cell, null, true);
          } else {
            const source = getCell(sourceId)
              // if in the same user operation, a vertex is created and an edge
              // is connected to that vertex, it is possible that the connection
              // is made before the vertex is created (operations within a single user
              // operation are currently unordered)
              || createCell(sourceId,
                            /* isVertex */ true,
                            /* isEdge */ false);
            if (source === undefined) {
              throw new Error("NO SUCH CELL:", cellId);
            }
            model.setTerminal(cell, source, true);
          }
        },
        target: () => {
          const targetId = value;
          if (targetId === null) {
            model.setTerminal(cell, null, false);
          } else {
            const target = getCell(targetId)
              || createCell(targetId,
                            /* isVertex */ true,
                            /* isEdge */ false);
            if (target === undefined) {
              throw new Error("NO SUCH CELL:", cellId);
            }
            model.setTerminal(cell, target, false);
          }
        },
        collapsed: () => {
          const collapsed = value;
          model.setCollapsed(cell, collapsed);
        },
        visible: () => {
          const visible = value;
          model.setVisible(cell, visible);
        },
        vertex: () => {
          const vertex = value;
          cell.setVertex(vertex);
        },
        edge: () => {
          const edge = value;
          cell.setEdge(edge);
        },
      };
      attrHandlers[attribute]();
    }
  };

  const resolve = (a, b) => {
    console.log("CONFLICT between", a.id, "and", b.id);
    // Cheap & fun way to define a total ordering on all elements:
    return a.id > b.id;
  };

  // Fired when a local change happens
  model.addListener(mxEvent.NOTIFY, function(sender, event) {
    if (historyWrapper.transactionDepth === 0) {
      console.log("NOTIFY:", event.properties.edit.changes);

      const delta = {};
      for (const change of event.properties.edit.changes) {
        changeToDelta(change, delta);
      }
      if (Object.keys(delta).length > 0) {
        const op = historyWrapper.history.new(delta, 
          false // do NOT update mxGraphModel with the change (the change was already executed)
        );
        const serializedOp = op.serialize();
        console.log("NEW LOCAL OP:", serializedOp);
        controller.addInput("new_edit", "in", [serializedOp], controller.wallclockToSimtime());
      }
    }
  });

  class HistoryWrapper {
    constructor(graph, context) {
      this.graph = graph;
      this.context = context;
      this.transactionDepth = 0;
    }

    newEmptyDiagram() {
      console.log("newEmptyDiagram")
      this.history = new History(this.context, setState, resolve);
      this.graph.model.clear(); // the resetting of the diagram will be recorded into the (blank) history
      this.mergePromise = Promise.resolve();
    }

    // returns Promise
    loadExistingDiagram(ops) {
      console.log("loadExistingDiagram")
      this.graph.setEnabled(false);
      this.transactionDepth++; // prevent us from re-recording any changes
      this.graph.model.clear();
      this.history = new History(this.context, setState, resolve);
      this.mergePromise = Promise.resolve();
      for (const op of ops) {
        this.queuedMerge(op);
      }
      return this.mergePromise.then(() => {
        this.graph.setEnabled(true);
        this.transactionDepth--;
      });
    }

    // returns Promise
    queuedMerge(serializedOp) {
      this.mergePromise = this.mergePromise.then(() => {
        return this.context.receiveOperation(serializedOp).then(op => {
          console.log("Merging ", op.id);
          try {
            this.transactionDepth++;

            this.graph.model.beginUpdate();
            this.history.autoMerge(op);
            this.graph.model.endUpdate();
          }
          catch (e) {
            e.op = op;
            throw e;
          }
          finally {
            this.transactionDepth--;
          }
        });
      });
      return this.mergePromise;
    }
  }

  function getMyName() {
    return myName;
  }

  const controller = new StatechartController(uiState, myId, getMyName, new JsEventLoop());

  const selectionCounter = new Map();
  const userSelection = new Map();

  // const handleMergeError = err => {
  //   console.error("Unexpected error merging:", err);
  //   controller.addInput("leave", "in", [], controller.wallclockToSimtime());
  // }

  controller.addMyOwnOutputListener({
    'add': event => {
      // console.log("output event:", event);
      if (event.name === "ack_join") {
        const [ops] = event.parameters;
        historyWrapper.loadExistingDiagram(ops);
      }
      else if (event.name === "ack_new_share") {
        // nothing needs to happen
        const [sessionId] = event.parameters;
      }
      else if (event.name === "broadcast") {
        const [sessionId, msg] = event.parameters;
        if (msg.type === "pub_edit") {
          const {op} = msg;
          historyWrapper.queuedMerge(op);
        }
        else if (msg.type === "update_cursor") {
          const {userId, name, x, y} = msg;
          ghostOverlays.put(userId, name, x, y);
        }
        else if (msg.type === "update_selection") {
          const {userId, selectedIds} = msg;
          const oldHighlights = userSelection.get(userId) || [];
          for (const highlight of oldHighlights) {
            highlight.destroy();
          }
          const color = userColors.getColor(userId);
          const newHighlights = selectedIds.map(cellId => {
            const cell = graph.model.cells[cellId];
            const highlight = new mxCellHighlight(graph, color, 6);
            highlight.highlight(graph.view.getState(cell));
            return highlight;
          });
          userSelection.set(userId, newHighlights);
        }
      }
    }
  });

  const dragHandler = new DragHandler(controller);
  dragHandler.install(graph);

  const ghostOverlays = new GhostOverlays(graph, userColors, "myPlugins/cursor.svg");

  graph.addMouseListener({
    mouseDown(graph, event) {
    },
    mouseMove(graph, event) {
      const x = event.graphX / graph.view.scale - graph.view.translate.x;
      const y = event.graphY / graph.view.scale - graph.view.translate.y;
      controller.addInput("broadcast_cursor", "in", [x,y], controller.wallclockToSimtime());
    },
    mouseUp(graph, event) {
    },
  });

  graph.selectionModel.addListener(mxEvent.CHANGE, (source, eventObj) => {
    console.log("selectionModel event CHANGE");
    // const addedIds = (eventObj.properties.removed || []).map(cell => cell.id);
    // const removedIds = (eventObj.properties.added || []).map(cell => cell.id);
    const selectedIds = graph.getSelectionCells().map(cell => cell.id);
    controller.addInput(
      "broadcast_selection",
      "in",
      [selectedIds],
      controller.wallclockToSimtime(),
    );
  });

  {
    // Upon loading the page, if a 'sessionId' URL parameter is given, automatically join the given session once the connection with the server is established.
    const searchParams = new URLSearchParams(window.location.search);
    const sessionId = searchParams.get('sessionId');
    if (sessionId) {
      controller.addInput("init_join", "in", [sessionId], 0);
    } else {
      controller.addInput("init_offline", "in", [], 0);
    }
  }

  let websocketAddress;
  if (window.location.protocol === "http:") {
    websocketAddress = "ws:";
  } else if (window.location.protocol === "https:") {
    websocketAddress = "wss:";
  } else {
    throw new Error("unexpected protocol: " + window.location.protocol);
  }
  websocketAddress += "//" + window.location.host + "/websocket?me=" + myId;

  controller.addInput("connect", "in", [websocketAddress], 0);

  controller.start();

  function keyPrefix(cell) {
    return cell.id + '_' + (cell.isVertex() ? 'V' : cell.isEdge() ? 'E' : 'X') + '_';
  }

  function encodeCompleteCellState(cell, deltaObj) {
    const prefix = keyPrefix(cell);

    if (cell.geometry) {
      deltaObj[prefix + 'geometry'] = xmlSerializer.serializeToString(
        codec.encode(
          cell.geometry));
    }
    if (cell.style) {
      deltaObj[prefix + 'style'] = cell.style;
    }
    if (cell.parent) {
      deltaObj[prefix + 'parent'] = {
        parentId: cell.parent.id,
        isVertex: cell.parent.isVertex(),
        isEdge: cell.parent.isEdge(),
      };
    }
    if (cell.value) {
      // For some reason, 'value' can be an HTML string (when only a label is defined, or it can be a DOM object
      if (cell.value.constructor === String) {
        deltaObj[prefix + 'value'] = cell.value;
      } else {
        deltaObj[prefix + 'value'] = {
          xmlEncoded: xmlSerializer.serializeToString(cell.value),
        };
      }
    }
    if (cell.source) {
      deltaObj[prefix + 'source'] = cell.source.id;
    }
    if (cell.target) {
      deltaObj[prefix + 'target'] = cell.target.id;
    }
    if (cell.collapsed) {
      deltaObj[prefix + 'collapsed'] = cell.collapsed;
    }
    if (cell.visible !== undefined) {
      deltaObj[prefix + 'visible'] = cell.visible;
    }
  }

  const changeToDelta = (change, deltaObj) => {
    if (change.constructor === mxRootChange) {

      if (change.root === change.previous) {
        // This doesn't seem to occur:
        console.log("no root change")
        return; // no change
      }
      deltaObj['root'] = change.root.id;

      for (const cell of Object.values(model.cells)) {
        encodeCompleteCellState(cell, deltaObj);
      }
    }
    else {
      if (change.constructor === mxGeometryChange) {
        console.log(change.cell.id, 'change geometry')
        deltaObj[keyPrefix(change.cell) + 'geometry'] = change.geometry ?
          xmlSerializer.serializeToString(
            codec.encode(change.geometry)) : null;
      }
      else if (change.constructor === mxStyleChange) {
        deltaObj[keyPrefix(change.cell) + 'style'] = change.style;
      }
      else if (change.constructor === mxChildChange) {
        console.log(change.child.id, 'change parent')
        if (change.previous && model.contains(change.previous)) {
          // cell had a previous parent
          console.log("previous parent");
          deltaObj[keyPrefix(change.child) + 'parent'] = change.parent ? {
            parentId: change.parent.id,
            isVertex: change.parent.isVertex(),
            isEdge: change.parent.isEdge(),
          } : null;
        } else {
          // no previous parent -> cell was created:
          console.log("no previous parent");
          function recursivelyEncode(cell) {
            encodeCompleteCellState(cell, deltaObj);
            if (cell.children !== null) {
              for (const child of cell.children) {
                recursivelyEncode(child);
              }
            }
          }
          recursivelyEncode(change.child);
        }
      }
      else if (change.constructor === mxValueChange) {
        if (change.value.constructor === String) {
          deltaObj[keyPrefix(change.cell) + 'value'] = change.value;
        } else {
          deltaObj[keyPrefix(change.cell) + 'value'] = {
            xmlEncoded: xmlSerializer.serializeToString(change.value),
          };
        }
      }
      else if (change.constructor === mxTerminalChange) {
        deltaObj[keyPrefix(change.cell) + (change.source ? 'source' : 'target')] =
          change.terminal ? change.terminal.id : null;
      }
      else if (change.constructor === mxCollapseChange) {
        deltaObj[keyPrefix(change.cell) + 'collapsed'] = change.collapsed;
      }
      else if (change.constructor === mxVisibleChange) {
        deltaObj[keyPrefix(change.cell) + 'visible'] = change.visible;
      }
      else if (change.constructor === mxCellAttributeChange) {
        // Mentioned in mxGraph documentation, but what is this, and does it actually occur?
        throw new Error("DEBUG: Don't know what to do with mxCellAttributeChange");
      }
    }
  }


  // UI stuff
  uiState.install(ui);


  const context = new Context(async id => {
    throw new Error("Fetch is forbidden. ", id)
  });
  const historyWrapper = new HistoryWrapper(graph, context);
  historyWrapper.newEmptyDiagram();


  // HACK: disable undo
  
  mxUndoManager.prototype.canUndo = function() {
    return false;
  };
  mxUndoManager.prototype.canRedo = function() {
    return false;
  };


  // For debugging
  document.addEventListener('keydown', e => {
  //   console.log(e);
    // if (e.code === 'KeyC') {
    //   console.log("KeyC pressed: Clear model and reset history");
    //   historyWrapper.reset();
    // }

    // if (e.code === 'KeyH') {
    //   const seq = historyWrapper.history.getOpsSequence();
    //   console.log(seq.map(op => op.serialize()));
    // }

    // if (e.code === 'KeyU') {
    //   const u = window.prompt("Choose a user name:", myName);
    //   if (u) {
    //     myName = u;
    //   }
    // }
  });
});