import { WS_ABNORMAL_CLOSURE, WS_NORMAL_CLOSURE, processNeighborData, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';

import ErrorBanner from './ErrorBanner.jsx';
import Percentage from './util/Percentage.js';
import PropTypes from 'prop-types';
import React from 'react';
import TopEventTable from './TopEventTable.jsx';
import _cloneDeep from 'lodash/cloneDeep';
import _each from 'lodash/each';
import _get from 'lodash/get';
import _has from 'lodash/has';
import _includes from 'lodash/includes';
import _isEmpty from 'lodash/isEmpty';
import _isEqual from 'lodash/isEqual';
import _isNil from 'lodash/isNil';
import _noop from 'lodash/noop';
import _size from 'lodash/size';
import _take from 'lodash/take';
import _throttle from 'lodash/throttle';
import _values from 'lodash/values';
import { withContext } from './util/AppContext.jsx';

// https://github.com/grpc/grpc/blob/master/doc/statuscodes.md
const grpcErrorStatusCodes = [2, 4, 13, 14, 15];

class TopModule extends React.Component {
  constructor(props) {
    super(props);
    this.tapResultsById = {};
    this.topEventIndex = {};
    this.throttledWebsocketRecvHandler = _throttle(this.updateTapEventIndexState, 500);
    this.updateTapClosingState = props.updateTapClosingState;
    this.unmeshedSources = {};

    this.state = {
      error: null,
      topEventIndex: {},
    };
  }

  componentDidMount() {
    const { startTap } = this.props;
    if (startTap) {
      this.startTapStreaming();
    }
  }

  componentDidUpdate(prevProps) {
    const { startTap, query } = this.props;
    if (startTap && !prevProps.startTap) {
      this.startTapStreaming();
    }
    if (!startTap && prevProps.startTap) {
      this.stopTapStreaming();
    }
    if (!_isEqual(query, prevProps.query)) {
      this.clearTopTable();
    }
  }

  componentWillUnmount() {
    this.throttledWebsocketRecvHandler.cancel();
    this.stopTapStreaming();
    this.updateTapClosingState = _noop;
  }

  onWebsocketOpen = () => {
    const { query } = this.props;
    const tapQuery = _cloneDeep(query);
    setMaxRps(tapQuery);

    this.ws.send(JSON.stringify({
      id: 'top-web',
      ...tapQuery,
    }));
    this.setState({
      error: null,
    });
  }

  onWebsocketRecv = e => {
    this.indexTapResult(e.data);
    this.throttledWebsocketRecvHandler();
  }

  onWebsocketClose = e => {
    this.updateTapClosingState();
    /* We ignore any abnormal closure since it doesn't matter as long as
    the connection to the websocket is closed. This is also a workaround
    where Chrome browsers incorrectly displays a 1006 close code
    https://github.com/linkerd/linkerd2/issues/1630
    */
    if (e.code !== WS_NORMAL_CLOSURE && e.code !== WS_ABNORMAL_CLOSURE) {
      this.setState({
        error: {
          error: `Websocket close error [${e.code}: ${wsCloseCodes[e.code]}] ${e.reason ? ':' : ''} ${e.reason}`,
        },
      });
    }
  }

  onWebsocketError = e => {
    this.setState({
      error: { error: `Websocket error: ${e.message}` },
    });
  }

  closeWebSocket = () => {
    if (this.ws) {
      this.ws.close(1000);
    }
  }

  parseTapResult = data => {
    const d = processTapEvent(data);

    if (d.eventType === 'responseEnd') {
      d.latency = parseFloat(d.http.responseEnd.sinceRequestInit.replace('s', ''));
      d.completed = true;
    }

    return d;
  }

  topEventKey = event => {
    const sourceKey = event.source.owner || event.source.pod || event.source.str;
    const dstKey = event.destination.owner || event.destination.pod || event.destination.str;

    return [sourceKey, dstKey, _get(event, 'http.requestInit.method.registered'), event.http.requestInit.path].join('_');
  }

  initialTopResult = (d, eventKey) => {
    // in the event that we key on resources with multiple pods/ips, store them so we can display
    const sourceDisplay = {
      ips: {},
      pods: {},
    };
    sourceDisplay.ips[d.base.source.str] = true;
    if (!_isNil(d.base.source.pod)) {
      sourceDisplay.pods[d.base.source.pod] = d.base.source.namespace;
    }

    const destinationDisplay = {
      ips: {},
      pods: {},
    };
    destinationDisplay.ips[d.base.destination.str] = true;
    if (!_isNil(d.base.destination.pod)) {
      destinationDisplay.pods[d.base.destination.pod] = d.base.destination.namespace;
    }

    return {
      count: 1,
      best: d.responseEnd.latency,
      worst: d.responseEnd.latency,
      last: d.responseEnd.latency,
      success: !d.success ? 0 : 1,
      failure: !d.success ? 1 : 0,
      meshed: true,
      successRate: !d.success ? new Percentage(0, 1) : new Percentage(1, 1),
      direction: d.base.proxyDirection,
      source: d.requestInit.source,
      sourceLabels: d.requestInit.sourceMeta.labels,
      sourceDisplay,
      destination: d.requestInit.destination,
      destinationLabels: d.requestInit.destinationMeta.labels,
      destinationDisplay,
      httpMethod: _get(d, 'requestInit.http.requestInit.method.registered'),
      path: d.requestInit.http.requestInit.path,
      key: eventKey,
      lastUpdated: Date.now(),
    };
  }

  incrementTopResult = (d, result) => {
    result.count += 1;
    if (!d.success) {
      result.failure += 1;
    } else {
      result.success += 1;
    }
    result.successRate = new Percentage(result.success, result.success + result.failure);

    result.last = d.responseEnd.latency;
    if (d.responseEnd.latency < result.best) {
      result.best = d.responseEnd.latency;
    }
    if (d.responseEnd.latency > result.worst) {
      result.worst = d.responseEnd.latency;
    }

    result.sourceDisplay.ips[d.base.source.str] = true;
    if (!_isNil(d.requestInit.sourceMeta.labels.pod)) {
      result.sourceDisplay.pods[d.requestInit.sourceMeta.labels.pod] = d.requestInit.sourceMeta.labels.namespace;
    }
    result.destinationDisplay.ips[d.base.destination.str] = true;
    if (!_isNil(d.requestInit.destinationMeta.labels.pod)) {
      result.destinationDisplay.pods[d.requestInit.destinationMeta.labels.pod] = d.requestInit.destinationMeta.labels.namespace;
    }

    result.lastUpdated = Date.now();
  }

  indexTopResult = (d, topResults) => {
    const { query, maxRowsToStore } = this.props;

    // only index if have the full request (i.e. init and end)
    if (!d.requestInit) {
      return topResults;
    }

    const eventKey = this.topEventKey(d.requestInit);
    this.addSuccessCount(d);

    if (!topResults[eventKey]) {
      topResults[eventKey] = this.initialTopResult(d, eventKey);
    } else {
      this.incrementTopResult(d, topResults[eventKey]);
    }

    if (_size(topResults) > maxRowsToStore) {
      this.deleteOldestIndexedResult(topResults);
    }

    if (d.base.proxyDirection === 'INBOUND') {
      this.updateNeighborsFromTapData(d.requestInit.source, _get(d, 'requestInit.sourceMeta.labels'));
      const isPod = query.resource.split('/')[0] === 'pod';
      if (isPod) {
        topResults[eventKey].meshed = !_has(this.unmeshedSources, `pod/${d.base.source.pod}`);
      } else {
        topResults[eventKey].meshed = !_isEmpty(d.base.source.owner) && !_has(this.unmeshedSources, d.base.source.owner);
      }
    }

    return topResults;
  }

  updateTapEventIndexState = () => {
    // tap websocket events come in at a really high, bursty rate
    // calling setState every time an event comes in causes a lot of re-rendering
    // and causes the page to freeze. To fix this, limit the times we
    // update the state (and thus trigger a render)
    this.setState({
      topEventIndex: this.topEventIndex,
    });
  }

  updateNeighborsFromTapData = (source, sourceLabels) => {
    const { query, updateUnmeshedSources } = this.props;

    // store this outside of state, as updating the state upon every websocket event received
    // is very costly and causes the page to freeze up
    const resourceType = _isNil(query.resource) ? '' : query.resource.split('/')[0];
    this.unmeshedSources = processNeighborData(source, sourceLabels, this.unmeshedSources, resourceType);
    updateUnmeshedSources(this.unmeshedSources);
  }

  // keep an index of tap results by id until the request is complete.
  // when the request has completed, add it to the aggregated Top counts and
  // discard the individual tap result
  indexTapResult = data => {
    const { maxRowsToStore } = this.props;

    const resultIndex = this.tapResultsById;
    const d = this.parseTapResult(data);

    if (_isNil(resultIndex[d.id])) {
      // don't let tapResultsById grow unbounded
      if (_size(resultIndex) > maxRowsToStore) {
        this.deleteOldestIndexedResult(resultIndex);
      }

      resultIndex[d.id] = {};
    }
    resultIndex[d.id][d.eventType] = d;

    // assumption: requests of a given id all share the same high level metadata
    resultIndex[d.id].base = d;
    resultIndex[d.id].lastUpdated = Date.now();

    if (d.completed) {
      // only add results into top if the request has completed
      // we can also now delete this result from the Tap result index
      this.topEventIndex = this.indexTopResult(resultIndex[d.id], this.topEventIndex);
      delete resultIndex[d.id];
    }
  }

  addSuccessCount = d => {
    // cope with the fact that gRPC failures are returned with HTTP status 200
    // and correctly classify gRPC failures as failures
    let success = parseInt(_get(d, 'responseInit.http.responseInit.httpStatus'), 10) < 500;
    if (success) {
      const grpcStatusCode = _get(d, 'responseEnd.http.responseEnd.eos.grpcStatusCode');
      if (!_isNil(grpcStatusCode)) {
        success = !_includes(grpcErrorStatusCodes, grpcStatusCode);
      } else if (!_isNil(_get(d, 'responseEnd.http.responseEnd.eos.resetErrorCode'))) {
        success = false;
      }
    }

    d.success = success;
  }

  deleteOldestIndexedResult = resultIndex => {
    let oldest = Date.now();
    let oldestId = '';

    _each(resultIndex, (res, id) => {
      if (res.lastUpdated < oldest) {
        oldest = res.lastUpdated;
        oldestId = id;
      }
    });

    delete resultIndex[oldestId];
  }

  clearTopTable() {
    this.tapResultsById = {};
    this.topEventIndex = {};
    this.setState({
      topEventIndex: {},
    });
  }

  startTapStreaming() {
    const { pathPrefix } = this.props;

    this.clearTopTable();

    const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
    const tapWebSocket = `${protocol}://${window.location.host}${pathPrefix}/api/tap`;

    this.ws = new WebSocket(tapWebSocket);
    this.ws.onmessage = this.onWebsocketRecv;
    this.ws.onclose = this.onWebsocketClose;
    this.ws.onopen = this.onWebsocketOpen;
    this.ws.onerror = this.onWebsocketError;
  }

  stopTapStreaming() {
    this.closeWebSocket();
  }

  banner = () => {
    const { error } = this.state;
    return error ? <ErrorBanner message={error} /> : null;
  }

  render() {
    const { topEventIndex } = this.state;
    const { query, maxRowsToDisplay } = this.props;

    const tableRows = _take(_values(topEventIndex), maxRowsToDisplay);
    const resourceType = _isNil(query.resource) ? '' : query.resource.split('/')[0];

    return (
      <React.Fragment>
        {this.banner()}
        <TopEventTable resourceType={resourceType} tableRows={tableRows} />
      </React.Fragment>
    );
  }
}

TopModule.propTypes = {
  maxRowsToDisplay: PropTypes.number,
  maxRowsToStore: PropTypes.number,
  pathPrefix: PropTypes.string.isRequired,
  query: PropTypes.shape({
    resource: PropTypes.string,
  }),
  startTap: PropTypes.bool.isRequired,
  updateTapClosingState: PropTypes.func,
  updateUnmeshedSources: PropTypes.func,
};

TopModule.defaultProps = {
  // max aggregated top rows to index and display in table
  maxRowsToDisplay: 40,
  // max rows to keep in index. there are two indexes we keep:
  // - un-ended tap results, pre-aggregation into the top counts
  // - aggregated top rows
  maxRowsToStore: 50,
  updateTapClosingState: _noop,
  updateUnmeshedSources: _noop,
  query: {
    resource: '',
  },
};

export default withContext(TopModule);
