/* 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 {
  Component,
  createFactory,
} = require("resource://devtools/client/shared/vendor/react.js");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
const {
  connect,
} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
const {
  HTMLTooltip,
} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");

const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
const {
  formDataURI,
} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
const {
  getDisplayedRequests,
  getColumns,
  getSelectedRequest,
  getClickedRequest,
} = require("resource://devtools/client/netmonitor/src/selectors/index.js");

loader.lazyRequireGetter(
  this,
  "openRequestInTab",
  "resource://devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js",
  true
);
loader.lazyGetter(this, "setImageTooltip", function () {
  return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js")
    .setImageTooltip;
});
loader.lazyGetter(this, "getImageDimensions", function () {
  return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js")
    .getImageDimensions;
});

// Components
const RequestListHeader = createFactory(
  require("resource://devtools/client/netmonitor/src/components/request-list/RequestListHeader.js")
);
const RequestListItem = createFactory(
  require("resource://devtools/client/netmonitor/src/components/request-list/RequestListItem.js")
);
const RequestListContextMenu = require("resource://devtools/client/netmonitor/src/widgets/RequestListContextMenu.js");

const { div } = dom;

// Tooltip show / hide delay in ms
const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500;
// Tooltip image maximum dimension in px
const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400;

const LEFT_MOUSE_BUTTON = 0;
const MIDDLE_MOUSE_BUTTON = 1;
const RIGHT_MOUSE_BUTTON = 2;

/**
 * Renders the actual contents of the request list.
 */
class RequestListContent extends Component {
  static get propTypes() {
    return {
      blockedUrls: PropTypes.array.isRequired,
      connector: PropTypes.object.isRequired,
      columns: PropTypes.object.isRequired,
      networkActionOpen: PropTypes.bool,
      networkDetailsOpen: PropTypes.bool.isRequired,
      networkDetailsWidth: PropTypes.number,
      networkDetailsHeight: PropTypes.number,
      cloneRequest: PropTypes.func.isRequired,
      clickedRequest: PropTypes.object,
      openDetailsPanelTab: PropTypes.func.isRequired,
      openHTTPCustomRequestTab: PropTypes.func.isRequired,
      closeHTTPCustomRequestTab: PropTypes.func.isRequired,
      sendCustomRequest: PropTypes.func.isRequired,
      sendHTTPCustomRequest: PropTypes.func.isRequired,
      displayedRequests: PropTypes.array.isRequired,
      firstRequestStartedMs: PropTypes.number.isRequired,
      fromCache: PropTypes.bool,
      onInitiatorBadgeMouseDown: PropTypes.func.isRequired,
      onItemRightMouseButtonDown: PropTypes.func.isRequired,
      onItemMouseDown: PropTypes.func.isRequired,
      onSecurityIconMouseDown: PropTypes.func.isRequired,
      onSelectDelta: PropTypes.func.isRequired,
      onWaterfallMouseDown: PropTypes.func.isRequired,
      openStatistics: PropTypes.func.isRequired,
      openRequestBlockingAndAddUrl: PropTypes.func.isRequired,
      openRequestBlockingAndDisableUrls: PropTypes.func.isRequired,
      removeBlockedUrl: PropTypes.func.isRequired,
      selectedActionBarTabId: PropTypes.string,
      selectRequest: PropTypes.func.isRequired,
      selectedRequest: PropTypes.object,
      requestFilterTypes: PropTypes.object.isRequired,
    };
  }

  constructor(props) {
    super(props);
    this.onHover = this.onHover.bind(this);
    this.onScroll = this.onScroll.bind(this);
    this.onResize = this.onResize.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
    this.openRequestInTab = this.openRequestInTab.bind(this);
    this.onDoubleClick = this.onDoubleClick.bind(this);
    this.onDragStart = this.onDragStart.bind(this);
    this.onContextMenu = this.onContextMenu.bind(this);
    this.onMouseDown = this.onMouseDown.bind(this);
    this.hasOverflow = false;
    this.onIntersect = this.onIntersect.bind(this);
    this.intersectionObserver = null;
    this.state = {
      onscreenItems: new Set(),
    };
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillMount() {
    this.tooltip = new HTMLTooltip(window.parent.document, { type: "arrow" });
    window.addEventListener("resize", this.onResize);
  }

  componentDidMount() {
    // Install event handler for displaying a tooltip
    this.tooltip.startTogglingOnHover(this.refs.scrollEl, this.onHover, {
      toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY,
      interactive: true,
    });
    // Install event handler to hide the tooltip on scroll
    this.refs.scrollEl.addEventListener("scroll", this.onScroll, true);
    this.onResize();
    this.intersectionObserver = new IntersectionObserver(this.onIntersect, {
      root: this.refs.scrollEl,
      // Render 10% more columns for a scrolling headstart
      rootMargin: "10%",
    });
    // Prime IntersectionObserver with existing entries
    for (const item of this.refs.scrollEl.querySelectorAll(
      ".request-list-item"
    )) {
      this.intersectionObserver.observe(item);
    }
  }

  componentDidUpdate(prevProps) {
    const output = this.refs.scrollEl;
    if (!this.hasOverflow && output.scrollHeight > output.clientHeight) {
      output.scrollTop = output.scrollHeight;
      this.hasOverflow = true;
    }
    if (
      prevProps.networkDetailsOpen !== this.props.networkDetailsOpen ||
      prevProps.networkDetailsWidth !== this.props.networkDetailsWidth ||
      prevProps.networkDetailsHeight !== this.props.networkDetailsHeight
    ) {
      this.onResize();
    }
  }

  componentWillUnmount() {
    this.refs.scrollEl.removeEventListener("scroll", this.onScroll, true);

    // Uninstall the tooltip event handler
    this.tooltip.stopTogglingOnHover();
    window.removeEventListener("resize", this.onResize);
    if (this.intersectionObserver !== null) {
      this.intersectionObserver.disconnect();
      this.intersectionObserver = null;
    }
  }

  /*
   * Removing onResize() method causes perf regression - too many repaints of the panel.
   * So it is needed in ComponentDidMount and ComponentDidUpdate. See Bug 1532914.
   */
  onResize() {
    const parent = this.refs.scrollEl.parentNode;
    this.refs.scrollEl.style.width = parent.offsetWidth + "px";
    this.refs.scrollEl.style.height = parent.offsetHeight + "px";
  }

  onIntersect(entries) {
    // Track when off screen elements moved on screen to ensure updates
    let onscreenDidChange = false;
    const onscreenItems = new Set(this.state.onscreenItems);
    for (const { target, isIntersecting } of entries) {
      const { id } = target.dataset;
      if (isIntersecting) {
        if (onscreenItems.add(id)) {
          onscreenDidChange = true;
        }
      } else {
        onscreenItems.delete(id);
      }
    }
    if (onscreenDidChange) {
      // Remove ids that are no longer displayed
      const itemIds = new Set(this.props.displayedRequests.map(({ id }) => id));
      for (const id of onscreenItems) {
        if (!itemIds.has(id)) {
          onscreenItems.delete(id);
        }
      }
      this.setState({ onscreenItems });
    }
  }

  /**
   * The predicate used when deciding whether a popup should be shown
   * over a request item or not.
   *
   * @param Node target
   *        The element node currently being hovered.
   * @param object tooltip
   *        The current tooltip instance.
   * @return {Promise}
   */
  async onHover(target, tooltip) {
    const itemEl = target.closest(".request-list-item");
    if (!itemEl) {
      return false;
    }
    const itemId = itemEl.dataset.id;
    if (!itemId) {
      return false;
    }
    const requestItem = this.props.displayedRequests.find(r => r.id == itemId);
    if (!requestItem) {
      return false;
    }

    if (!target.closest(".requests-list-file")) {
      return false;
    }

    const { mimeType } = requestItem;
    if (!mimeType || !mimeType.includes("image/")) {
      return false;
    }

    const responseContent = await this.props.connector.requestData(
      requestItem.id,
      "responseContent"
    );
    const { encoding, text } = responseContent.content;
    const src = formDataURI(mimeType, encoding, text);
    const maxDim = REQUESTS_TOOLTIP_IMAGE_MAX_DIM;
    const { naturalWidth, naturalHeight } = await getImageDimensions(
      tooltip.doc,
      src
    );
    const options = { maxDim, naturalWidth, naturalHeight };
    setImageTooltip(tooltip, tooltip.doc, src, options);

    return itemEl.querySelector(".requests-list-file");
  }

  /**
   * Scroll listener for the requests menu view.
   */
  onScroll() {
    this.tooltip.hide();
  }

  onMouseDown(evt, id, request) {
    if (evt.button === LEFT_MOUSE_BUTTON) {
      this.props.selectRequest(id, request);
    } else if (evt.button === RIGHT_MOUSE_BUTTON) {
      this.props.onItemRightMouseButtonDown(id);
    } else if (evt.button === MIDDLE_MOUSE_BUTTON) {
      this.onMiddleMouseButtonDown(request);
    }
  }

  /**
   * Handler for keyboard events. For arrow up/down, page up/down, home/end,
   * move the selection up or down.
   */
  onKeyDown(evt) {
    let delta;

    switch (evt.key) {
      case "ArrowUp":
        delta = -1;
        break;
      case "ArrowDown":
        delta = +1;
        break;
      case "PageUp":
        delta = "PAGE_UP";
        break;
      case "PageDown":
        delta = "PAGE_DOWN";
        break;
      case "Home":
        delta = -Infinity;
        break;
      case "End":
        delta = +Infinity;
        break;
    }

    if (delta) {
      // Prevent scrolling when pressing navigation keys.
      evt.preventDefault();
      evt.stopPropagation();
      this.props.onSelectDelta(delta);
    }
  }

  /**
   * Opens selected item in a new tab.
   */
  async openRequestInTab(id, url, requestHeaders, requestPostData) {
    requestHeaders =
      requestHeaders ||
      (await this.props.connector.requestData(id, "requestHeaders"));

    requestPostData =
      requestPostData ||
      (await this.props.connector.requestData(id, "requestPostData"));

    openRequestInTab(url, requestHeaders, requestPostData);
  }

  onDoubleClick({ id, url, requestHeaders, requestPostData }) {
    this.openRequestInTab(id, url, requestHeaders, requestPostData);
  }

  onMiddleMouseButtonDown({ id, url, requestHeaders, requestPostData }) {
    this.openRequestInTab(id, url, requestHeaders, requestPostData);
  }

  onDragStart(evt, { url }) {
    evt.dataTransfer.setData("text/plain", url);
  }

  onContextMenu(evt) {
    evt.preventDefault();
    const { clickedRequest, displayedRequests, blockedUrls } = this.props;

    if (!this.contextMenu) {
      const {
        connector,
        cloneRequest,
        openDetailsPanelTab,
        openHTTPCustomRequestTab,
        closeHTTPCustomRequestTab,
        sendCustomRequest,
        sendHTTPCustomRequest,
        openStatistics,
        openRequestBlockingAndAddUrl,
        openRequestBlockingAndDisableUrls,
        removeBlockedUrl,
      } = this.props;
      this.contextMenu = new RequestListContextMenu({
        connector,
        cloneRequest,
        openDetailsPanelTab,
        openHTTPCustomRequestTab,
        closeHTTPCustomRequestTab,
        sendCustomRequest,
        sendHTTPCustomRequest,
        openStatistics,
        openRequestBlockingAndAddUrl,
        openRequestBlockingAndDisableUrls,
        removeBlockedUrl,
        openRequestInTab: this.openRequestInTab,
      });
    }

    this.contextMenu.open(evt, clickedRequest, displayedRequests, blockedUrls);
  }

  render() {
    const {
      connector,
      columns,
      displayedRequests,
      firstRequestStartedMs,
      onInitiatorBadgeMouseDown,
      onSecurityIconMouseDown,
      onWaterfallMouseDown,
      requestFilterTypes,
      selectedRequest,
      selectedActionBarTabId,
      openRequestBlockingAndAddUrl,
      openRequestBlockingAndDisableUrls,
      networkActionOpen,
      networkDetailsOpen,
    } = this.props;

    return div(
      {
        ref: "scrollEl",
        className: "requests-list-scroll",
      },
      [
        dom.table(
          {
            className: "requests-list-table",
            key: "table",
          },
          RequestListHeader(),
          dom.tbody(
            {
              ref: "rowGroupEl",
              className: "requests-list-row-group",
              tabIndex: 0,
              onKeyDown: this.onKeyDown,
            },
            displayedRequests.map((item, index) => {
              return RequestListItem({
                blocked: !!item.blockedReason,
                firstRequestStartedMs,
                fromCache: item.status === "304" || item.fromCache,
                networkDetailsOpen,
                networkActionOpen,
                selectedActionBarTabId,
                connector,
                columns,
                item,
                index,
                isSelected: item.id === selectedRequest?.id,
                isVisible: this.state.onscreenItems.has(item.id),
                key: item.id,
                intersectionObserver: this.intersectionObserver,
                onContextMenu: this.onContextMenu,
                onDoubleClick: () => this.onDoubleClick(item),
                onDragStart: evt => this.onDragStart(evt, item),
                onMouseDown: evt => this.onMouseDown(evt, item.id, item),
                onInitiatorBadgeMouseDown: () =>
                  onInitiatorBadgeMouseDown(item.cause),
                onSecurityIconMouseDown: () =>
                  onSecurityIconMouseDown(item.securityState),
                onWaterfallMouseDown,
                requestFilterTypes,
                openRequestBlockingAndAddUrl,
                openRequestBlockingAndDisableUrls,
              });
            })
          )
        ), // end of requests-list-row-group">
        dom.div({
          className: "requests-list-anchor",
          key: "anchor",
        }),
      ]
    );
  }
}

module.exports = connect(
  state => ({
    blockedUrls: state.requestBlocking.blockedUrls
      .map(({ enabled, url }) => (enabled ? url : null))
      .filter(Boolean),
    columns: getColumns(state),
    networkActionOpen: state.ui.networkActionOpen,
    networkDetailsOpen: state.ui.networkDetailsOpen,
    networkDetailsWidth: state.ui.networkDetailsWidth,
    networkDetailsHeight: state.ui.networkDetailsHeight,
    clickedRequest: getClickedRequest(state),
    displayedRequests: getDisplayedRequests(state),
    firstRequestStartedMs: state.requests.firstStartedMs,
    selectedActionBarTabId: state.ui.selectedActionBarTabId,
    selectedRequest: getSelectedRequest(state),
    requestFilterTypes: state.filters.requestFilterTypes,
  }),
  (dispatch, props) => ({
    cloneRequest: id => dispatch(Actions.cloneRequest(id)),
    openDetailsPanelTab: () => dispatch(Actions.openNetworkDetails(true)),
    openHTTPCustomRequestTab: () =>
      dispatch(Actions.openHTTPCustomRequest(true)),
    closeHTTPCustomRequestTab: () =>
      dispatch(Actions.openHTTPCustomRequest(false)),
    sendCustomRequest: () => dispatch(Actions.sendCustomRequest()),
    sendHTTPCustomRequest: request =>
      dispatch(Actions.sendHTTPCustomRequest(request)),
    openStatistics: open =>
      dispatch(Actions.openStatistics(props.connector, open)),
    openRequestBlockingAndAddUrl: url =>
      dispatch(Actions.openRequestBlockingAndAddUrl(url)),
    removeBlockedUrl: url => dispatch(Actions.removeBlockedUrl(url)),
    openRequestBlockingAndDisableUrls: url =>
      dispatch(Actions.openRequestBlockingAndDisableUrls(url)),
    /**
     * A handler that opens the stack trace tab when a stack trace is available
     */
    onInitiatorBadgeMouseDown: cause => {
      if (cause.lastFrame) {
        dispatch(Actions.selectDetailsPanelTab("stack-trace"));
      }
    },
    selectRequest: (id, request) =>
      dispatch(Actions.selectRequest(id, request)),
    onItemRightMouseButtonDown: id => dispatch(Actions.rightClickRequest(id)),
    onItemMouseDown: id => dispatch(Actions.selectRequest(id)),
    /**
     * A handler that opens the security tab in the details view if secure or
     * broken security indicator is clicked.
     */
    onSecurityIconMouseDown: securityState => {
      if (securityState && securityState !== "insecure") {
        dispatch(Actions.selectDetailsPanelTab("security"));
      }
    },
    onSelectDelta: delta => dispatch(Actions.selectDelta(delta)),
    /**
     * A handler that opens the timing sidebar panel if the waterfall is clicked.
     */
    onWaterfallMouseDown: () => {
      dispatch(Actions.selectDetailsPanelTab("timings"));
    },
  })
)(RequestListContent);
