/* 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,
  createRef,
  createFactory,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const { div, span } = dom;
const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
const {
  PANELS,
} = require("resource://devtools/client/netmonitor/src/constants.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
const {
  connect,
} = require("resource://devtools/client/shared/vendor/react-redux.js");
const TreeViewClass = ChromeUtils.importESModule(
  "resource://devtools/client/shared/components/tree/TreeView.mjs"
).default;
const TreeView = createFactory(TreeViewClass);
const LabelCell = createFactory(
  ChromeUtils.importESModule(
    "resource://devtools/client/shared/components/tree/LabelCell.mjs"
  ).default
);
const {
  SearchProvider,
} = require("resource://devtools/client/netmonitor/src/components/search/search-provider.js");
const Toolbar = createFactory(
  require("resource://devtools/client/netmonitor/src/components/search/Toolbar.js")
);
const StatusBar = createFactory(
  require("resource://devtools/client/netmonitor/src/components/search/StatusBar.js")
);
const {
  limitTooltipLength,
} = require("resource://devtools/client/netmonitor/src/utils/tooltips.js");
const {
  L10N,
} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
loader.lazyRequireGetter(
  this,
  "showMenu",
  "resource://devtools/client/shared/components/menu/utils.js",
  true
);

const PropertiesViewContextMenu = require("resource://devtools/client/netmonitor/src/widgets/PropertiesViewContextMenu.js");
const RequestListContextMenu = require("resource://devtools/client/netmonitor/src/widgets/RequestListContextMenu.js");

// There are two levels in the search panel tree hierarchy:
// 0: Resource - represents the source request object
// 1: Search Result - represents a match coming from the parent resource
const RESOURCE_LEVEL = 0;
const SEARCH_RESULT_LEVEL = 1;

/**
 * This component is responsible for rendering all search results
 * coming from the current search.
 */
class SearchPanel extends Component {
  static get propTypes() {
    return {
      clearSearchResults: PropTypes.func.isRequired,
      openSearch: PropTypes.func.isRequired,
      closeSearch: PropTypes.func.isRequired,
      search: PropTypes.func.isRequired,
      caseSensitive: PropTypes.bool,
      connector: PropTypes.object.isRequired,
      addSearchQuery: PropTypes.func.isRequired,
      query: PropTypes.string.isRequired,
      results: PropTypes.array,
      navigate: PropTypes.func.isRequired,
      isDisplaying: PropTypes.bool.isRequired,
      blockedUrls: PropTypes.array.isRequired,
      requests: PropTypes.array.isRequired,
      cloneRequest: PropTypes.func.isRequired,
      openDetailsPanelTab: PropTypes.func.isRequired,
      openHTTPCustomRequestTab: PropTypes.func.isRequired,
      closeHTTPCustomRequestTab: PropTypes.func.isRequired,
      sendCustomRequest: PropTypes.func.isRequired,
      sendHTTPCustomRequest: PropTypes.func.isRequired,
      openStatistics: PropTypes.func.isRequired,
      openRequestBlockingAndAddUrl: PropTypes.func.isRequired,
      openRequestBlockingAndDisableUrls: PropTypes.func.isRequired,
      removeBlockedUrl: PropTypes.func.isRequired,
    };
  }

  constructor(props) {
    super(props);

    this.searchboxRef = createRef();
    this.renderValue = this.renderValue.bind(this);
    this.renderLabel = this.renderLabel.bind(this);
    this.onClickTreeRow = this.onClickTreeRow.bind(this);
    this.onContextMenuTreeRow = this.onContextMenuTreeRow.bind(this);
    this.provider = SearchProvider;
    this.expandedNodes = new Set();
  }

  componentDidMount() {
    if (this.searchboxRef) {
      this.searchboxRef.current.focus();
    }
  }

  componentDidUpdate(prevProps) {
    if (this.props.isDisplaying && !prevProps.isDisplaying) {
      this.searchboxRef.current.focus();
    }
  }

  onClickTreeRow(path, event, member) {
    if (member.object.parentResource) {
      this.props.navigate(member.object);
    }
  }

  /**
   * Custom TreeView label rendering. The search result
   * value isn't rendered in separate column, but in the
   * same column as the label (to save space).
   */
  renderLabel(props) {
    const { member } = props;
    const level = member.level || 0;
    const className = level == RESOURCE_LEVEL ? "resourceCell" : "resultCell";

    // Customize label rendering by adding a suffix/value
    const renderSuffix = () => {
      return dom.span(
        {
          className,
        },
        " ",
        this.renderValue(props)
      );
    };

    return LabelCell({
      ...props,
      title:
        member.level == 1
          ? limitTooltipLength(member.object.value)
          : this.provider.getResourceTooltipLabel(member.object),
      renderSuffix,
    });
  }

  onContextMenuTreeRow(member, evt) {
    evt.preventDefault();

    // if selected item is associated to a request --> show suitable contextmenu
    const request = member?.object?.resource;
    if (typeof request === "object") {
      // test if request is still available:
      const requestId = request.id;
      const storedRequest = this.props.requests.find(
        currentStoredRequest => requestId === currentStoredRequest?.id
      );

      if (typeof storedRequest === "object") {
        // request is in cache --> open full context menu:
        this.openRequestListContextMenu(evt, request);
      } else {
        // request is not in the cache anymore --> open context menu with note about it:
        const menuItems = [
          {
            id: "simple-view-context-menu-request-not-available-anymore",
            label: L10N.getStr("netmonitor.context.hintRequestNotAvailable"),
            disabled: true,
          },
        ];

        showMenu(menuItems, {
          screenX: evt.screenX,
          screenY: evt.screenY,
        });
      }
    } else {
      // for other content -> open simple context menu with copy only
      if (!this.contextMenuSimple) {
        this.contextMenuSimple = new PropertiesViewContextMenu();
      }
      this.contextMenuSimple.open(evt, window.getSelection(), {
        member,
      });
    }
  }

  openRequestListContextMenu(evt, request) {
    // reuse context menu of request list:
    if (!this.contextMenuRequest) {
      const {
        connector,
        cloneRequest,
        openDetailsPanelTab,
        openHTTPCustomRequestTab,
        closeHTTPCustomRequestTab,
        sendCustomRequest,
        sendHTTPCustomRequest,
        openStatistics,
        openRequestBlockingAndAddUrl,
        openRequestBlockingAndDisableUrls,
        removeBlockedUrl,
      } = this.props;
      this.contextMenuRequest = new RequestListContextMenu({
        connector,
        cloneRequest,
        openDetailsPanelTab,
        openHTTPCustomRequestTab,
        closeHTTPCustomRequestTab,
        sendCustomRequest,
        sendHTTPCustomRequest,
        openStatistics,
        openRequestBlockingAndAddUrl,
        openRequestBlockingAndDisableUrls,
        removeBlockedUrl,
      });
    }

    const { blockedUrls, results } = this.props;
    const allRequestsInResults = results.map(r => r.resource);

    this.contextMenuRequest.open(
      evt,
      request,
      allRequestsInResults,
      blockedUrls
    );
  }

  renderTree() {
    const { results } = this.props;
    return TreeView({
      object: results,
      provider: this.provider,
      expandableStrings: false,
      // Ensure passing a stable expanded Set,
      // so that TreeView doesn't reset to default prop's Set
      // on each new received props.
      expandedNodes: this.expandedNodes,
      renderLabelCell: this.renderLabel,
      onContextMenuRow: this.onContextMenuTreeRow,
      columns: [],
      onClickRow: this.onClickTreeRow,
    });
  }

  /**
   * Custom tree value rendering. This method is responsible for
   * rendering highlighted query string within the search result
   * result tree.
   */
  renderValue(props) {
    const { member } = props;
    const { query, caseSensitive } = this.props;

    // Handle only second level (zero based) that displays
    // the search result. Find the query string inside the
    // search result value (`props.object`) and render it
    // within a span element with proper class name.
    // level 0 = resource name
    if (member.level === SEARCH_RESULT_LEVEL) {
      const { object } = member;

      // Handles multiple matches in a string
      if (object.startIndex && object.startIndex.length > 1) {
        let indexStart = 0;
        const allMatches = object.startIndex.map((match, index) => {
          if (index === 0) {
            indexStart = match - 50;
          }

          const highlightedMatch = [
            span(
              { key: "match-" + match },
              object.value.substring(indexStart, match)
            ),
            span(
              {
                className: "query-match",
                key: "match-" + match + "-highlight",
              },
              object.value.substring(match, match + query.length)
            ),
          ];

          indexStart = match + query.length;

          return highlightedMatch;
        });

        return span(
          {
            title: limitTooltipLength(object.value),
          },
          allMatches
        );
      }

      const indexStart = caseSensitive
        ? object.value.indexOf(query)
        : object.value.toLowerCase().indexOf(query.toLowerCase());
      const indexEnd = indexStart + query.length;

      // Handles a match in a string
      if (indexStart >= 0) {
        return span(
          { title: limitTooltipLength(object.value) },
          span({}, object.value.substring(0, indexStart)),
          span(
            { className: "query-match" },
            object.value.substring(indexStart, indexStart + query.length)
          ),
          span({}, object.value.substring(indexEnd, object.value.length))
        );
      }

      // Default for key:value matches where query might not
      // be present in the value, but found in the key.
      return span(
        { title: limitTooltipLength(object.value) },
        span({}, object.value)
      );
    }

    return this.provider.getValue(member.object);
  }

  render() {
    const {
      openSearch,
      closeSearch,
      clearSearchResults,
      connector,
      addSearchQuery,
      search,
    } = this.props;
    return div(
      { className: "search-panel", style: { width: "100%" } },
      Toolbar({
        searchboxRef: this.searchboxRef,
        openSearch,
        closeSearch,
        clearSearchResults,
        addSearchQuery,
        search,
        connector,
      }),
      div(
        { className: "search-panel-content", style: { width: "100%" } },
        this.renderTree()
      ),
      StatusBar()
    );
  }
}

module.exports = connect(
  state => ({
    query: state.search.query,
    caseSensitive: state.search.caseSensitive,
    results: state.search.results,
    ongoingSearch: state.search.ongoingSearch,
    isDisplaying: state.ui.selectedActionBarTabId === PANELS.SEARCH,
    status: state.search.status,
    blockedUrls: state.requestBlocking.blockedUrls,
    requests: state.requests.requests,
  }),
  (dispatch, props) => ({
    closeSearch: () => dispatch(Actions.closeSearch()),
    openSearch: () => dispatch(Actions.openSearch()),
    search: () => dispatch(Actions.search()),
    clearSearchResults: () => dispatch(Actions.clearSearchResults()),
    addSearchQuery: query => dispatch(Actions.addSearchQuery(query)),
    navigate: searchResult => dispatch(Actions.navigate(searchResult)),
    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)),
    openRequestBlockingAndDisableUrls: url =>
      dispatch(Actions.openRequestBlockingAndDisableUrls(url)),
    removeBlockedUrl: url => dispatch(Actions.removeBlockedUrl(url)),
  })
)(SearchPanel);
