/* 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/.
 *
 * This file incorporates work covered by the following copyright and
 * permission notice:
 *
 *   MIT License
 *
 *   Copyright (c) 2019 Oleg Grishechkin
 *
 *   Permission is hereby granted, free of charge, to any person obtaining a copy
 *   of this software and associated documentation files (the "Software"), to deal
 *   in the Software without restriction, including without limitation the rights
 *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *   copies of the Software, and to permit persons to whom the Software is
 *   furnished to do so, subject to the following conditions:
 *
 *   The above copyright notice and this permission notice shall be included in all
 *   copies or substantial portions of the Software.
 *
 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 *   SOFTWARE.
 */
"use strict";

const {
  Fragment,
  Component,
  createElement,
  createRef,
} = require("resource://devtools/client/shared/vendor/react.js");

loader.lazyRequireGetter(
  this,
  "PropTypes",
  "resource://devtools/client/shared/vendor/react-prop-types.js"
);

// This element is a webconsole optimization for handling large numbers of
// console messages. The purpose is to only create DOM elements for messages
// which are actually visible within the scrollport. This code was based on
// Oleg Grishechkin's react-viewport-list element - however, it has been quite
// heavily modified, to the point that it is mostly unrecognizable. The most
// notable behavioral modification is that the list implements the behavior of
// pinning the scrollport to the bottom of the scroll container.
class LazyMessageList extends Component {
  static get propTypes() {
    return {
      viewportRef: PropTypes.shape({
        // Note that we can't use Element here because, the Element global is
        // exposed from base-loader and is not the same as window.Element.
        // Also PropTypes.instanceOf relies solely on `instanceof` and not on
        // isInstance, so we really need to use the actual constructor.
        current: PropTypes.instanceOf(window.Element),
      }).isRequired,
      items: PropTypes.array.isRequired,
      itemsToKeepAlive: PropTypes.shape({
        has: PropTypes.func,
        keys: PropTypes.func,
        size: PropTypes.number,
      }).isRequired,
      editorMode: PropTypes.bool.isRequired,
      itemDefaultHeight: PropTypes.number.isRequired,
      scrollOverdrawCount: PropTypes.number.isRequired,
      renderItem: PropTypes.func.isRequired,
      shouldScrollBottom: PropTypes.func.isRequired,
      cacheGeneration: PropTypes.number.isRequired,
      serviceContainer: PropTypes.shape({
        emitForTests: PropTypes.func.isRequired,
      }),
    };
  }

  constructor(props) {
    super(props);
    this.#initialized = false;
    this.#topBufferRef = createRef();
    this.#bottomBufferRef = createRef();
    this.#viewportHeight = window.innerHeight;
    this.#startIndex = 0;
    this.#resizeObserver = null;
    this.#cachedHeights = [];

    this.#scrollHandlerBinding = this.#scrollHandler.bind(this);
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillUpdate(nextProps) {
    if (nextProps.cacheGeneration !== this.props.cacheGeneration) {
      this.#cachedHeights = [];
      this.#startIndex = 0;
    } else if (
      (this.props.shouldScrollBottom() &&
        nextProps.items.length > this.props.items.length) ||
      this.#startIndex > nextProps.items.length - this.#numItemsToDraw
    ) {
      this.#startIndex = Math.max(
        0,
        nextProps.items.length - this.#numItemsToDraw
      );
    }
  }

  componentDidUpdate(prevProps) {
    const { viewportRef, serviceContainer } = this.props;
    if (!viewportRef.current || !this.#topBufferRef.current) {
      return;
    }

    if (!this.#initialized) {
      // We set these up from a one-time call in componentDidUpdate, rather than in
      // componentDidMount, because we need the parent to be mounted first, to add
      // listeners to it, and React orders things such that children mount before
      // parents.
      this.#addListeners();
    }

    if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) {
      this.#resizeObserver.observe(viewportRef.current);
    }

    this.#initialized = true;

    // Since we updated, we're now going to compute the heights of all visible
    // elements and store them in a cache. This allows us to get more accurate
    // buffer regions to make scrolling correct when these elements no longer
    // exist.
    let index = this.#startIndex;
    let element = this.#topBufferRef.current.nextSibling;
    let elementRect = element?.getBoundingClientRect();
    while (
      Element.isInstance(element) &&
      index < this.#clampedEndIndex &&
      element !== this.#bottomBufferRef.current
    ) {
      const next = element.nextSibling;
      const nextRect = next.getBoundingClientRect();
      this.#cachedHeights[index] = nextRect.top - elementRect.top;
      element = next;
      elementRect = nextRect;
      index++;
    }

    serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
  }

  componentWillUnmount() {
    this.#removeListeners();
  }

  #initialized;
  #topBufferRef;
  #bottomBufferRef;
  #viewportHeight;
  #startIndex;
  #resizeObserver;
  #cachedHeights;
  #scrollHandlerBinding;

  get #overdrawHeight() {
    return this.props.scrollOverdrawCount * this.props.itemDefaultHeight;
  }

  get #numItemsToDraw() {
    const scrollingWindowCount = Math.ceil(
      this.#viewportHeight / this.props.itemDefaultHeight
    );
    return scrollingWindowCount + 2 * this.props.scrollOverdrawCount;
  }

  get #unclampedEndIndex() {
    return this.#startIndex + this.#numItemsToDraw;
  }

  // Since the "end index" is computed based off a fixed offset from the start
  // index, it can exceed the length of our items array. This is just a helper
  // to ensure we don't exceed that.
  get #clampedEndIndex() {
    return Math.min(this.#unclampedEndIndex, this.props.items.length);
  }

  /**
   * Increases our start index until we've passed enough elements to cover
   * the difference in px between where we are and where we want to be.
   *
   * @param Number startIndex
   *        The current value of our start index.
   * @param Number deltaPx
   *        The difference in pixels between where we want to be and
   *        where we are.
   * @return {Number} The new computed start index.
   */
  #increaseStartIndex(startIndex, deltaPx) {
    for (let i = startIndex + 1; i < this.props.items.length; i++) {
      deltaPx -= this.#cachedHeights[i];
      startIndex = i;

      if (deltaPx <= 0) {
        break;
      }
    }
    return startIndex;
  }

  /**
   * Decreases our start index until we've passed enough elements to cover
   * the difference in px between where we are and where we want to be.
   *
   * @param Number startIndex
   *        The current value of our start index.
   * @param Number deltaPx
   *        The difference in pixels between where we want to be and
   *        where we are.
   * @return {Number} The new computed start index.
   */
  #decreaseStartIndex(startIndex, diff) {
    for (let i = startIndex - 1; i >= 0; i--) {
      diff -= this.#cachedHeights[i];
      startIndex = i;

      if (diff <= 0) {
        break;
      }
    }
    return startIndex;
  }

  #scrollHandler() {
    if (!this.props.viewportRef.current || !this.#topBufferRef.current) {
      return;
    }

    const scrollportMin =
      this.props.viewportRef.current.getBoundingClientRect().top -
      this.#overdrawHeight;
    const uppermostItemRect =
      this.#topBufferRef.current.nextSibling.getBoundingClientRect();
    const uppermostItemMin = uppermostItemRect.top;
    const uppermostItemMax = uppermostItemRect.bottom;

    let nextStartIndex = this.#startIndex;
    const downwardPx = scrollportMin - uppermostItemMax;
    const upwardPx = uppermostItemMin - scrollportMin;
    if (downwardPx > 0) {
      nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx);
    } else if (upwardPx > 0) {
      nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx);
    }

    nextStartIndex = Math.max(
      0,
      Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw)
    );

    if (nextStartIndex !== this.#startIndex) {
      this.#startIndex = nextStartIndex;
      this.forceUpdate();
    } else {
      const { serviceContainer } = this.props;
      serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
    }
  }

  #addListeners() {
    const { viewportRef } = this.props;
    viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding);
    this.#resizeObserver = new ResizeObserver(() => {
      this.#viewportHeight =
        viewportRef.current.parentNode.parentNode.clientHeight;
      this.forceUpdate();
    });
  }

  #removeListeners() {
    const { viewportRef } = this.props;
    this.#resizeObserver?.disconnect();
    viewportRef.current?.removeEventListener(
      "scroll",
      this.#scrollHandlerBinding
    );
  }

  get bottomBuffer() {
    return this.#bottomBufferRef.current;
  }

  isItemNearBottom(index) {
    return index >= this.props.items.length - this.#numItemsToDraw;
  }

  render() {
    const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } =
      this.props;
    if (!items.length) {
      return createElement(Fragment, {
        key: "LazyMessageList",
      });
    }

    // Resize our cached heights to fit if necessary.
    const countUncached = items.length - this.#cachedHeights.length;
    if (countUncached > 0) {
      // It would be lovely if javascript allowed us to resize an array in one
      // go. I think this is the closest we can get to that. This in theory
      // allows us to realloc, and doesn't require copying the whole original
      // array like concat does.
      this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight));
    }

    let topBufferHeight = 0;
    let bottomBufferHeight = 0;
    // We can't compute the bottom buffer height until the end, so we just
    // store the index of where it needs to go.
    let bottomBufferIndex = 0;
    let currentChild = 0;
    const startIndex = this.#startIndex;
    const endIndex = this.#clampedEndIndex;
    // We preallocate this array to avoid allocations in the loop. The minimum,
    // and typical length for it is the size of the body plus 2 for the top and
    // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just
    // add the size, since itemsToKeepAlive could in theory hold items which are
    // not even in the list.
    const children = new Array(endIndex - startIndex + 2);
    const pushChild = c => {
      if (currentChild >= children.length) {
        children.push(c);
      } else {
        children[currentChild] = c;
      }
      return currentChild++;
    };
    for (let i = 0; i < items.length; i++) {
      const itemId = items[i];
      if (i < startIndex) {
        if (i == 0 || itemsToKeepAlive.has(itemId)) {
          // If this is our first item, and we wouldn't otherwise be rendering
          // it, we want to ensure that it's at the beginning of our children
          // array to ensure keyboard navigation functions properly.
          pushChild(renderItem(itemId, i));
        } else {
          topBufferHeight += this.#cachedHeights[i];
        }
      } else if (i < endIndex) {
        if (i == startIndex) {
          pushChild(
            createElement("div", {
              key: "LazyMessageListTop",
              className: "lazy-message-list-top",
              ref: this.#topBufferRef,
              style: { height: topBufferHeight },
            })
          );
        }
        pushChild(renderItem(itemId, i));
        if (i == endIndex - 1) {
          // We're just reserving the bottom buffer's spot in the children
          // array here. We will create the actual element and assign it at
          // this index after the loop.
          bottomBufferIndex = pushChild(null);
        }
      } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) {
        // Similarly to the logic for our first item, we also want to ensure
        // that our last item is always rendered as the last item in our
        // children array.
        pushChild(renderItem(itemId, i));
      } else {
        bottomBufferHeight += this.#cachedHeights[i];
      }
    }

    children[bottomBufferIndex] = createElement("div", {
      key: "LazyMessageListBottom",
      className: "lazy-message-list-bottom",
      ref: this.#bottomBufferRef,
      style: { height: bottomBufferHeight },
    });

    return createElement(
      Fragment,
      {
        key: "LazyMessageList",
      },
      children
    );
  }
}

module.exports = LazyMessageList;
