import {
  type DataFrame,
  type Field,
  FieldType,
  getDisplayProcessor,
  type GrafanaTheme2,
  isBooleanUnit,
  type TimeRange,
  type PanelData,
  cacheFieldDisplayNames,
  applyNullInsertThreshold,
  nullToValue,
} from '@grafana/data';
import { convertFieldType } from '@grafana/data/internal';
import { type GraphFieldConfig, LineInterpolation, TooltipDisplayMode, type VizTooltipOptions } from '@grafana/schema';
import { type AdHocFilterItem } from '@grafana/ui';
import { buildScaleKey, FILTER_FOR_OPERATOR } from '@grafana/ui/internal';

import { type HeatmapTooltip } from '../heatmap/panelcfg.gen';

type ScaleKey = string;

// this will re-enumerate all enum fields on the same scale to create one ordinal progression
// e.g. ['a','b'][0,1,0] + ['c','d'][1,0,1] -> ['a','b'][0,1,0] + ['c','d'][3,2,3]
function reEnumFields(frames: DataFrame[]): DataFrame[] {
  let allTextsByKey: Map<ScaleKey, string[]> = new Map();

  let frames2: DataFrame[] = frames.map((frame) => {
    return {
      ...frame,
      fields: frame.fields.map((field) => {
        if (field.type === FieldType.enum) {
          let scaleKey = buildScaleKey(field.config, field.type);
          let allTexts = allTextsByKey.get(scaleKey);

          if (!allTexts) {
            allTexts = [];
            allTextsByKey.set(scaleKey, allTexts);
          }

          let idxs: number[] = field.values.slice();
          let txts = field.config.type!.enum!.text!;

          // by-reference incrementing
          if (allTexts.length > 0) {
            for (let i = 0; i < idxs.length; i++) {
              idxs[i] += allTexts.length;
            }
          }

          allTexts.push(...txts);

          // shared among all enum fields on same scale
          field.config.type!.enum!.text! = allTexts;

          return {
            ...field,
            values: idxs,
          };

          // TODO: update displayProcessor?
        }

        return field;
      }),
    };
  });

  return frames2;
}

/**
 * Returns null if there are no graphable fields
 */
export function prepareGraphableFields(
  series: DataFrame[],
  theme: GrafanaTheme2,
  timeRange?: TimeRange,
  // numeric X requires a single frame where the first field is numeric
  xNumFieldIdx?: number
): DataFrame[] | null {
  if (!series?.length) {
    return null;
  }

  cacheFieldDisplayNames(series);

  let useNumericX = xNumFieldIdx != null;

  // Make sure the numeric x field is first in the frame
  if (xNumFieldIdx != null && xNumFieldIdx > 0) {
    series = [
      {
        ...series[0],
        fields: [series[0].fields[xNumFieldIdx], ...series[0].fields.filter((f, i) => i !== xNumFieldIdx)],
      },
    ];
  }

  // some datasources simply tag the field as time, but don't convert to milli epochs
  // so we're stuck with doing the parsing here to avoid Moment slowness everywhere later
  // this mutates (once)
  for (let frame of series) {
    for (let field of frame.fields) {
      if (field.type === FieldType.time && typeof field.values[0] !== 'number') {
        field.values = convertFieldType(field, { destinationType: FieldType.time }).values;
      }
    }
  }

  let enumFieldsCount = 0;

  loopy: for (let frame of series) {
    for (let field of frame.fields) {
      if (field.type === FieldType.enum && ++enumFieldsCount > 1) {
        series = reEnumFields(series);
        break loopy;
      }
    }
  }

  let copy: Field;

  const frames: DataFrame[] = [];

  for (let frame of series) {
    const fields: Field[] = [];

    let hasTimeField = false;
    let hasValueField = false;

    let nulledFrame = useNumericX
      ? frame
      : applyNullInsertThreshold({
          frame,
          refFieldPseudoMin: timeRange?.from.valueOf(),
          refFieldPseudoMax: timeRange?.to.valueOf(),
        });

    const frameFields = nullToValue(nulledFrame).fields;

    for (let fieldIdx = 0; fieldIdx < (frameFields?.length || 0); fieldIdx++) {
      const field = frameFields[fieldIdx];

      switch (field.type) {
        case FieldType.time:
          hasTimeField = true;
          fields.push(field);
          break;
        case FieldType.number:
          hasValueField = useNumericX ? fieldIdx > 0 : true;

          // we need to make sure all values in the array are numbers or null
          // so, check all values and if we encounter a bad one, copy the array and
          // replace all further-occuring non-numbers with null to make safe values array
          let values = field.values;
          let safeValues: unknown[] | undefined = undefined;

          for (let i = 0; i < values.length; i++) {
            let v = values[i];

            if (!(Number.isFinite(v) || v == null)) {
              safeValues ??= values.slice();
              safeValues[i] = null;
            }
          }

          safeValues ??= values;

          copy = {
            ...field,
            values: safeValues,
          };

          fields.push(copy);
          break; // ok
        case FieldType.enum:
          hasValueField = true;
        case FieldType.string:
          copy = {
            ...field,
            values: field.values,
          };

          fields.push(copy);
          break; // ok
        case FieldType.boolean:
          hasValueField = true;
          const custom: GraphFieldConfig = field.config?.custom ?? {};
          const config = {
            ...field.config,
            max: 1,
            min: 0,
            custom: { ...custom },
          };

          // smooth and linear do not make sense
          if (config.custom.lineInterpolation !== LineInterpolation.StepBefore) {
            config.custom.lineInterpolation = LineInterpolation.StepAfter;
          }

          copy = {
            ...field,
            config,
            type: FieldType.number,
            values: field.values.map((v) => {
              if (v == null) {
                return v;
              }
              return Boolean(v) ? 1 : 0;
            }),
          };

          if (!isBooleanUnit(config.unit)) {
            config.unit = 'bool';
            copy.display = getDisplayProcessor({ field: copy, theme });
          }

          fields.push(copy);
          break;
      }
    }

    if ((useNumericX || hasTimeField) && hasValueField) {
      frames.push({
        ...frame,
        length: nulledFrame.length,
        fields,
      });
    }
  }

  if (frames.length) {
    setClassicPaletteIdxs(frames, theme, 0);
    matchEnumColorToSeriesColor(frames, theme);
    return frames;
  }

  return null;
}

const matchEnumColorToSeriesColor = (frames: DataFrame[], theme: GrafanaTheme2) => {
  const { palette } = theme.visualization;
  for (const frame of frames) {
    for (const field of frame.fields) {
      if (field.type === FieldType.enum) {
        const namedColor = palette[field.state?.seriesIndex! % palette.length];
        const hexColor = theme.visualization.getColorByName(namedColor);
        const enumConfig = field.config.type!.enum!;

        enumConfig.color = Array(enumConfig.text!.length).fill(hexColor);
        field.display = getDisplayProcessor({ field, theme });
      }
    }
  }
};

export const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => {
  let seriesIndex = 0;

  const updateFieldDisplay = (field: Field, idx: number) => {
    field.state = { ...field.state, seriesIndex: idx };
    field.display = getDisplayProcessor({ field, theme });
  };

  const shouldProcessField = (field: Field, fieldIdx: number) => {
    return (
      fieldIdx !== skipFieldIdx &&
      (field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)
    );
  };

  // Pre-pass to group main frames by refId
  const mainFramesByRefId = new Map<string, DataFrame[]>();
  for (const frame of frames) {
    if (!frame.meta?.timeCompare?.isTimeShiftQuery && frame.refId) {
      if (!mainFramesByRefId.has(frame.refId)) {
        mainFramesByRefId.set(frame.refId, []);
      }
      mainFramesByRefId.get(frame.refId)!.push(frame);
    }
  }

  // Counter for comparison indices per baseRefId
  const compareIndicesByRefId = new Map<string, number>();

  for (const frame of frames) {
    const isCompareFrame = frame.meta?.timeCompare?.isTimeShiftQuery;

    if (isCompareFrame) {
      const baseRefId = frame.refId?.replace('-compare', '');

      if (baseRefId) {
        // Get and increment the comparison index
        let compareIndex = compareIndicesByRefId.get(baseRefId) ?? 0;
        compareIndicesByRefId.set(baseRefId, compareIndex + 1);

        // Get the matching main frame using the index
        const mainFrames = mainFramesByRefId.get(baseRefId);
        const mainFrame = mainFrames?.[compareIndex];

        if (mainFrame && mainFrame.fields.length === frame.fields.length) {
          // Match series indices with main frame
          frame.fields.forEach((field, fieldIdx) => {
            if (shouldProcessField(field, fieldIdx)) {
              const mainField = mainFrame.fields[fieldIdx];
              updateFieldDisplay(field, mainField.state?.seriesIndex ?? seriesIndex++);
            }
          });
        } else {
          // Fallback
          frame.fields.forEach((field, fieldIdx) => {
            if (shouldProcessField(field, fieldIdx)) {
              updateFieldDisplay(field, seriesIndex++);
            }
          });
        }
      } else {
        // Fallback when no baseRefId
        frame.fields.forEach((field, fieldIdx) => {
          if (shouldProcessField(field, fieldIdx)) {
            updateFieldDisplay(field, seriesIndex++);
          }
        });
      }
    } else {
      // Main frames
      frame.fields.forEach((field, fieldIdx) => {
        if (shouldProcessField(field, fieldIdx)) {
          updateFieldDisplay(field, seriesIndex++);
        }
      });
    }
  }
};

export function getTimezones(timezones: string[] | undefined, defaultTimezone: string): string[] {
  if (!timezones || !timezones.length) {
    return [defaultTimezone];
  }
  return timezones.map((v) => (v?.length ? v : defaultTimezone));
}

export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
  return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
};

export function getGroupedFilters(
  frame: DataFrame,
  seriesIdx: number,
  getFiltersBasedOnGrouping: (filters: AdHocFilterItem[]) => AdHocFilterItem[]
) {
  const groupingFilters: AdHocFilterItem[] = [];
  const xField = frame.fields[seriesIdx];

  if (xField && xField.labels && xField.config.filterable) {
    const seriesFilters: AdHocFilterItem[] = [];

    Object.entries(xField.labels).forEach(([key, value]) => {
      seriesFilters.push({
        key,
        operator: FILTER_FOR_OPERATOR,
        value,
      });
    });

    groupingFilters.push(...getFiltersBasedOnGrouping(seriesFilters));
  }

  return groupingFilters;
}

export const LTTB_THRESHOLD = 150;

// adapted from https://github.com/pingec/downsample-lttb
function lttbIndices(xs: number[], ys: number[], threshold: number): number[] {
  const len = xs.length;
  if (threshold >= len) {
    return Array.from({ length: len }, (_, i) => i);
  }

  const indices = new Array(threshold);
  indices[0] = 0;
  indices[threshold - 1] = len - 1;

  const bucketSize = (len - 2) / (threshold - 2);
  let prevIdx = 0;

  for (let i = 1; i < threshold - 1; i++) {
    const bucketStart = Math.floor((i - 1) * bucketSize) + 1;
    const bucketEnd = Math.min(Math.floor(i * bucketSize) + 1, len - 1);
    const nextEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, len - 1);

    let avgX = 0,
      avgY = 0,
      count = 0;
    for (let j = bucketEnd; j < nextEnd; j++) {
      avgX += xs[j];
      avgY += ys[j];
      count++;
    }
    if (count > 0) {
      avgX /= count;
      avgY /= count;
    }

    let maxArea = -1,
      maxIdx = bucketStart;
    for (let j = bucketStart; j < bucketEnd; j++) {
      const area =
        Math.abs((xs[prevIdx] - avgX) * (ys[j] - ys[prevIdx]) - (xs[prevIdx] - xs[j]) * (avgY - ys[prevIdx])) * 0.5;
      if (area > maxArea) {
        maxArea = area;
        maxIdx = j;
      }
    }
    indices[i] = maxIdx;
    prevIdx = maxIdx;
  }

  return indices;
}

// Downsamples each frame using the first numeric field to compute LTTB indices,
// then applies those indices to all fields. For frames with multiple numeric fields
// the sampling is optimal for the first field only, which is acceptable for small
// preview cards where pixel density already limits visible detail.
export function lttbPreviewData(data: PanelData, threshold = LTTB_THRESHOLD): PanelData {
  return {
    ...data,
    series: data.series.map((frame) => {
      const timeField = frame.fields.find((f) => f.type === FieldType.time);
      const numericField = frame.fields.find((f) => f.type === FieldType.number);
      if (!timeField || !numericField || frame.length <= threshold) {
        return frame;
      }
      // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
      const indices = lttbIndices(timeField.values as number[], numericField.values as number[], threshold);

      return {
        ...frame,
        length: indices.length,
        fields: frame.fields.map((field) => ({
          ...field,
          values: indices.map((i) => field.values[i]),
          ...(field.type === FieldType.time && {
            // since lttb may pick points further apart than timeField.config.interval, we clear it out to avoid gap insertion
            config: { ...field.config, interval: undefined },
          }),
        })),
      };
    }),
  };
}
