/* 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/>. */

import React, { Component } from "devtools/client/shared/vendor/react";
import {
  div,
  ul,
  li,
  span,
  h2,
  button,
} from "devtools/client/shared/vendor/react-dom-factories";
import PropTypes from "devtools/client/shared/vendor/react-prop-types";
import { connect } from "devtools/client/shared/vendor/react-redux";

import { containsPosition, positionAfter } from "../../utils/ast";
import { createLocation } from "../../utils/location";

import actions from "../../actions/index";
import {
  getSelectedLocation,
  getSelectedSourceTextContent,
} from "../../selectors/index";

import OutlineFilter from "./OutlineFilter";
import PreviewFunction from "../shared/PreviewFunction";

import { isFulfilled } from "../../utils/async-value";

const classnames = require("resource://devtools/client/shared/classnames.js");
const {
  score: fuzzaldrinScore,
} = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js");

// Set higher to make the fuzzaldrin filter more specific
const FUZZALDRIN_FILTER_THRESHOLD = 15000;

/**
 * Check whether the name argument matches the fuzzy filter argument
 */
const filterOutlineItem = (name, filter) => {
  if (!filter) {
    return true;
  }

  if (filter.length === 1) {
    // when filter is a single char just check if it starts with the char
    return filter.toLowerCase() === name.toLowerCase()[0];
  }
  return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD;
};

// Checks if an element is visible inside its parent element
function isVisible(element, parent) {
  const parentRect = parent.getBoundingClientRect();
  const elementRect = element.getBoundingClientRect();

  const parentTop = parentRect.top;
  const parentBottom = parentRect.bottom;
  const elTop = elementRect.top;
  const elBottom = elementRect.bottom;

  return parentTop < elTop && parentBottom > elBottom;
}

export class Outline extends Component {
  constructor(props) {
    super(props);
    this.focusedElRef = null;
    this.state = { filter: "", focusedItem: null, symbols: null };
  }

  static get propTypes() {
    return {
      alphabetizeOutline: PropTypes.bool.isRequired,
      onAlphabetizeClick: PropTypes.func.isRequired,
      selectLocation: PropTypes.func.isRequired,
      selectedLocation: PropTypes.object,
      getFunctionSymbols: PropTypes.func.isRequired,
      getClassSymbols: PropTypes.func.isRequired,
      selectedSourceTextContent: PropTypes.object,
      canFetchSymbols: PropTypes.bool,
    };
  }

  componentDidMount() {
    if (!this.props.canFetchSymbols) {
      return;
    }
    this.getClassAndFunctionSymbols();
  }

  componentDidUpdate(prevProps) {
    const { selectedLocation, selectedSourceTextContent, canFetchSymbols } =
      this.props;
    if (selectedLocation && selectedLocation !== prevProps.selectedLocation) {
      this.setFocus(selectedLocation);
    }

    if (
      this.focusedElRef &&
      !isVisible(this.focusedElRef, this.refs.outlineList)
    ) {
      this.focusedElRef.scrollIntoView({ block: "center" });
    }

    // Lets make sure the source text has been loaded and it is different
    if (
      canFetchSymbols &&
      prevProps.selectedSourceTextContent !== selectedSourceTextContent
    ) {
      this.getClassAndFunctionSymbols();
    }
  }

  async getClassAndFunctionSymbols() {
    const { selectedLocation, getFunctionSymbols, getClassSymbols } =
      this.props;

    const functions = await getFunctionSymbols(selectedLocation);
    const classes = await getClassSymbols(selectedLocation);

    this.setState({ symbols: { functions, classes } });
  }

  async setFocus(selectedLocation) {
    const { symbols } = this.state;

    let classes = [];
    let functions = [];

    if (symbols) {
      ({ classes, functions } = symbols);
    }

    // Find items that enclose the selected location
    const enclosedItems = [...classes, ...functions].filter(({ location }) =>
      containsPosition(location, selectedLocation)
    );

    if (!enclosedItems.length) {
      this.setState({ focusedItem: null });
      return;
    }

    // Find the closest item to the selected location to focus
    const closestItem = enclosedItems.reduce((item, closest) =>
      positionAfter(item.location, closest.location) ? item : closest
    );

    this.setState({ focusedItem: closestItem });
  }

  selectItem(selectedItem) {
    const { selectedLocation, selectLocation } = this.props;
    if (!selectedLocation || !selectedItem) {
      return;
    }

    selectLocation(
      createLocation({
        source: selectedLocation.source,
        line: selectedItem.location.start.line,
        column: selectedItem.location.start.column,
      })
    );

    this.setState({ focusedItem: selectedItem });
  }

  onContextMenu(event, func) {
    event.stopPropagation();
    event.preventDefault();

    const { symbols } = this.state;
    this.props.showOutlineContextMenu(event, func, symbols);
  }

  updateFilter = filter => {
    this.setState({ filter: filter.trim() });
  };

  renderPlaceholder() {
    const placeholderMessage = this.props.selectedLocation
      ? L10N.getStr("outline.noFunctions")
      : L10N.getStr("outline.noFileSelected");
    return div(
      {
        className: "outline-pane-info",
      },
      placeholderMessage
    );
  }

  renderLoading() {
    return div(
      {
        className: "outline-pane-info",
      },
      L10N.getStr("loadingText")
    );
  }

  renderFunction(func) {
    const { focusedItem } = this.state;
    const { name, location, parameterNames } = func;
    const isFocused = focusedItem === func;
    return li(
      {
        key: `${name}:${location.start.line}:${location.start.column}`,
        className: classnames("outline-list__element", {
          focused: isFocused,
        }),
        ref: el => {
          if (isFocused) {
            this.focusedElRef = el;
          }
        },
        onClick: () => this.selectItem(func),
        onContextMenu: e => this.onContextMenu(e, func),
      },
      span(
        {
          className: "outline-list__element-icon",
        },
        "λ"
      ),
      React.createElement(PreviewFunction, {
        func: {
          name,
          parameterNames,
        },
      })
    );
  }

  renderClassHeader(klass) {
    return div(
      null,
      span(
        {
          className: "keyword",
        },
        "class"
      ),
      " ",
      klass
    );
  }

  renderClassFunctions(klass, functions) {
    const { symbols } = this.state;

    if (!symbols || klass == null || !functions.length) {
      return null;
    }

    const { focusedItem } = this.state;
    const classFunc = functions.find(func => func.name === klass);
    const classFunctions = functions.filter(func => func.klass === klass);
    const classInfo = symbols.classes.find(c => c.name === klass);

    const item = classFunc || classInfo;
    const isFocused = focusedItem === item;

    return li(
      {
        className: "outline-list__class",
        ref: el => {
          if (isFocused) {
            this.focusedElRef = el;
          }
        },
        key: klass,
      },
      h2(
        {
          className: classnames({
            focused: isFocused,
          }),
          onClick: () => this.selectItem(item),
        },
        classFunc
          ? this.renderFunction(classFunc)
          : this.renderClassHeader(klass)
      ),
      ul(
        {
          className: "outline-list__class-list",
        },
        classFunctions.map(func => this.renderFunction(func))
      )
    );
  }

  renderFunctions(functions) {
    const { filter } = this.state;
    let classes = [...new Set(functions.map(({ klass }) => klass))];
    const namedFunctions = functions.filter(
      ({ name, klass }) =>
        filterOutlineItem(name, filter) && !klass && !classes.includes(name)
    );
    const classFunctions = functions.filter(
      ({ name, klass }) => filterOutlineItem(name, filter) && !!klass
    );

    if (this.props.alphabetizeOutline) {
      const sortByName = (a, b) => (a.name < b.name ? -1 : 1);
      namedFunctions.sort(sortByName);
      classes = classes.sort();
      classFunctions.sort(sortByName);
    }
    return ul(
      {
        ref: "outlineList",
        className: "outline-list devtools-monospace",
        dir: "ltr",
      },
      namedFunctions.map(func => this.renderFunction(func)),
      classes.map(klass => this.renderClassFunctions(klass, classFunctions))
    );
  }

  renderFooter() {
    return div(
      {
        className: "outline-footer",
      },
      button(
        {
          onClick: this.props.onAlphabetizeClick,
          className: this.props.alphabetizeOutline ? "active" : "",
        },
        L10N.getStr("outline.sortLabel")
      )
    );
  }

  render() {
    const { selectedLocation } = this.props;
    const { filter, symbols } = this.state;

    if (!selectedLocation) {
      return this.renderPlaceholder();
    }

    if (!symbols) {
      return this.renderLoading();
    }

    const { functions } = symbols;

    if (functions.length === 0) {
      return this.renderPlaceholder();
    }

    return div(
      {
        className: "outline",
      },
      div(
        null,
        React.createElement(OutlineFilter, {
          filter,
          updateFilter: this.updateFilter,
        }),
        this.renderFunctions(functions),
        this.renderFooter()
      )
    );
  }
}

const mapStateToProps = state => {
  const selectedSourceTextContent = getSelectedSourceTextContent(state);
  return {
    selectedSourceTextContent,
    selectedLocation: getSelectedLocation(state),
    canFetchSymbols:
      selectedSourceTextContent && isFulfilled(selectedSourceTextContent),
  };
};

export default connect(mapStateToProps, {
  selectLocation: actions.selectLocation,
  showOutlineContextMenu: actions.showOutlineContextMenu,
  getFunctionSymbols: actions.getFunctionSymbols,
  getClassSymbols: actions.getClassSymbols,
})(Outline);
