/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const L10N = new LocalizationHelper(
  "devtools/client/locales/inspector.properties"
);

const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
const beautify = require("resource://devtools/shared/jsbeautify/beautify.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");

const XHTML_NS = "http://www.w3.org/1999/xhtml";
const CONTAINER_WIDTH = 500;

const L10N_BUBBLING = L10N.getStr("eventsTooltip.Bubbling");
const L10N_CAPTURING = L10N.getStr("eventsTooltip.Capturing");

class EventTooltip extends EventEmitter {
  /**
   * Set the content of a provided HTMLTooltip instance to display a list of event
   * listeners, with their event type, capturing argument and a link to the code
   * of the event handler.
   *
   * @param {HTMLTooltip} tooltip
   *        The tooltip instance on which the event details content should be set
   * @param {Array} eventListenerInfos
   *        A list of event listeners
   * @param {Toolbox} toolbox
   *        Toolbox used to select debugger panel
   * @param {NodeFront} nodeFront
   *        The nodeFront we're displaying event listeners for.
   */
  constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
    super();

    this._tooltip = tooltip;
    this._toolbox = toolbox;
    this._eventEditors = new WeakMap();
    this._nodeFront = nodeFront;
    this._eventListenersAbortController = new AbortController();

    // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
    this._tooltip.eventTooltip = this;

    this._headerClicked = this._headerClicked.bind(this);
    this._eventToggleCheckboxChanged =
      this._eventToggleCheckboxChanged.bind(this);

    this._subscriptions = [];

    const config = {
      mode: Editor.modes.js,
      lineNumbers: false,
      lineWrapping: true,
      readOnly: true,
      styleActiveLine: true,
      extraKeys: {},
      theme: "mozilla markup-view",
      cm6: true,
    };

    const doc = this._tooltip.doc;
    this.container = doc.createElementNS(XHTML_NS, "ul");
    this.container.className = "devtools-tooltip-events-container";

    const sourceMapURLService = this._toolbox.sourceMapURLService;

    for (let i = 0; i < eventListenerInfos.length; i++) {
      const listener = eventListenerInfos[i];

      // Create this early so we can refer to it from a closure, below.
      const content = doc.createElementNS(XHTML_NS, "div");
      const codeMirrorContainerId = `cm-${i}`;
      content.id = codeMirrorContainerId;

      // Header
      const header = doc.createElementNS(XHTML_NS, "div");
      header.className = "event-header";
      header.setAttribute("data-event-type", listener.type);

      const arrow = doc.createElementNS(XHTML_NS, "button");
      arrow.className = "theme-twisty";
      arrow.setAttribute("aria-expanded", "false");
      arrow.setAttribute("aria-owns", codeMirrorContainerId);
      arrow.setAttribute(
        "title",
        L10N.getFormatStr("eventsTooltip.toggleButton.label", listener.type)
      );

      header.appendChild(arrow);

      if (!listener.hide.type) {
        const eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
        eventTypeLabel.className = "event-tooltip-event-type";
        eventTypeLabel.textContent = listener.type;
        eventTypeLabel.setAttribute("title", listener.type);
        header.appendChild(eventTypeLabel);
      }

      const filename = doc.createElementNS(XHTML_NS, "span");
      filename.className = "event-tooltip-filename devtools-monospace";

      let location = null;
      let text = listener.origin;
      let title = text;
      if (listener.hide.filename) {
        text = L10N.getStr("eventsTooltip.unknownLocation");
        title = L10N.getStr("eventsTooltip.unknownLocationExplanation");
      } else {
        location = this._parseLocation(listener.origin);

        // There will be no source actor if the listener is a native function
        // or wasn't a debuggee, in which case there's also not going to be
        // a sourcemap, so we don't need to worry about subscribing.
        if (location && listener.sourceActor) {
          location.id = listener.sourceActor;

          this._subscriptions.push(
            sourceMapURLService.subscribeByID(
              location.id,
              location.line,
              location.column,
              originalLocation => {
                const currentLoc = originalLocation || location;

                const newURI = currentLoc.url + ":" + currentLoc.line;
                filename.textContent = newURI;
                filename.setAttribute("title", newURI);

                // This is emitted for testing.
                this._tooltip.emitForTests("event-tooltip-source-map-ready");
              }
            )
          );
        }
      }

      filename.textContent = text;
      filename.setAttribute("title", title);
      header.appendChild(filename);

      if (!listener.hide.debugger) {
        const debuggerIcon = doc.createElementNS(XHTML_NS, "button");
        debuggerIcon.className = "event-tooltip-debugger-icon";
        const openInDebugger = L10N.getFormatStr(
          "eventsTooltip.openInDebugger2",
          listener.type
        );
        debuggerIcon.setAttribute("title", openInDebugger);
        header.appendChild(debuggerIcon);
      }

      const attributesContainer = doc.createElementNS(XHTML_NS, "div");
      attributesContainer.className = "event-tooltip-attributes-container";
      header.appendChild(attributesContainer);

      if (listener.tags) {
        for (const tag of listener.tags.split(",")) {
          const attributesBox = doc.createElementNS(XHTML_NS, "div");
          attributesBox.className = "event-tooltip-attributes-box";
          attributesContainer.appendChild(attributesBox);

          const tagBox = doc.createElementNS(XHTML_NS, "span");
          tagBox.className = "event-tooltip-attributes";
          tagBox.textContent = tag;
          tagBox.setAttribute("title", tag);
          attributesBox.appendChild(tagBox);
        }
      }

      if (!listener.hide.capturing) {
        const attributesBox = doc.createElementNS(XHTML_NS, "div");
        attributesBox.className = "event-tooltip-attributes-box";
        attributesContainer.appendChild(attributesBox);

        const capturing = doc.createElementNS(XHTML_NS, "span");
        capturing.className = "event-tooltip-attributes";

        const phase = listener.capturing ? L10N_CAPTURING : L10N_BUBBLING;
        capturing.textContent = phase;
        capturing.setAttribute("title", phase);
        attributesBox.appendChild(capturing);
      }

      const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
      toggleListenerCheckbox.type = "checkbox";
      toggleListenerCheckbox.className =
        "event-tooltip-listener-toggle-checkbox";
      toggleListenerCheckbox.setAttribute(
        "aria-label",
        L10N.getFormatStr("eventsTooltip.toggleListenerLabel", listener.type)
      );
      if (listener.eventListenerInfoId) {
        toggleListenerCheckbox.checked = listener.enabled;
        toggleListenerCheckbox.setAttribute(
          "data-event-listener-info-id",
          listener.eventListenerInfoId
        );
        toggleListenerCheckbox.addEventListener(
          "change",
          this._eventToggleCheckboxChanged,
          { signal: this._eventListenersAbortController.signal }
        );
      } else {
        toggleListenerCheckbox.checked = true;
        toggleListenerCheckbox.setAttribute("disabled", true);
      }
      header.appendChild(toggleListenerCheckbox);

      // Content
      const editor = new Editor(config);
      this._eventEditors.set(content, {
        editor,
        handler: listener.handler,
        native: listener.native,
        appended: false,
        location,
      });

      content.className = "event-tooltip-content-box";

      const li = doc.createElementNS(XHTML_NS, "li");
      li.append(header, content);
      this.container.appendChild(li);
      this._addContentListeners(header);
    }

    this._tooltip.panel.innerHTML = "";
    this._tooltip.panel.appendChild(this.container);
    this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity });
  }

  _addContentListeners(header) {
    header.addEventListener("click", this._headerClicked, {
      signal: this._eventListenersAbortController.signal,
    });
  }

  _headerClicked(event) {
    // Clicking on the checkbox shouldn't impact the header (checkbox state change is
    // handled in _eventToggleCheckboxChanged).
    if (
      event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
    ) {
      event.stopPropagation();
      return;
    }

    if (event.target.classList.contains("event-tooltip-debugger-icon")) {
      this._debugClicked(event);
      event.stopPropagation();
      return;
    }

    const doc = this._tooltip.doc;
    const header = event.currentTarget;
    const content = header.nextElementSibling;
    const twisty = header.querySelector(".theme-twisty");

    if (content.hasAttribute("open")) {
      header.classList.remove("content-expanded");
      twisty.setAttribute("aria-expanded", false);
      content.removeAttribute("open");
    } else {
      // Close other open events first
      const openHeaders = doc.querySelectorAll(
        ".event-header.content-expanded"
      );
      const openContent = doc.querySelectorAll(
        ".event-tooltip-content-box[open]"
      );
      for (const node of openHeaders) {
        node.classList.remove("content-expanded");
        const nodeTwisty = node.querySelector(".theme-twisty");
        nodeTwisty.setAttribute("aria-expanded", false);
      }
      for (const node of openContent) {
        node.removeAttribute("open");
      }

      header.classList.add("content-expanded");
      content.setAttribute("open", "");
      twisty.setAttribute("aria-expanded", true);

      const eventEditor = this._eventEditors.get(content);

      if (eventEditor.appended) {
        return;
      }

      const { editor, handler } = eventEditor;

      const iframe = doc.createElementNS(XHTML_NS, "iframe");
      iframe.classList.add("event-tooltip-editor-frame");
      iframe.setAttribute(
        "title",
        L10N.getFormatStr(
          "eventsTooltip.codeIframeTitle",
          header.getAttribute("data-event-type")
        )
      );

      editor.appendTo(content, iframe).then(() => {
        const tidied = beautify.js(handler, { indent_size: 2 });
        editor.setText(tidied);

        eventEditor.appended = true;

        const container = header.parentElement.getBoundingClientRect();
        if (header.getBoundingClientRect().top < container.top) {
          header.scrollIntoView(true);
        } else if (content.getBoundingClientRect().bottom > container.bottom) {
          content.scrollIntoView(false);
        }

        this._tooltip.emitForTests("event-tooltip-ready");
      });
    }
  }

  _debugClicked(event) {
    const header = event.currentTarget;
    const content = header.nextElementSibling;

    const { location } = this._eventEditors.get(content);
    if (location) {
      // Save a copy of toolbox as it will be set to null when we hide the tooltip.
      const toolbox = this._toolbox;

      this._tooltip.hide();

      toolbox.viewSourceInDebugger(
        location.url,
        location.line,
        location.column,
        location.id
      );
    }
  }

  async _eventToggleCheckboxChanged(event) {
    const checkbox = event.currentTarget;
    const id = checkbox.getAttribute("data-event-listener-info-id");
    if (checkbox.checked) {
      await this._nodeFront.enableEventListener(id);
    } else {
      await this._nodeFront.disableEventListener(id);
    }
    this.emit("event-tooltip-listener-toggled", {
      hasDisabledEventListeners:
        // No need to query the other checkboxes if the handled checkbox is unchecked
        !checkbox.checked ||
        this._tooltip.doc.querySelector(
          `input.event-tooltip-listener-toggle-checkbox:not(:checked)`
        ) !== null,
    });
  }

  /**
   * Parse URI and return {url, line, column}; or return null if it can't be parsed.
   */
  _parseLocation(uri) {
    if (uri && uri !== "?") {
      uri = uri.replace(/"/g, "");

      let matches = uri.match(/(.*):(\d+):(\d+$)/);

      if (matches) {
        return {
          url: matches[1],
          line: parseInt(matches[2], 10),
          column: parseInt(matches[3], 10),
        };
      } else if ((matches = uri.match(/(.*):(\d+$)/))) {
        return {
          url: matches[1],
          line: parseInt(matches[2], 10),
          column: null,
        };
      }
      return { url: uri, line: 1, column: null };
    }
    return null;
  }

  destroy() {
    if (this._tooltip) {
      const boxes = this.container.querySelectorAll(
        ".event-tooltip-content-box"
      );

      for (const box of boxes) {
        const { editor } = this._eventEditors.get(box);
        editor.destroy();
      }

      this._eventEditors = null;
      this._tooltip.eventTooltip = null;
    }

    this.clearEvents();
    if (this._eventListenersAbortController) {
      this._eventListenersAbortController.abort();
      this._eventListenersAbortController = null;
    }

    for (const unsubscribe of this._subscriptions) {
      unsubscribe();
    }

    this._toolbox = this._tooltip = this._nodeFront = null;
  }
}

module.exports.EventTooltip = EventTooltip;
