/* 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,
  createElement,
  createRef,
} = require("resource://devtools/client/shared/vendor/react.js");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const {
  connect,
} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
const {
  initialize,
} = require("resource://devtools/client/webconsole/actions/ui.js");
const LazyMessageList = require("resource://devtools/client/webconsole/components/Output/LazyMessageList.js");

const {
  getMutableMessagesById,
  getAllMessagesUiById,
  getAllDisabledMessagesById,
  getAllCssMessagesMatchingElements,
  getAllNetworkMessagesUpdateById,
  getLastMessageId,
  getVisibleMessages,
  getAllRepeatById,
  getAllWarningGroupsById,
  isMessageInWarningGroup,
} = require("resource://devtools/client/webconsole/selectors/messages.js");

loader.lazyRequireGetter(
  this,
  "PropTypes",
  "resource://devtools/client/shared/vendor/react-prop-types.js"
);
loader.lazyRequireGetter(
  this,
  "MessageContainer",
  "resource://devtools/client/webconsole/components/Output/MessageContainer.js",
  true
);
loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js");

const {
  MESSAGE_TYPE,
} = require("resource://devtools/client/webconsole/constants.js");

class ConsoleOutput extends Component {
  static get propTypes() {
    return {
      initialized: PropTypes.bool.isRequired,
      mutableMessages: PropTypes.object.isRequired,
      messageCount: PropTypes.number.isRequired,
      messagesUi: PropTypes.array.isRequired,
      disabledMessages: PropTypes.array.isRequired,
      serviceContainer: PropTypes.shape({
        attachRefToWebConsoleUI: PropTypes.func.isRequired,
        openContextMenu: PropTypes.func.isRequired,
        sourceMapURLService: PropTypes.object,
      }),
      dispatch: PropTypes.func.isRequired,
      timestampsVisible: PropTypes.bool,
      cssMatchingElements: PropTypes.object.isRequired,
      messagesRepeat: PropTypes.object.isRequired,
      warningGroups: PropTypes.object.isRequired,
      networkMessagesUpdate: PropTypes.object.isRequired,
      visibleMessages: PropTypes.array.isRequired,
      networkMessageActiveTabId: PropTypes.string.isRequired,
      onFirstMeaningfulPaint: PropTypes.func.isRequired,
      editorMode: PropTypes.bool.isRequired,
      cacheGeneration: PropTypes.number.isRequired,
      disableVirtualization: PropTypes.bool,
      lastMessageId: PropTypes.string.isRequired,
    };
  }

  constructor(props) {
    super(props);
    this.onContextMenu = this.onContextMenu.bind(this);
    this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this);
    this.messageIdsToKeepAlive = new Set();
    this.ref = createRef();
    this.lazyMessageListRef = createRef();

    this.resizeObserver = new ResizeObserver(() => {
      // If we don't have the outputNode reference, or if the outputNode isn't connected
      // anymore, we disconnect the resize observer (componentWillUnmount is never called
      // on this component, so we have to do it here).
      if (!this.outputNode || !this.outputNode.isConnected) {
        this.resizeObserver.disconnect();
        return;
      }

      if (this.scrolledToBottom) {
        this.scrollToBottom();
      }
    });
  }

  componentDidMount() {
    if (this.props.disableVirtualization) {
      return;
    }

    if (this.props.visibleMessages.length) {
      this.scrollToBottom();
    }

    this.scrollDetectionIntersectionObserver = new IntersectionObserver(
      entries => {
        for (const entry of entries) {
          // Consider that we're not pinned to the bottom anymore if the bottom of the
          // scrollable area is within 10px of visible (half the typical element height.)
          this.scrolledToBottom = entry.intersectionRatio > 0;
        }
      },
      { root: this.outputNode, rootMargin: "10px" }
    );

    this.resizeObserver.observe(this.getElementToObserve());

    const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props;
    serviceContainer.attachRefToWebConsoleUI(
      "outputScroller",
      this.ref.current
    );

    // Waiting for the next paint.
    new Promise(res => requestAnimationFrame(res)).then(() => {
      if (onFirstMeaningfulPaint) {
        onFirstMeaningfulPaint();
      }

      // Dispatching on next tick so we don't block on action execution.
      setTimeout(() => {
        dispatch(initialize());
      }, 0);
    });
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillUpdate(nextProps) {
    this.isUpdating = true;
    if (nextProps.cacheGeneration !== this.props.cacheGeneration) {
      this.messageIdsToKeepAlive = new Set();
    }

    if (nextProps.editorMode !== this.props.editorMode) {
      this.resizeObserver.disconnect();
    }

    const { outputNode } = this;
    if (!outputNode?.lastChild) {
      // Force a scroll to bottom when messages are added to an empty console.
      // This makes the console stay pinned to the bottom if a batch of messages
      // are added after a page refresh (Bug 1402237).
      this.shouldScrollBottom = true;
      this.scrolledToBottom = true;
      return;
    }

    const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer;
    this.scrollDetectionIntersectionObserver.unobserve(bottomBuffer);

    const visibleMessagesDelta =
      nextProps.visibleMessages.length - this.props.visibleMessages.length;
    const messagesDelta = nextProps.messageCount - this.props.messageCount;
    // Evaluation results are never filtered out, so if it's in the store, it will be
    // visible in the output.
    const isNewMessageEvaluationResult =
      messagesDelta > 0 &&
      nextProps.lastMessageId &&
      nextProps.mutableMessages.get(nextProps.lastMessageId)?.type ===
        MESSAGE_TYPE.RESULT;

    // Use an inline function in order to avoid executing the expensive Array.some()
    // unless condition are meant to do this additional check.
    const isOpeningGroup = () => {
      const messagesUiDelta =
        nextProps.messagesUi.length - this.props.messagesUi.length;
      return (
        messagesUiDelta > 0 &&
        nextProps.messagesUi.some(
          id =>
            !this.props.messagesUi.includes(id) &&
            this.props.visibleMessages.includes(id) &&
            nextProps.visibleMessages.includes(id)
        )
      );
    };

    // We need to scroll to the bottom if:
    this.shouldScrollBottom =
      // - we are reacting to "initialize" action, and we are already scrolled to the bottom
      (!this.props.initialized &&
        nextProps.initialized &&
        this.scrolledToBottom) ||
      // - the number of messages in the store changed and the new message is an evaluation
      //   result.
      isNewMessageEvaluationResult ||
      // - the number of messages displayed changed and we are already scrolled to the
      //   bottom, but not if we are reacting to a group opening.
      (this.scrolledToBottom && visibleMessagesDelta > 0 && !isOpeningGroup());
  }

  componentDidUpdate(prevProps) {
    this.isUpdating = false;
    this.maybeScrollToBottom();
    if (this?.outputNode?.lastChild) {
      const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer;
      this.scrollDetectionIntersectionObserver.observe(bottomBuffer);
    }

    if (prevProps.editorMode !== this.props.editorMode) {
      this.resizeObserver.observe(this.getElementToObserve());
    }
  }

  get outputNode() {
    return this.ref.current;
  }

  maybeScrollToBottom() {
    if (this.outputNode && this.shouldScrollBottom) {
      this.scrollToBottom();
    }
  }

  // The maybeScrollToBottom callback we provide to messages needs to be a little bit more
  // strict than the one we normally use, because they can potentially interrupt a user
  // scroll (between when the intersection observer registers the scroll break and when
  // a componentDidUpdate comes through to reconcile it.)
  maybeScrollToBottomMessageCallback(index) {
    if (
      this.outputNode &&
      this.shouldScrollBottom &&
      this.scrolledToBottom &&
      this.lazyMessageListRef.current?.isItemNearBottom(index)
    ) {
      this.scrollToBottom();
    }
  }

  scrollToBottom() {
    if (flags.testing && this.outputNode.hasAttribute("disable-autoscroll")) {
      return;
    }
    if (this.outputNode.scrollHeight > this.outputNode.clientHeight) {
      this.outputNode.scrollTop = this.outputNode.scrollHeight;
    }

    this.scrolledToBottom = true;
  }

  getElementToObserve() {
    // In inline mode, we need to observe the output node parent, which contains both the
    // output and the input, so we don't trigger the resizeObserver callback when only the
    // output size changes (e.g. when a network request is expanded).
    return this.props.editorMode
      ? this.outputNode
      : this.outputNode?.parentNode;
  }

  onContextMenu(e) {
    this.props.serviceContainer.openContextMenu(e);
    e.stopPropagation();
    e.preventDefault();
  }

  render() {
    const {
      cacheGeneration,
      dispatch,
      visibleMessages,
      disabledMessages,
      mutableMessages,
      messagesUi,
      cssMatchingElements,
      messagesRepeat,
      warningGroups,
      networkMessagesUpdate,
      networkMessageActiveTabId,
      serviceContainer,
      timestampsVisible,
    } = this.props;

    const renderMessage = (messageId, index) => {
      return createElement(MessageContainer, {
        dispatch,
        key: messageId,
        messageId,
        serviceContainer,
        open: messagesUi.includes(messageId),
        cssMatchingElements: cssMatchingElements.get(messageId),
        timestampsVisible,
        disabled: disabledMessages.includes(messageId),
        repeat: messagesRepeat[messageId],
        badge: warningGroups.has(messageId)
          ? warningGroups.get(messageId).length
          : null,
        inWarningGroup:
          warningGroups && warningGroups.size > 0
            ? isMessageInWarningGroup(
                mutableMessages.get(messageId),
                visibleMessages
              )
            : false,
        networkMessageUpdate: networkMessagesUpdate[messageId],
        networkMessageActiveTabId,
        getMessage: () => mutableMessages.get(messageId),
        maybeScrollToBottom: () =>
          this.maybeScrollToBottomMessageCallback(index),
        // Whenever a node is expanded, we want to make sure we keep the
        // message node alive so as to not lose the expanded state.
        setExpanded: () => this.messageIdsToKeepAlive.add(messageId),
      });
    };

    // scrollOverdrawCount tells the list to draw extra elements above and
    // below the scrollport so that we can avoid flashes of blank space
    // when scrolling. When `disableVirtualization` is passed we make it as large as the
    // number of messages to render them all and effectively disabling virtualization (this
    // should only be used for some actions that requires all the messages to be rendered
    // in the DOM, like "Copy All Messages").
    const scrollOverdrawCount = this.props.disableVirtualization
      ? visibleMessages.length
      : 20;

    const attrs = {
      className: "webconsole-output",
      role: "main",
      onContextMenu: this.onContextMenu,
      ref: this.ref,
    };
    if (flags.testing) {
      attrs["data-visible-messages"] = JSON.stringify(visibleMessages);
    }
    return dom.div(
      attrs,
      createElement(LazyMessageList, {
        viewportRef: this.ref,
        items: visibleMessages,
        itemDefaultHeight: 21,
        editorMode: this.props.editorMode,
        scrollOverdrawCount,
        ref: this.lazyMessageListRef,
        renderItem: renderMessage,
        itemsToKeepAlive: this.messageIdsToKeepAlive,
        serviceContainer,
        cacheGeneration,
        shouldScrollBottom: () => this.shouldScrollBottom && this.isUpdating,
      })
    );
  }
}

function mapStateToProps(state) {
  const mutableMessages = getMutableMessagesById(state);
  return {
    initialized: state.ui.initialized,
    cacheGeneration: state.ui.cacheGeneration,
    // We need to compute this so lifecycle methods can compare the global message count
    // on state change (since we can't do it with mutableMessagesById).
    messageCount: mutableMessages.size,
    mutableMessages,
    lastMessageId: getLastMessageId(state),
    visibleMessages: getVisibleMessages(state),
    disabledMessages: getAllDisabledMessagesById(state),
    messagesUi: getAllMessagesUiById(state),
    cssMatchingElements: getAllCssMessagesMatchingElements(state),
    messagesRepeat: getAllRepeatById(state),
    warningGroups: getAllWarningGroupsById(state),
    networkMessagesUpdate: getAllNetworkMessagesUpdateById(state),
    timestampsVisible: state.ui.timestampsVisible,
    networkMessageActiveTabId: state.ui.networkMessageActiveTabId,
  };
}

module.exports = connect(mapStateToProps)(ConsoleOutput);
