/* 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 ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
const {
  FILTER_TAGS,
} = require("resource://devtools/client/netmonitor/src/constants.js");
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 { Chart } = require("resource://devtools/client/shared/widgets/Chart.js");
const { PluralForm } = require("resource://devtools/shared/plural-form.js");
const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
const {
  Filters,
} = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js");
const {
  getSizeWithDecimals,
  getTimeWithDecimals,
} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js");
const {
  L10N,
} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
const {
  getPerformanceAnalysisURL,
} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
const {
  fetchNetworkUpdatePacket,
} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");

// Components
const MDNLink = createFactory(
  require("resource://devtools/client/shared/components/MdnLink.js")
);

const { button, div } = dom;
const MediaQueryList = window.matchMedia("(min-width: 700px)");

const NETWORK_ANALYSIS_PIE_CHART_DIAMETER = 200;
const BACK_BUTTON = L10N.getStr("netmonitor.backButton");
const CHARTS_CACHE_ENABLED = L10N.getStr("charts.cacheEnabled");
const CHARTS_CACHE_DISABLED = L10N.getStr("charts.cacheDisabled");
const CHARTS_LEARN_MORE = L10N.getStr("charts.learnMore");

/*
 * Statistics panel component
 * Performance analysis tool which shows you how long the browser takes to
 * download the different parts of your site.
 */
class StatisticsPanel extends Component {
  static get propTypes() {
    return {
      connector: PropTypes.object.isRequired,
      closeStatistics: PropTypes.func.isRequired,
      enableRequestFilterTypeOnly: PropTypes.func.isRequired,
      hasLoad: PropTypes.bool,
      requests: PropTypes.array,
    };
  }

  constructor(props) {
    super(props);

    this.state = {
      isVerticalSpliter: MediaQueryList.matches,
    };

    this.createMDNLink = this.createMDNLink.bind(this);
    this.unmountMDNLinkContainers = this.unmountMDNLinkContainers.bind(this);
    this.createChart = this.createChart.bind(this);
    this.sanitizeChartDataSource = this.sanitizeChartDataSource.bind(this);
    this.responseIsFresh = this.responseIsFresh.bind(this);
    this.onLayoutChange = this.onLayoutChange.bind(this);
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillMount() {
    this.mdnLinkContainerNodes = new Map();
  }

  componentDidMount() {
    const { requests, connector } = this.props;
    requests.forEach(request => {
      fetchNetworkUpdatePacket(connector.requestData, request, [
        "eventTimings",
        "responseHeaders",
      ]);
    });
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    const { requests, connector } = nextProps;
    requests.forEach(request => {
      fetchNetworkUpdatePacket(connector.requestData, request, [
        "eventTimings",
        "responseHeaders",
      ]);
    });
  }

  componentDidUpdate(prevProps) {
    MediaQueryList.addListener(this.onLayoutChange);

    const { hasLoad, requests } = this.props;

    // Display statistics about all requests for which we received enough data,
    // as soon as the page is considered to be loaded
    const ready = requests.length && hasLoad;

    // Ignore requests which are missing data expected by this component:
    // - pending/incomplete requests
    // - blocked/errored requests
    const validRequests = requests.filter(
      req =>
        req.contentSize !== undefined &&
        req.mimeType &&
        req.responseHeaders &&
        req.status !== undefined &&
        req.totalTime !== undefined
    );

    this.createChart({
      id: "primedCacheChart",
      title: CHARTS_CACHE_ENABLED,
      data: ready ? this.sanitizeChartDataSource(validRequests, false) : null,
    });

    this.createChart({
      id: "emptyCacheChart",
      title: CHARTS_CACHE_DISABLED,
      data: ready ? this.sanitizeChartDataSource(validRequests, true) : null,
    });

    this.createMDNLink("primedCacheChart", getPerformanceAnalysisURL());
    this.createMDNLink("emptyCacheChart", getPerformanceAnalysisURL());
  }

  componentWillUnmount() {
    MediaQueryList.removeListener(this.onLayoutChange);
    this.unmountMDNLinkContainers();
  }

  createMDNLink(chartId, url) {
    if (this.mdnLinkContainerNodes.has(chartId)) {
      ReactDOM.unmountComponentAtNode(this.mdnLinkContainerNodes.get(chartId));
    }

    // MDNLink is a React component but Chart isn't.  To get the link
    // into the chart we mount a new ReactDOM at the appropriate
    // location after the chart has been created.
    const title = this.refs[chartId].querySelector(".table-chart-title");
    const containerNode = document.createElement("span");
    title.appendChild(containerNode);

    ReactDOM.render(
      MDNLink({
        url,
        title: CHARTS_LEARN_MORE,
      }),
      containerNode
    );
    this.mdnLinkContainerNodes.set(chartId, containerNode);
  }

  unmountMDNLinkContainers() {
    for (const [, node] of this.mdnLinkContainerNodes) {
      ReactDOM.unmountComponentAtNode(node);
    }
  }

  createChart({ id, title, data }) {
    // Create a new chart.
    const chart = Chart.PieTable(document, {
      diameter: NETWORK_ANALYSIS_PIE_CHART_DIAMETER,
      title,
      header: {
        count: L10N.getStr("charts.requestsNumber"),
        label: L10N.getStr("charts.type"),
        size: L10N.getStr("charts.size"),
        transferredSize: L10N.getStr("charts.transferred"),
        time: L10N.getStr("charts.time"),
        nonBlockingTime: L10N.getStr("charts.nonBlockingTime"),
      },
      data,
      strings: {
        size: value =>
          L10N.getFormatStr(
            "charts.size.kB",
            getSizeWithDecimals(value / 1000)
          ),
        transferredSize: value =>
          L10N.getFormatStr(
            "charts.transferredSize.kB",
            getSizeWithDecimals(value / 1000)
          ),
        time: value =>
          L10N.getFormatStr("charts.totalS", getTimeWithDecimals(value / 1000)),
        nonBlockingTime: value =>
          L10N.getFormatStr("charts.totalS", getTimeWithDecimals(value / 1000)),
      },
      totals: {
        cached: total => L10N.getFormatStr("charts.totalCached", total),
        count: total => L10N.getFormatStr("charts.totalCount", total),
        size: total =>
          L10N.getFormatStr(
            "charts.totalSize.kB",
            getSizeWithDecimals(total / 1000)
          ),
        transferredSize: total =>
          L10N.getFormatStr(
            "charts.totalTransferredSize.kB",
            getSizeWithDecimals(total / 1000)
          ),
        time: total => {
          const seconds = total / 1000;
          const string = getTimeWithDecimals(seconds);
          return PluralForm.get(
            seconds,
            L10N.getStr("charts.totalSeconds")
          ).replace("#1", string);
        },
        nonBlockingTime: total => {
          const seconds = total / 1000;
          const string = getTimeWithDecimals(seconds);
          return PluralForm.get(
            seconds,
            L10N.getStr("charts.totalSecondsNonBlocking")
          ).replace("#1", string);
        },
      },
      sorted: true,
    });

    chart.on("click", ({ label }) => {
      // Reset FilterButtons and enable one filter exclusively
      this.props.closeStatistics();
      this.props.enableRequestFilterTypeOnly(label);
    });

    const container = this.refs[id];

    // Nuke all existing charts of the specified type.
    while (container.hasChildNodes()) {
      container.firstChild.remove();
    }

    container.appendChild(chart.node);
  }

  sanitizeChartDataSource(requests, emptyCache) {
    const data = FILTER_TAGS.map(type => ({
      cached: 0,
      count: 0,
      label: type,
      size: 0,
      transferredSize: 0,
      time: 0,
      nonBlockingTime: 0,
    }));

    for (const request of requests) {
      let type;

      if (Filters.html(request)) {
        // "html"
        type = 0;
      } else if (Filters.css(request)) {
        // "css"
        type = 1;
      } else if (Filters.js(request)) {
        // "js"
        type = 2;
      } else if (Filters.fonts(request)) {
        // "fonts"
        type = 4;
      } else if (Filters.images(request)) {
        // "images"
        type = 5;
      } else if (Filters.media(request)) {
        // "media"
        type = 6;
      } else if (Filters.ws(request)) {
        // "ws"
        type = 7;
      } else if (Filters.xhr(request)) {
        // Verify XHR last, to categorize other mime types in their own blobs.
        // "xhr"
        type = 3;
      } else {
        // "other"
        type = 8;
      }

      if (emptyCache || !this.responseIsFresh(request)) {
        data[type].time += request.totalTime || 0;
        data[type].size += request.contentSize || 0;
        data[type].transferredSize += request.transferredSize || 0;
        const nonBlockingTime =
          request.eventTimings.totalTime - request.eventTimings.timings.blocked;
        data[type].nonBlockingTime += nonBlockingTime || 0;
      } else {
        data[type].cached++;
      }
      data[type].count++;
    }

    return data.filter(e => e.count > 0);
  }

  /**
   * Checks if the "Expiration Calculations" defined in section 13.2.4 of the
   * "HTTP/1.1: Caching in HTTP" spec holds true for a collection of headers.
   *
   * @param object
   *        An object containing the { responseHeaders, status } properties.
   * @return boolean
   *         True if the response is fresh and loaded from cache.
   */
  responseIsFresh({ responseHeaders, status }) {
    // Check for a "304 Not Modified" status and response headers availability.
    if (status != 304 || !responseHeaders) {
      return false;
    }

    const list = responseHeaders.headers;
    const cacheControl = list.find(
      e => e.name.toLowerCase() === "cache-control"
    );
    const expires = list.find(e => e.name.toLowerCase() === "expires");

    // Check the "Cache-Control" header for a maximum age value.
    if (cacheControl) {
      const maxAgeMatch =
        cacheControl.value.match(/s-maxage\s*=\s*(\d+)/) ||
        cacheControl.value.match(/max-age\s*=\s*(\d+)/);

      if (maxAgeMatch && maxAgeMatch.pop() > 0) {
        return true;
      }
    }

    // Check the "Expires" header for a valid date.
    if (expires && Date.parse(expires.value)) {
      return true;
    }

    return false;
  }

  onLayoutChange() {
    this.setState({
      isVerticalSpliter: MediaQueryList.matches,
    });
  }

  render() {
    const { closeStatistics } = this.props;
    const splitterClassName = ["splitter"];

    if (this.state.isVerticalSpliter) {
      splitterClassName.push("devtools-side-splitter");
    } else {
      splitterClassName.push("devtools-horizontal-splitter");
    }

    return div(
      { className: "statistics-panel" },
      button(
        {
          className: "back-button devtools-button",
          "data-text-only": "true",
          title: BACK_BUTTON,
          onClick: closeStatistics,
        },
        BACK_BUTTON
      ),
      div(
        { className: "charts-container" },
        div({
          ref: "primedCacheChart",
          className: "charts primed-cache-chart",
        }),
        div({ className: splitterClassName.join(" ") }),
        div({ ref: "emptyCacheChart", className: "charts empty-cache-chart" })
      )
    );
  }
}

module.exports = connect(
  state => ({
    // `firstDocumentLoadTimestamp` is set on timing markers when we receive
    // DOCUMENT_EVENT's dom-complete, which is equivalent to page `load` event.
    hasLoad: state.timingMarkers.firstDocumentLoadTimestamp != -1,
    requests: [...state.requests.requests],
  }),
  (dispatch, props) => ({
    closeStatistics: () =>
      dispatch(Actions.openStatistics(props.connector, false)),
    enableRequestFilterTypeOnly: label =>
      dispatch(Actions.enableRequestFilterTypeOnly(label)),
  })
)(StatisticsPanel);
