import {
  isMacOS,
  isEditableElement,
  clamp,
  selectElementContent,
  smoothlyScrollToElement,
  setFavicon,
} from "../lib/utils";
import { getAttributeOrDefault } from "../lib/attribute";
import KeyBuffer from "./key_buffer";
import { globalPubSub } from "../lib/pub_sub";
import monaco from "../cell/live_editor/monaco";

/**
 * A hook managing the whole session.
 *
 * Serves as a coordinator handling all the global session events.
 * Note that each cell has its own hook, so that LV keeps track
 * of cells being added/removed from the DOM. We do however need
 * to communicate between this global hook and cells and for
 * that we use a simple local pubsub that the hooks subscribe to.
 *
 * Configuration:
 *
 *   * `data-autofocus-cell-id` - id of the cell that gets initial
 *     focus once the notebook is loaded
 *
 * ## Shortcuts
 *
 * This hook registers session shortcut handlers,
 * see `LivebookWeb.SessionLive.ShortcutsComponent`
 * for the complete list of available shortcuts.
 *
 * ## Navigation
 *
 * This hook handles focusing cells and moving the focus around,
 * this is done purely on the client side because it is event-intensive
 * and specific to this client only. The UI changes are handled by
 * setting `data-js-*` attributes and using CSS accordingly (see assets/css/js_interop.css).
 * Navigation changes are also broadcasted to all cell hooks via PubSub.
 *
 * ## Location tracking and following
 *
 * Location describes where the given client is within
 * the notebook (in which cell, and where specifically in that cell).
 * When multiple clients are connected, they report own location to
 * each other whenever it changes. We then each the location to show
 * cursor and selection indicators.
 *
 * Additionally the current user may follow another client from the clients list.
 * In such case, whenever a new location comes from that client we move there automatically
 * (i.e. we focus the same cells to effectively mimic how the followed client moves around).
 *
 * Initially we load basic information about connected clients using `"session_init"`
 * and then update this information whenever clients join/leave/update.
 * This way location reports include only client pid, as we already have
 * the necessary hex_color/name locally.
 */
const Session = {
  mounted() {
    this.props = getProps(this);
    this.state = {
      focusedCellId: null,
      focusedSectionId: null,
      focusedCellType: null,
      insertMode: false,
      keyBuffer: new KeyBuffer(),
      clientsMap: {},
      lastLocationReportByClientPid: {},
      followedClientPid: null,
    };

    // Set initial favicon based on the current status

    setFavicon(faviconForEvaluationStatus(this.props.globalStatus));

    // Load initial data

    this.pushEvent("session_init", {}, ({ clients }) => {
      clients.forEach((client) => {
        this.state.clientsMap[client.pid] = client;
      });
    });

    // DOM events

    this.handleDocumentKeyDown = (event) => {
      handleDocumentKeyDown(this, event);
    };

    document.addEventListener("keydown", this.handleDocumentKeyDown, true);

    this.handleDocumentMouseDown = (event) => {
      handleDocumentMouseDown(this, event);
    };

    document.addEventListener("mousedown", this.handleDocumentMouseDown);

    this.handleDocumentDoubleClick = (event) => {
      handleDocumentDoubleClick(this, event);
    };

    document.addEventListener("dblclick", this.handleDocumentDoubleClick);

    getSectionsList().addEventListener("click", (event) => {
      handleSectionsListClick(this, event);
      handleCellIndicatorsClick(this, event);
    });

    getClientsList().addEventListener("click", (event) => {
      handleClientsListClick(this, event);
    });

    getSectionsListToggle().addEventListener("click", (event) => {
      toggleSectionsList(this);
    });

    getClientsListToggle().addEventListener("click", (event) => {
      toggleClientsList(this);
    });

    getRuntimeInfoToggle().addEventListener("click", (event) => {
      toggleRuntimeInfo(this);
    });

    getNotebook().addEventListener("scroll", (event) => {
      updateSectionListHighlight();
    });

    getCellIndicators().addEventListener("click", (event) => {
      handleCellIndicatorsClick(this, event);
    });

    window.addEventListener(
      "phx:page-loading-stop",
      () => {
        initializeFocus(this);
      },
      { once: true }
    );

    // DOM setup

    updateSectionListHighlight();
    focusNotebookNameIfNew();

    // Server events

    this.handleEvent("cell_inserted", ({ cell_id: cellId }) => {
      handleCellInserted(this, cellId);
    });

    this.handleEvent(
      "cell_deleted",
      ({ cell_id: cellId, sibling_cell_id: siblingCellId }) => {
        handleCellDeleted(this, cellId, siblingCellId);
      }
    );

    this.handleEvent("cell_restored", ({ cell_id: cellId }) => {
      handleCellRestored(this, cellId);
    });

    this.handleEvent("cell_moved", ({ cell_id }) => {
      handleCellMoved(this, cell_id);
    });

    this.handleEvent("section_inserted", ({ section_id }) => {
      handleSectionInserted(this, section_id);
    });

    this.handleEvent("section_deleted", ({ section_id }) => {
      handleSectionDeleted(this, section_id);
    });

    this.handleEvent("section_moved", ({ section_id }) => {
      handleSectionMoved(this, section_id);
    });

    this.handleEvent("cell_upload", ({ cell_id, url }) => {
      handleCellUpload(this, cell_id, url);
    });

    this.handleEvent("client_joined", ({ client }) => {
      handleClientJoined(this, client);
    });

    this.handleEvent("client_left", ({ client_pid }) => {
      handleClientLeft(this, client_pid);
    });

    this.handleEvent("clients_updated", ({ clients }) => {
      handleClientsUpdated(this, clients);
    });

    this.handleEvent(
      "location_report",
      ({ client_pid, cell_id, selection }) => {
        const report = {
          cellId: cell_id,
          selection: decodeSelection(selection),
        };

        handleLocationReport(this, client_pid, report);
      }
    );

    this._unsubscribeFromSessionEvents = globalPubSub.subscribe(
      "session",
      (event) => {
        handleSessionEvent(this, event);
      }
    );
  },

  updated() {
    const prevProps = this.props;
    this.props = getProps(this);

    if (this.props.globalStatus !== prevProps.globalStatus) {
      setFavicon(faviconForEvaluationStatus(this.props.globalStatus));
    }
  },

  destroyed() {
    this._unsubscribeFromSessionEvents();

    document.removeEventListener("keydown", this.handleDocumentKeyDown);
    document.removeEventListener("mousedown", this.handleDocumentMouseDown);
    document.removeEventListener("dblclick", this.handleDocumentDoubleClick);

    setFavicon("favicon");
  },
};

function getProps(hook) {
  return {
    autofocusCellId: getAttributeOrDefault(
      hook.el,
      "data-autofocus-cell-id",
      null
    ),
    globalStatus: getAttributeOrDefault(hook.el, "data-global-status", null),
  };
}

function faviconForEvaluationStatus(evaluationStatus) {
  if (evaluationStatus === "evaluating") return "favicon-evaluating";
  if (evaluationStatus === "stale") return "favicon-stale";
  return "favicon";
}

/**
 * Data of a specific LV client.
 *
 * @typedef Client
 * @type {Object}
 * @property {String} pid
 * @property {String} hex_color
 * @property {String} name
 */

/**
 * A report of the current location sent by one of the other clients.
 *
 * @typedef LocationReport
 * @type {Object}
 * @property {String|null} cellId
 * @property {monaco.Selection|null} selection
 */

// DOM event handlers

/**
 * Handles session keybindings.
 *
 * Make sure to keep the shortcuts help modal up to date.
 */
function handleDocumentKeyDown(hook, event) {
  if (event.repeat) {
    return;
  }

  const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
  const alt = event.altKey;
  const key = event.key;
  const keyBuffer = hook.state.keyBuffer;

  if (hook.state.insertMode) {
    keyBuffer.reset();

    if (key === "Escape") {
      const monacoInputOpen = !!event.target.closest(".monaco-inputbox");

      const activeDescendant = event.target.getAttribute(
        "aria-activedescendant"
      );
      const completionBoxOpen =
        activeDescendant && activeDescendant.includes("suggest");

      // Ignore Escape if it's supposed to close some Monaco input
      // (like the find/replace box), or the completion box.
      if (!monacoInputOpen && !completionBoxOpen) {
        escapeInsertMode(hook);
      }
    } else if (cmd && key === "Enter" && !alt) {
      cancelEvent(event);
      if (hook.state.focusedCellType === "elixir") {
        queueFocusedCellEvaluation(hook);
      }
    } else if (cmd && key === "s") {
      cancelEvent(event);
      saveNotebook(hook);
    }
  } else {
    // Ignore inputs and notebook/section title fields
    if (isEditableElement(event.target)) {
      keyBuffer.reset();
      return;
    }

    keyBuffer.push(event.key);

    if (cmd && key === "s") {
      cancelEvent(event);
      saveNotebook(hook);
    } else if (keyBuffer.tryMatch(["d", "d"])) {
      deleteFocusedCell(hook);
    } else if (keyBuffer.tryMatch(["e", "e"]) || (cmd && key === "Enter")) {
      if (hook.state.focusedCellType === "elixir") {
        queueFocusedCellEvaluation(hook);
      }
    } else if (keyBuffer.tryMatch(["e", "a"])) {
      queueAllCellsEvaluation(hook);
    } else if (keyBuffer.tryMatch(["e", "s"])) {
      queueFocusedSectionEvaluation(hook);
    } else if (keyBuffer.tryMatch(["s", "s"])) {
      toggleSectionsList(hook);
    } else if (keyBuffer.tryMatch(["s", "u"])) {
      toggleClientsList(hook);
    } else if (keyBuffer.tryMatch(["s", "r"])) {
      toggleRuntimeInfo(hook);
    } else if (keyBuffer.tryMatch(["s", "b"])) {
      showBin(hook);
    } else if (keyBuffer.tryMatch(["e", "x"])) {
      cancelFocusedCellEvaluation(hook);
    } else if (keyBuffer.tryMatch(["0", "0"])) {
      restartRuntime(hook);
    } else if (keyBuffer.tryMatch(["?"])) {
      showShortcuts(hook);
    } else if (
      keyBuffer.tryMatch(["i"]) ||
      (event.target === document.body &&
        hook.state.focusedCellId &&
        key === "Enter")
    ) {
      cancelEvent(event);
      enterInsertMode(hook);
    } else if (keyBuffer.tryMatch(["j"])) {
      moveCellFocus(hook, 1);
    } else if (keyBuffer.tryMatch(["k"])) {
      moveCellFocus(hook, -1);
    } else if (keyBuffer.tryMatch(["J"])) {
      moveFocusedCell(hook, 1);
    } else if (keyBuffer.tryMatch(["K"])) {
      moveFocusedCell(hook, -1);
    } else if (keyBuffer.tryMatch(["n"])) {
      insertCellBelowFocused(hook, "elixir");
    } else if (keyBuffer.tryMatch(["N"])) {
      insertCellAboveFocused(hook, "elixir");
    } else if (keyBuffer.tryMatch(["m"])) {
      insertCellBelowFocused(hook, "markdown");
    } else if (keyBuffer.tryMatch(["M"])) {
      insertCellAboveFocused(hook, "markdown");
    }
  }
}

/**
 * Focuses/blurs a cell when the user clicks somewhere.
 *
 * Note: we use mousedown event to more reliably focus editor
 * (e.g. if the user starts selecting some text within the editor)
 */
function handleDocumentMouseDown(hook, event) {
  // If the click is outside the notebook element, keep the focus as is
  if (!event.target.closest(`[data-element="notebook"]`)) {
    if (hook.state.insertMode) {
      setInsertMode(hook, false);
    }
    return;
  }

  // If the pencil icon is clicked, enter insert mode
  if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) {
    setInsertMode(hook, true);
    return;
  }

  // If a cell action is clicked, keep the focus as is
  if (event.target.closest(`[data-element="actions"]`)) {
    return;
  }

  // Find the cell element, if one was clicked
  const cell = event.target.closest(`[data-element="cell"]`);
  const cellId = cell ? cell.dataset.cellId : null;
  const insertMode = editableElementClicked(event, cell);

  if (cellId !== hook.state.focusedCellId) {
    setFocusedCell(hook, cellId, false);
  }

  // Depending on whether the click targets editor disable/enable insert mode
  if (hook.state.insertMode !== insertMode) {
    setInsertMode(hook, insertMode);
  }
}

function editableElementClicked(event, cell) {
  if (cell) {
    const editorContainer = cell.querySelector(
      `[data-element="editor-container"]`
    );
    const input = cell.querySelector(`[data-element="input"]`);
    const editableElement = editorContainer || input;

    return editableElement.contains(event.target);
  }

  return false;
}

/**
 * Enters insert mode when a markdown cell is double-clicked.
 */
function handleDocumentDoubleClick(hook, event) {
  const markdownCell = event.target.closest(
    `[data-element="cell"][data-type="markdown"]`
  );

  if (markdownCell && hook.state.focusedCellId && !hook.state.insertMode) {
    setInsertMode(hook, true);
  }
}

/**
 * Handles section link clicks in the section list.
 */
function handleSectionsListClick(hook, event) {
  const sectionButton = event.target.closest(
    `[data-element="sections-list-item"]`
  );
  if (sectionButton) {
    const sectionId = sectionButton.getAttribute("data-section-id");
    const section = getSectionById(sectionId);
    section.scrollIntoView({ behavior: "smooth", block: "start" });
  }
}

/**
 * Handles client link clicks in the clients list.
 */
function handleClientsListClick(hook, event) {
  const clientListItem = event.target.closest(
    `[data-element="clients-list-item"]`
  );

  if (clientListItem) {
    const clientPid = clientListItem.getAttribute("data-client-pid");

    const clientLink = event.target.closest(`[data-element="client-link"]`);
    if (clientLink) {
      handleClientLinkClick(hook, clientPid);
    }

    const clientFollowToggle = event.target.closest(
      `[data-element="client-follow-toggle"]`
    );
    if (clientFollowToggle) {
      handleClientFollowToggleClick(hook, clientPid, clientListItem);
    }
  }
}

function handleClientLinkClick(hook, clientPid) {
  mirrorClientFocus(hook, clientPid);
}

function handleClientFollowToggleClick(hook, clientPid, clientListItem) {
  const followedClientListItem = document.querySelector(
    `[data-element="clients-list-item"][data-js-followed]`
  );

  if (followedClientListItem) {
    followedClientListItem.removeAttribute("data-js-followed");
  }

  if (clientPid === hook.state.followedClientPid) {
    hook.state.followedClientPid = null;
  } else {
    clientListItem.setAttribute("data-js-followed", "true");
    hook.state.followedClientPid = clientPid;
    mirrorClientFocus(hook, clientPid);
  }
}

function mirrorClientFocus(hook, clientPid) {
  const locationReport = hook.state.lastLocationReportByClientPid[clientPid];

  if (locationReport && locationReport.cellId) {
    setFocusedCell(hook, locationReport.cellId);
  }
}

/**
 * Handles button clicks within cell indicators section.
 */
function handleCellIndicatorsClick(hook, event) {
  const button = event.target.closest(`[data-element="focus-cell-button"]`);
  if (button) {
    const cellId = button.getAttribute("data-target");
    setFocusedCell(hook, cellId);
  }
}

/**
 * Focuses cell or any other element based on the current
 * URL and hook attributes.
 */
function initializeFocus(hook) {
  const hash = window.location.hash;

  if (hash) {
    if (hash.startsWith("#cell-")) {
      const cellId = hash.replace(/^#cell-/, "");
      if (getCellById(cellId)) {
        setFocusedCell(hook, cellId);
      }
    } else {
      // Explicitly scroll to the target element
      // after the loading finishes
      const htmlId = hash.replace(/^#/, "");
      const element = document.getElementById(htmlId);
      if (element) {
        element.scrollIntoView();
      }
    }
  } else if (hook.props.autofocusCellId) {
    setFocusedCell(hook, hook.props.autofocusCellId, false);
    setInsertMode(hook, true);
  }
}

/**
 * Handles the main notebook area being scrolled.
 */
function updateSectionListHighlight() {
  const currentListItem = document.querySelector(
    `[data-element="sections-list-item"][data-js-is-viewed]`
  );

  if (currentListItem) {
    currentListItem.removeAttribute("data-js-is-viewed");
  }

  // Consider a section being viewed if it is within the top 35% of the screen
  const viewedSection = getSections()
    .reverse()
    .find((section) => {
      const { top } = section.getBoundingClientRect();
      const scrollTop = document.documentElement.scrollTop;
      return top <= scrollTop + window.innerHeight * 0.35;
    });

  if (viewedSection) {
    const sectionId = viewedSection.getAttribute("data-section-id");
    const listItem = document.querySelector(
      `[data-element="sections-list-item"][data-section-id="${sectionId}"]`
    );
    listItem.setAttribute("data-js-is-viewed", "true");
  }
}

// User action handlers (mostly keybindings)

function toggleSectionsList(hook) {
  toggleSidePanelContent(hook, "sections-list");
}

function toggleClientsList(hook) {
  toggleSidePanelContent(hook, "clients-list");
}

function toggleRuntimeInfo(hook) {
  toggleSidePanelContent(hook, "runtime-info");
}

function toggleSidePanelContent(hook, name) {
  if (hook.el.getAttribute("data-js-side-panel-content") === name) {
    hook.el.removeAttribute("data-js-side-panel-content");
  } else {
    hook.el.setAttribute("data-js-side-panel-content", name);
  }
}

function showBin(hook) {
  hook.pushEvent("show_bin", {});
}

function saveNotebook(hook) {
  hook.pushEvent("save", {});
}

function deleteFocusedCell(hook) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("delete_cell", { cell_id: hook.state.focusedCellId });
  }
}

function queueFocusedCellEvaluation(hook) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("queue_cell_evaluation", {
      cell_id: hook.state.focusedCellId,
    });
  }
}

function queueAllCellsEvaluation(hook) {
  hook.pushEvent("queue_all_cells_evaluation", {});
}

function queueFocusedSectionEvaluation(hook) {
  if (hook.state.focusedSectionId) {
    hook.pushEvent("queue_section_cells_evaluation", {
      section_id: hook.state.focusedSectionId,
    });
  }
}

function cancelFocusedCellEvaluation(hook) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("cancel_cell_evaluation", {
      cell_id: hook.state.focusedCellId,
    });
  }
}

function restartRuntime(hook) {
  hook.pushEvent("restart_runtime", {});
}

function showShortcuts(hook) {
  hook.pushEvent("show_shortcuts", {});
}

function enterInsertMode(hook) {
  if (hook.state.focusedCellId) {
    setInsertMode(hook, true);
  }
}

function escapeInsertMode(hook) {
  setInsertMode(hook, false);
}

function moveCellFocus(hook, offset) {
  const cellId = nearbyCellId(hook.state.focusedCellId, offset);
  setFocusedCell(hook, cellId);
}

function moveFocusedCell(hook, offset) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("move_cell", { cell_id: hook.state.focusedCellId, offset });
  }
}

function insertCellBelowFocused(hook, type) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("insert_cell_below", {
      cell_id: hook.state.focusedCellId,
      type,
    });
  } else {
    // If no cell is focused, insert below the last cell
    const cellIds = getCellIds();
    if (cellIds.length > 0) {
      const lastCellId = cellIds[cellIds.length - 1];
      hook.pushEvent("insert_cell_below", { cell_id: lastCellId, type });
    } else {
      insertFirstCell(hook, type);
    }
  }
}

function insertCellAboveFocused(hook, type) {
  if (hook.state.focusedCellId) {
    hook.pushEvent("insert_cell_above", {
      cell_id: hook.state.focusedCellId,
      type,
    });
  } else {
    // If no cell is focused, insert above the first cell
    const cellIds = getCellIds();
    if (cellIds.length > 0) {
      const lastCellId = cellIds[0];
      hook.pushEvent("insert_cell_above", { cell_id: lastCellId, type });
    } else {
      insertFirstCell(hook, type);
    }
  }
}

function insertFirstCell(hook, type) {
  const sectionIds = getSectionIds();

  if (sectionIds.length > 0) {
    hook.pushEvent("insert_cell", {
      section_id: sectionIds[0],
      index: 0,
      type,
    });
  }
}

function setFocusedCell(hook, cellId, scroll = true) {
  hook.state.focusedCellId = cellId;

  if (hook.state.focusedCellId) {
    const cell = getCellById(hook.state.focusedCellId);
    hook.state.focusedCellType = cell.getAttribute("data-type");
    hook.state.focusedSectionId = getSectionIdByCellId(
      hook.state.focusedCellId
    );
    // Focus the primary cell content, this is important for screen readers
    const cellBody = cell.querySelector(`[data-element="cell-body"]`);
    cellBody.focus({ preventScroll: true });
  } else {
    hook.state.focusedCellType = null;
    hook.state.focusedSectionId = null;
  }

  globalPubSub.broadcast("cells", { type: "cell_focused", cellId, scroll });

  setInsertMode(hook, false);
}

function setInsertMode(hook, insertModeEnabled) {
  hook.state.insertMode = insertModeEnabled;

  if (insertModeEnabled) {
    hook.el.setAttribute("data-js-insert-mode", "true");
  } else {
    hook.el.removeAttribute("data-js-insert-mode");

    sendLocationReport(hook, {
      cellId: hook.state.focusedCellId,
      selection: null,
    });
  }

  globalPubSub.broadcast("cells", {
    type: "insert_mode_changed",
    enabled: insertModeEnabled,
  });
}

// Server event handlers

function handleCellInserted(hook, cellId) {
  setFocusedCell(hook, cellId);
  if (["markdown", "elixir"].includes(hook.state.focusedCellType)) {
    setInsertMode(hook, true);
  }
}

function handleCellDeleted(hook, cellId, siblingCellId) {
  if (hook.state.focusedCellId === cellId) {
    setFocusedCell(hook, siblingCellId);
  }
}

function handleCellRestored(hook, cellId) {
  setFocusedCell(hook, cellId);
}

function handleCellMoved(hook, cellId) {
  if (hook.state.focusedCellId === cellId) {
    globalPubSub.broadcast("cells", { type: "cell_moved", cellId });

    // The cell may have moved to another section, so update this information.
    hook.state.focusedSectionId = getSectionIdByCellId(
      hook.state.focusedCellId
    );
  }
}

function handleSectionInserted(hook, sectionId) {
  if (hook.state.focusedSectionId) {
    setFocusedCell(hook, null);
  }

  const section = getSectionById(sectionId);
  const nameElement = section.querySelector(`[data-element="section-name"]`);
  nameElement.focus({ preventScroll: true });
  selectElementContent(nameElement);
  smoothlyScrollToElement(nameElement);
}

function handleSectionDeleted(hook, sectionId) {
  if (hook.state.focusedSectionId === sectionId) {
    setFocusedCell(hook, null);
  }
}

function handleSectionMoved(hook, sectionId) {
  const section = getSectionById(sectionId);
  smoothlyScrollToElement(section);
}

function handleCellUpload(hook, cellId, url) {
  if (hook.state.focusedCellId !== cellId) {
    setFocusedCell(hook, cellId);
  }

  if (!hook.state.insertMode) {
    setInsertMode(hook, true);
  }

  globalPubSub.broadcast("cells", { type: "cell_upload", cellId, url });
}

function handleClientJoined(hook, client) {
  hook.state.clientsMap[client.pid] = client;
}

function handleClientLeft(hook, clientPid) {
  const client = hook.state.clientsMap[clientPid];

  if (client) {
    delete hook.state.clientsMap[clientPid];

    broadcastLocationReport(client, { cellId: null, selection: null });

    if (client.pid === hook.state.followedClientPid) {
      hook.state.followedClientPid = null;
    }
  }
}

function handleClientsUpdated(hook, updatedClients) {
  updatedClients.forEach((client) => {
    hook.state.clientsMap[client.pid] = client;
  });
}

function handleLocationReport(hook, clientPid, report) {
  const client = hook.state.clientsMap[clientPid];

  hook.state.lastLocationReportByClientPid[clientPid] = report;

  if (client) {
    broadcastLocationReport(client, report);

    if (
      client.pid === hook.state.followedClientPid &&
      report.cellId !== hook.state.focusedCellId
    ) {
      setFocusedCell(hook, report.cellId);
    }
  }
}

function focusNotebookNameIfNew() {
  const sections = getSections();
  const nameElement = document.querySelector(`[data-element="notebook-name"]`);

  if (sections.length === 0 && nameElement.innerText === "Untitled notebook") {
    nameElement.focus();
    selectElementContent(nameElement);
  }
}

// Session event handlers

function handleSessionEvent(hook, event) {
  if (event.type === "cursor_selection_changed") {
    sendLocationReport(hook, {
      cellId: event.cellId,
      selection: event.selection,
    });
  }
}

/**
 * Broadcast new location report coming from the server to all the cells.
 */
function broadcastLocationReport(client, report) {
  globalPubSub.broadcast("cells", {
    type: "location_report",
    client,
    report,
  });
}

/**
 * Sends local location report to the server.
 */
function sendLocationReport(hook, report) {
  const numberOfClients = Object.keys(hook.state.clientsMap).length;

  // Only send reports if there are other people to send to
  if (numberOfClients > 1) {
    hook.pushEvent("location_report", {
      cell_id: report.cellId,
      selection: encodeSelection(report.selection),
    });
  }
}

function encodeSelection(selection) {
  if (selection === null) return null;

  return [
    selection.selectionStartLineNumber,
    selection.selectionStartColumn,
    selection.positionLineNumber,
    selection.positionColumn,
  ];
}

function decodeSelection(encoded) {
  if (encoded === null) return null;

  const [
    selectionStartLineNumber,
    selectionStartColumn,
    positionLineNumber,
    positionColumn,
  ] = encoded;

  return new monaco.Selection(
    selectionStartLineNumber,
    selectionStartColumn,
    positionLineNumber,
    positionColumn
  );
}

// Helpers

function nearbyCellId(cellId, offset) {
  const cellIds = getCellIds();

  if (cellIds.length === 0) {
    return null;
  }

  const idx = cellIds.indexOf(cellId);

  if (idx === -1) {
    return cellIds[0];
  } else {
    const siblingIdx = clamp(idx + offset, 0, cellIds.length - 1);
    return cellIds[siblingIdx];
  }
}

function getCellIds() {
  const cells = getCells();
  return cells.map((cell) => cell.getAttribute("data-cell-id"));
}

function getCells() {
  return Array.from(document.querySelectorAll(`[data-element="cell"]`));
}

function getCellById(cellId) {
  return document.querySelector(
    `[data-element="cell"][data-cell-id="${cellId}"]`
  );
}

function getSectionIdByCellId(cellId) {
  const cell = document.querySelector(
    `[data-element="cell"][data-cell-id="${cellId}"]`
  );
  const section = cell.closest(`[data-element="section"]`);
  return section.getAttribute("data-section-id");
}

function getSectionIds() {
  const sections = getSections();
  return sections.map((section) => section.getAttribute("data-section-id"));
}

function getSections() {
  return Array.from(document.querySelectorAll(`[data-element="section"]`));
}

function getSectionById(sectionId) {
  return document.querySelector(
    `[data-element="section"][data-section-id="${sectionId}"]`
  );
}

function getSectionsList() {
  return document.querySelector(`[data-element="sections-list"]`);
}

function getClientsList() {
  return document.querySelector(`[data-element="clients-list"]`);
}

function getCellIndicators() {
  return document.querySelector(`[data-element="notebook-indicators"]`);
}

function getNotebook() {
  return document.querySelector(`[data-element="notebook"]`);
}

function getSectionsListToggle() {
  return document.querySelector(`[data-element="sections-list-toggle"]`);
}

function getClientsListToggle() {
  return document.querySelector(`[data-element="clients-list-toggle"]`);
}

function getRuntimeInfoToggle() {
  return document.querySelector(`[data-element="runtime-info-toggle"]`);
}

function cancelEvent(event) {
  // Cancel any default browser behavior.
  event.preventDefault();
  // Stop event propagation (e.g. so it doesn't reach the editor).
  event.stopPropagation();
}

export default Session;
