import {PureComponent} from 'react';
import isEqual from 'lodash/isEqual';
import omitBy from 'lodash/omitBy';

import {doEventsRequest} from 'sentry/actionCreators/events';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {Client} from 'sentry/api';
import LoadingPanel from 'sentry/components/charts/loadingPanel';
import {
  canIncludePreviousPeriod,
  getPreviousSeriesName,
  isMultiSeriesStats,
} from 'sentry/components/charts/utils';
import {t} from 'sentry/locale';
import {
  DateString,
  EventsStats,
  EventsStatsData,
  MultiSeriesEventsStats,
  OrganizationSummary,
} from 'sentry/types';
import {Series, SeriesDataUnit} from 'sentry/types/echarts';
import {defined} from 'sentry/utils';
import {DURATION_UNITS, SIZE_UNITS} from 'sentry/utils/discover/fieldRenderers';
import {
  AggregationOutputType,
  getAggregateAlias,
  stripEquationPrefix,
} from 'sentry/utils/discover/fields';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';

export type TimeSeriesData = {
  allTimeseriesData?: EventsStatsData;
  comparisonTimeseriesData?: Series[];
  originalPreviousTimeseriesData?: EventsStatsData | null;
  originalTimeseriesData?: EventsStatsData;
  previousTimeseriesData?: Series[] | null;
  timeAggregatedData?: Series | {};
  timeframe?: {end: number; start: number};
  // timeseries data
  timeseriesData?: Series[];
  timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  timeseriesTotals?: {count: number};
  yAxis?: string | string[];
};

type LoadingStatus = {
  /**
   * Whether there was an error retrieving data
   */
  errored: boolean;
  loading: boolean;
  reloading: boolean;
  errorMessage?: string;
};

// Can hold additional data from the root an events stat object (eg. start, end, order, isMetricsData).
interface AdditionalSeriesInfo {
  isExtrapolatedData?: boolean;
  isMetricsData?: boolean;
}

export type RenderProps = LoadingStatus &
  TimeSeriesData & {
    results?: Series[]; // Chart with multiple series.
    seriesAdditionalInfo?: Record<string, AdditionalSeriesInfo>;
  };

type DefaultProps = {
  /**
   * Include data for previous period
   */
  includePrevious: boolean;
  /**
   * Transform the response data to be something ingestible by charts
   */
  includeTransformedData: boolean;
  /**
   * Interval to group results in
   *
   * e.g. 1d, 1h, 1m, 1s
   */
  interval: string;
  /**
   * number of rows to return
   */
  limit: number;
  /**
   * The query string to search events by
   */
  query: string;
  /**
   * Time delta for comparing intervals of alert metrics, in seconds
   */
  comparisonDelta?: number;
  /**
   * Absolute end date for query
   */
  end?: DateString;
  /**
   * Relative time period for query.
   *
   * Use `start` and `end` for absolute dates.
   *
   * e.g. 24h, 7d, 30d
   */
  period?: string | null;
  /**
   * Absolute start date for query
   */
  start?: DateString;
};

type EventsRequestPartialProps = {
  /**
   * API client instance
   */
  api: Client;
  children: (renderProps: RenderProps) => React.ReactNode;
  organization: OrganizationSummary;
  /**
   * Whether or not to include the last partial bucket. This happens for example when the
   * current time is 11:26 and the last bucket ranges from 11:25-11:30. This means that
   * the last bucket contains 1 minute worth of data while the rest contains 5 minutes.
   *
   * This flag indicates whether or not this last bucket should be included in the result.
   */
  partial: boolean;
  /**
   * Discover needs confirmation to run >30 day >10 project queries,
   * optional and when not passed confirmation is not required.
   */
  confirmedQuery?: boolean;
  /**
   * Name used for display current series dataset tooltip
   */
  currentSeriesNames?: string[];
  /**
   * Optional callback to further process raw events request response data
   */
  dataLoadedCallback?: (any: EventsStats | MultiSeriesEventsStats | null) => void;
  /**
   * Specify the dataset to query from. Defaults to discover.
   */
  dataset?: DiscoverDatasets;
  /**
   * List of environments to query
   */
  environment?: Readonly<string[]>;
  /**
   * Is query out of retention
   */
  expired?: boolean;
  /**
   * List of fields to group with when doing a topEvents request.
   */
  field?: string[];
  /**
   * Allows overriding the pathname.
   */
  generatePathname?: (org: OrganizationSummary) => string;
  /**
   * Hide error toast (used for pages which also query discover). Stops error appearing as a toast.
   */
  hideError?: boolean;
  /**
   * Initial loading state
   */
  loading?: boolean;
  /**
   * Query name used for displaying error toast if it is out of retention
   */
  name?: string;
  /**
   * A way to control error if error handling is not owned by the toast.
   */
  onError?: (error: string) => void;
  /**
   * How to order results when getting top events.
   */
  orderby?: string;
  previousSeriesNames?: string[];
  /**
   * List of project ids to query
   */
  project?: Readonly<number[]>;
  /**
   * A container for query batching data and functions.
   */
  queryBatching?: QueryBatching;
  /**
   * Extra query parameters to be added.
   */
  queryExtras?: Record<string, string>;
  /**
   * A unique name for what's triggering this request, see organization_events_stats for an allowlist
   */
  referrer?: string;
  /**
   * Sample rate used for data extrapolation in OnDemandMetricsRequest
   */
  sampleRate?: number;
  /**
   * Should loading be shown.
   */
  showLoading?: boolean;
  /**
   * List of team ids to query
   */
  team?: Readonly<string | string[]>;
  /**
   * The number of top results to get. When set a multi-series result will be returned
   * in the `results` child render function.
   */
  topEvents?: number;
  /**
   * Whether or not to use on demand metrics
   * This is a temporary flag to allow us to test on demand metrics
   */
  useOnDemandMetrics?: boolean;
  /**
   * Whether or not to zerofill results
   */
  withoutZerofill?: boolean;
  /**
   * The yAxis being plotted. If multiple yAxis are requested,
   * the child render function will be called with `results`
   */
  yAxis?: string | string[];
};

interface EventsRequestPropsWithTimeAggregation
  extends DefaultProps,
    EventsRequestPartialProps {
  includeTimeAggregation: true;
  timeAggregationSeriesName: string;
}

interface EventsRequestPropsWithoutTimeAggregation
  extends DefaultProps,
    EventsRequestPartialProps {
  includeTimeAggregation?: false;
  timeAggregationSeriesName?: undefined;
}

export type EventsRequestProps =
  | EventsRequestPropsWithTimeAggregation
  | EventsRequestPropsWithoutTimeAggregation;

type EventsRequestState = {
  errored: boolean;
  fetchedWithPrevious: boolean;
  reloading: boolean;
  timeseriesData: null | EventsStats | MultiSeriesEventsStats;
  errorMessage?: string;
};

const propNamesToIgnore = [
  'api',
  'children',
  'organization',
  'loading',
  'queryBatching',
  'generatePathname',
];
const omitIgnoredProps = (props: EventsRequestProps) =>
  omitBy(props, (_value, key) => propNamesToIgnore.includes(key));

class EventsRequest extends PureComponent<EventsRequestProps, EventsRequestState> {
  static defaultProps: DefaultProps = {
    period: undefined,
    start: null,
    end: null,
    interval: '1d',
    comparisonDelta: undefined,
    limit: 15,
    query: '',
    includePrevious: true,
    includeTransformedData: true,
  };

  state: EventsRequestState = {
    reloading: !!this.props.loading,
    errored: false,
    timeseriesData: null,
    fetchedWithPrevious: false,
  };

  componentDidMount() {
    this.fetchData();
  }

  componentDidUpdate(prevProps: EventsRequestProps) {
    if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
      return;
    }
    this.fetchData();
  }

  componentWillUnmount() {
    this.unmounting = true;
  }

  private unmounting: boolean = false;

  fetchData = async () => {
    const {api, confirmedQuery, onError, expired, name, hideError, ...props} = this.props;
    let timeseriesData: EventsStats | MultiSeriesEventsStats | null = null;

    if (confirmedQuery === false) {
      return;
    }

    this.setState(state => ({
      reloading: state.timeseriesData !== null,
      errored: false,
      errorMessage: undefined,
    }));

    let errorMessage;
    if (expired) {
      errorMessage = t(
        '%s has an invalid date range. Please try a more recent date range.',
        name
      );
      addErrorMessage(errorMessage, {append: true});

      this.setState({
        errored: true,
        errorMessage,
      });
    } else {
      try {
        api.clear();
        timeseriesData = await doEventsRequest(api, props);
      } catch (resp) {
        if (resp && resp.responseJSON && resp.responseJSON.detail) {
          errorMessage = resp.responseJSON.detail;
        } else {
          errorMessage = t('Error loading chart data');
        }
        if (!hideError) {
          addErrorMessage(errorMessage);
        }
        if (onError) {
          onError(errorMessage);
        }
        this.setState({
          errored: true,
          errorMessage,
        });
      }
    }

    if (this.unmounting) {
      return;
    }

    this.setState({
      reloading: false,
      timeseriesData,
      fetchedWithPrevious: props.includePrevious,
    });
    if (props.dataLoadedCallback) {
      props.dataLoadedCallback(timeseriesData);
    }
  };

  /**
   * Retrieves dataset for the current period (since data can potentially
   * contain previous period's data), as well as the previous period if
   * possible.
   *
   * Returns `null` if data does not exist
   */
  getData = (
    data: EventsStatsData = []
  ): {current: EventsStatsData; previous: EventsStatsData | null} => {
    const {fetchedWithPrevious} = this.state;
    const {period, includePrevious} = this.props;

    const hasPreviousPeriod =
      fetchedWithPrevious || canIncludePreviousPeriod(includePrevious, period);
    // Take the floor just in case, but data should always be divisible by 2
    const dataMiddleIndex = Math.floor(data.length / 2);
    return {
      current: hasPreviousPeriod ? data.slice(dataMiddleIndex) : data,
      previous: hasPreviousPeriod ? data.slice(0, dataMiddleIndex) : null,
    };
  };

  // This aggregates all values per `timestamp`
  calculateTotalsPerTimestamp(
    data: EventsStatsData,
    getName: (
      timestamp: number,
      countArray: {count: number}[],
      i: number
    ) => number = timestamp => timestamp * 1000
  ): SeriesDataUnit[] {
    return data.map(([timestamp, countArray], i) => ({
      name: getName(timestamp, countArray, i),
      value: countArray.reduce((acc, {count}) => acc + count, 0),
    }));
  }

  /**
   * Get previous period data, but transform timestamps so that data fits unto
   * the current period's data axis
   */
  transformPreviousPeriodData(
    current: EventsStatsData,
    previous: EventsStatsData | null,
    seriesName?: string
  ): Series | null {
    // Need the current period data array so we can take the timestamp
    // so we can be sure the data lines up
    if (!previous) {
      return null;
    }

    return {
      seriesName: seriesName ?? 'Previous',
      data: this.calculateTotalsPerTimestamp(
        previous,
        (_timestamp, _countArray, i) => current[i][0] * 1000
      ),
      stack: 'previous',
    };
  }

  /**
   * Aggregate all counts for each time stamp
   */
  transformAggregatedTimeseries(data: EventsStatsData, seriesName: string = ''): Series {
    return {
      seriesName,
      data: this.calculateTotalsPerTimestamp(data),
    };
  }

  /**
   * Transforms query response into timeseries data to be used in a chart
   */
  transformTimeseriesData(
    data: EventsStatsData,
    meta: EventsStats['meta'],
    seriesName?: string
  ): Series[] {
    let scale = 1;
    if (seriesName) {
      const unit = meta?.units?.[getAggregateAlias(seriesName)];
      // Scale series values to milliseconds or bytes depending on units from meta
      scale = (unit && (DURATION_UNITS[unit] ?? SIZE_UNITS[unit])) ?? 1;
    }

    return [
      {
        seriesName: seriesName || 'Current',
        data: data.map(([timestamp, countsForTimestamp]) => ({
          name: timestamp * 1000,
          value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0) * scale,
        })),
      },
    ];
  }

  /**
   * Transforms comparisonCount in query response into timeseries data to be used in a comparison chart for change alerts
   */
  transformComparisonTimeseriesData(data: EventsStatsData): Series[] {
    return [
      {
        seriesName: 'comparisonCount()',
        data: data.map(([timestamp, countsForTimestamp]) => ({
          name: timestamp * 1000,
          value: countsForTimestamp.reduce(
            (acc, {comparisonCount}) => acc + (comparisonCount ?? 0),
            0
          ),
        })),
      },
    ];
  }

  processData(response: EventsStats, seriesIndex: number = 0, seriesName?: string) {
    const {data, isMetricsData, totals, meta, isExtrapolatedData} = response;
    const {
      includeTransformedData,
      includeTimeAggregation,
      timeAggregationSeriesName,
      currentSeriesNames,
      previousSeriesNames,
      comparisonDelta,
    } = this.props;
    const {current, previous} = this.getData(data);
    const transformedData = includeTransformedData
      ? this.transformTimeseriesData(
          current,
          meta,
          seriesName ?? currentSeriesNames?.[seriesIndex]
        )
      : [];
    const transformedComparisonData =
      includeTransformedData && comparisonDelta
        ? this.transformComparisonTimeseriesData(current)
        : [];
    const previousData = includeTransformedData
      ? this.transformPreviousPeriodData(
          current,
          previous,
          (seriesName ? getPreviousSeriesName(seriesName) : undefined) ??
            previousSeriesNames?.[seriesIndex]
        )
      : null;
    const timeAggregatedData = includeTimeAggregation
      ? this.transformAggregatedTimeseries(current, timeAggregationSeriesName || '')
      : {};
    const timeframe =
      response.start && response.end
        ? !previous
          ? {
              start: response.start * 1000,
              end: response.end * 1000,
            }
          : {
              // Find the midpoint of start & end since previous includes 2x data
              start: (response.start + response.end) * 500,
              end: response.end * 1000,
            }
        : undefined;

    const processedData = {
      data: transformedData,
      comparisonData: transformedComparisonData,
      allData: data,
      originalData: current,
      totals,
      isMetricsData,
      originalPreviousData: previous,
      previousData,
      timeAggregatedData,
      timeframe,
      isExtrapolatedData,
    };

    return processedData;
  }

  render() {
    const {children, showLoading, ...props} = this.props;
    const {topEvents, yAxis} = this.props;
    const {timeseriesData, reloading, errored, errorMessage} = this.state;
    // Is "loading" if data is null
    const loading = this.props.loading || timeseriesData === null;

    if (showLoading && loading) {
      return <LoadingPanel data-test-id="events-request-loading" />;
    }
    if (isMultiSeriesStats(timeseriesData, defined(topEvents))) {
      // Convert multi-series results into chartable series. Multi series results
      // are created when multiple yAxis are used or a topEvents request is made.
      // Convert the timeseries data into a multi-series result set.
      // As the server will have replied with a map like:
      // {[titleString: string]: EventsStats}
      let timeframe: {end: number; start: number} | undefined = undefined;
      const seriesAdditionalInfo: Record<string, AdditionalSeriesInfo> = {};
      const sortedTimeseriesData = Object.keys(timeseriesData)
        .map(
          (
            seriesName: string,
            index: number
          ): [number, Series, Series | null, AdditionalSeriesInfo] => {
            const seriesData: EventsStats = timeseriesData[seriesName];
            const processedData = this.processData(
              seriesData,
              index,
              stripEquationPrefix(seriesName)
            );
            if (!timeframe) {
              timeframe = processedData.timeframe;
            }
            if (processedData.isMetricsData) {
              seriesAdditionalInfo[seriesName] = {
                isMetricsData: processedData.isMetricsData,
              };
            }
            return [
              seriesData.order || 0,
              processedData.data[0],
              processedData.previousData,
              {isMetricsData: processedData.isMetricsData},
            ];
          }
        )
        .sort((a, b) => a[0] - b[0]);
      const timeseriesResultsTypes: Record<string, AggregationOutputType> = {};
      Object.keys(timeseriesData).forEach(key => {
        const fieldsMeta = timeseriesData[key].meta?.fields[getAggregateAlias(key)];
        if (fieldsMeta) {
          timeseriesResultsTypes[key] = fieldsMeta;
        }
      });
      const results: Series[] = sortedTimeseriesData.map(item => {
        return item[1];
      });
      const previousTimeseriesData: Series[] | undefined = sortedTimeseriesData.some(
        item => item[2] === null
      )
        ? undefined
        : sortedTimeseriesData.map(item => {
            return item[2] as Series;
          });

      return children({
        loading,
        reloading,
        errored,
        errorMessage,
        results,
        timeframe,
        previousTimeseriesData,
        seriesAdditionalInfo,
        timeseriesResultsTypes,
        // sometimes we want to reference props that were given to EventsRequest
        ...props,
      });
    }
    if (timeseriesData) {
      const yAxisKey = yAxis && (typeof yAxis === 'string' ? yAxis : yAxis[0]);
      const yAxisFieldType =
        yAxisKey && timeseriesData.meta?.fields[getAggregateAlias(yAxisKey)];
      const timeseriesResultsTypes = yAxisFieldType
        ? {[yAxisKey]: yAxisFieldType}
        : undefined;
      const {
        data: transformedTimeseriesData,
        comparisonData: transformedComparisonTimeseriesData,
        allData: allTimeseriesData,
        originalData: originalTimeseriesData,
        totals: timeseriesTotals,
        originalPreviousData: originalPreviousTimeseriesData,
        previousData: previousTimeseriesData,
        timeAggregatedData,
        timeframe,
        isMetricsData,
        isExtrapolatedData,
      } = this.processData(timeseriesData);

      const seriesAdditionalInfo = {
        [this.props.currentSeriesNames?.[0] ?? 'current']: {
          isMetricsData,
          isExtrapolatedData,
        },
      };

      return children({
        loading,
        reloading,
        errored,
        errorMessage,
        // meta data,
        seriesAdditionalInfo,
        // timeseries data
        timeseriesData: transformedTimeseriesData,
        comparisonTimeseriesData: transformedComparisonTimeseriesData,
        allTimeseriesData,
        originalTimeseriesData,
        timeseriesTotals,
        originalPreviousTimeseriesData,
        previousTimeseriesData: previousTimeseriesData
          ? [previousTimeseriesData]
          : previousTimeseriesData,
        timeAggregatedData,
        timeframe,
        timeseriesResultsTypes,
        // sometimes we want to reference props that were given to EventsRequest
        ...props,
      });
    }
    return children({
      loading,
      reloading,
      errored,
      errorMessage,
      ...props,
    });
  }
}
export default EventsRequest;
