import uPlot, { Axis, AlignedData, Scale } from 'uplot';

import { DataFrame, dateTimeFormat, GrafanaTheme2, systemDateFormats, TimeZone } from '@grafana/data';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import {
  StackingMode,
  VisibilityMode,
  ScaleDirection,
  ScaleOrientation,
  VizTextDisplayOptions,
  VizLegendOptions,
} from '@grafana/schema';
import { measureText } from '@grafana/ui';
import { timeUnitSize } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { StackingGroup, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils';

const intervals = systemDateFormats.interval;

import { distribute, SPACE_BETWEEN } from './distribute';
import { findRects, intersects, pointWithin, Quadtree, Rect } from './quadtree';

const groupDistr = SPACE_BETWEEN;
const barDistr = SPACE_BETWEEN;
// min.max font size for value label
const VALUE_MIN_FONT_SIZE = 8;
const VALUE_MAX_FONT_SIZE = 30;
// % of width/height of the bar that value should fit in when measuring size
const BAR_FONT_SIZE_RATIO = 0.65;
// distance between label and a bar in % of bar width
const LABEL_OFFSET_FACTOR_VT = 0.1;
const LABEL_OFFSET_FACTOR_HZ = 0.15;
// max distance
const LABEL_OFFSET_MAX_VT = 5;
const LABEL_OFFSET_MAX_HZ = 10;

// text baseline middle runs through the middle of lowercase letters
// since bar values are numbers and uppercase-like, we want the middle of uppercase
// this is a cheap fudge factor that skips expensive and inconsistent cross-browser measuring
const MIDDLE_BASELINE_SHIFT = 0.1;

/**
 * @internal
 */
export interface BarsOptions {
  xOri: ScaleOrientation;
  xDir: ScaleDirection;
  groupWidth: number;
  barWidth: number;
  barRadius: number;
  showValue: VisibilityMode;
  stacking: StackingMode;
  rawValue: (seriesIdx: number, valueIdx: number) => number | null;
  getColor?: (seriesIdx: number, valueIdx: number, value: unknown) => string | null;
  fillOpacity?: number;
  formatValue: (seriesIdx: number, value: unknown) => string;
  formatShortValue: (seriesIdx: number, value: unknown) => string;
  timeZone?: TimeZone;
  text?: VizTextDisplayOptions;
  hoverMulti?: boolean;
  legend?: VizLegendOptions;
  xSpacing?: number;
  xTimeAuto?: boolean;
  negY?: boolean[];
  fullHighlight?: boolean;
}

/**
 * @internal
 */
interface ValueLabelTable {
  [index: number]: ValueLabelArray;
}

/**
 * @internal
 */
interface ValueLabelArray {
  [index: number]: ValueLabel;
}

/**
 * @internal
 */
interface ValueLabel {
  text: string;
  value: number | null;
  hidden: boolean;
  bbox?: Rect;
  textMetrics?: TextMetrics;
  x?: number;
  y?: number;
}

/**
 * @internal
 */
function calculateFontSizeWithMetrics(
  text: string,
  width: number,
  height: number,
  lineHeight: number,
  maxSize?: number
) {
  // calculate width in 14px
  const textSize = measureText(text, 14);
  // how much bigger than 14px can we make it while staying within our width constraints
  const fontSizeBasedOnWidth = (width / (textSize.width + 2)) * 14;
  const fontSizeBasedOnHeight = height / lineHeight;

  // final fontSize
  const optimalSize = Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth);
  return {
    fontSize: Math.min(optimalSize, maxSize ?? optimalSize),
    textMetrics: textSize,
  };
}

/**
 * @internal
 */
export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
  const {
    xOri,
    xDir: dir,
    rawValue,
    getColor,
    formatValue,
    formatShortValue,
    fillOpacity = 1,
    showValue,
    xSpacing = 0,
    hoverMulti = false,
    timeZone = 'browser',
  } = opts;
  const isXHorizontal = xOri === ScaleOrientation.Horizontal;
  const hasAutoValueSize = !Boolean(opts.text?.valueSize);
  const isStacked = opts.stacking !== StackingMode.None;
  const pctStacked = opts.stacking === StackingMode.Percent;

  let { groupWidth, barWidth, barRadius = 0 } = opts;

  if (isStacked) {
    [groupWidth, barWidth] = [barWidth, groupWidth];
  }

  let qt: Quadtree;
  const numSeries = 30; // !!
  const hovered: Array<Rect | null> = Array(numSeries).fill(null);
  let hRect: Rect | null;

  // for distr: 2 scales, the splits array should contain indices into data[0] rather than values
  const xSplits: Axis.Splits | undefined = (u) => Array.from(u.data[0].map((v, i) => i));

  const hFilter: Axis.Filter | undefined =
    xSpacing === 0
      ? undefined
      : (u, splits) => {
          // hSpacing?
          const dim = u.bbox.width;
          const _dir = dir * (isXHorizontal ? 1 : -1);

          let dataLen = splits.length;
          let lastIdx = dataLen - 1;

          let skipMod = 0;

          let cssDim = dim / uPlot.pxRatio;
          let maxTicks = Math.abs(Math.floor(cssDim / xSpacing));

          skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks);

          let splits2 = splits.map((v, i) => {
            let shouldSkip = skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0;
            return shouldSkip ? null : v;
          });

          return _dir === 1 ? splits2 : splits2.reverse();
        };

  // the splits passed into here are data[0] values looked up by the indices returned from splits()
  const xValues: Axis.Values = (u, splits, axisIdx, foundSpace, foundIncr) => {
    if (opts.xTimeAuto) {
      let format = intervals.year;

      if (foundIncr < timeUnitSize.second) {
        format = intervals.millisecond;
      } else if (foundIncr < timeUnitSize.minute) {
        format = intervals.second;
      } else if (foundIncr < timeUnitSize.hour) {
        format = intervals.minute;
      } else if (foundIncr < timeUnitSize.day) {
        format = intervals.hour;
      } else if (foundIncr < timeUnitSize.month) {
        format = intervals.day;
      } else if (foundIncr < timeUnitSize.year) {
        format = intervals.month;
      } else {
        format = intervals.year;
      }

      return splits.map((v) => (v == null ? '' : dateTimeFormat(v, { format, timeZone })));
    }

    return splits.map((v) => (isXHorizontal ? formatShortValue(0, v) : formatValue(0, v)));
  };

  // this expands the distr: 2 scale so that the indicies of each data[0] land at the proper justified positions
  const xRange: Scale.Range = (u, min, max) => {
    min = 0;
    max = Math.max(1, u.data[0].length - 1);

    let pctOffset = 0;

    // how far in is the first tick in % of full dimension
    distribute(u.data[0].length, groupWidth, groupDistr, 0, (di, lftPct, widPct) => {
      pctOffset = lftPct + widPct / 2;
    });

    // expand scale range by equal amounts on both ends
    let rn = max - min;

    if (pctOffset === 0.5) {
      min -= rn;
    } else {
      let upScale = 1 / (1 - pctOffset * 2);
      let offset = (upScale * rn - rn) / 2;

      min -= offset;
      max += offset;
    }

    return [min, max];
  };

  let distrTwo = (groupCount: number, barCount: number) => {
    let out = Array.from({ length: barCount }, () => ({
      offs: Array(groupCount).fill(0),
      size: Array(groupCount).fill(0),
    }));

    distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
      distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
        out[barIdx].offs[groupIdx] = groupOffPct + groupDimPct * barOffPct;
        out[barIdx].size[groupIdx] = groupDimPct * barDimPct;
      });
    });

    return out;
  };

  let distrOne = (groupCount: number, barCount: number) => {
    let out = Array.from({ length: barCount }, () => ({
      offs: Array(groupCount).fill(0),
      size: Array(groupCount).fill(0),
    }));

    distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
      distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
        out[barIdx].offs[groupIdx] = groupOffPct;
        out[barIdx].size[groupIdx] = groupDimPct;
      });
    });

    return out;
  };

  const LABEL_OFFSET_FACTOR = isXHorizontal ? LABEL_OFFSET_FACTOR_VT : LABEL_OFFSET_FACTOR_HZ;
  const LABEL_OFFSET_MAX = isXHorizontal ? LABEL_OFFSET_MAX_VT : LABEL_OFFSET_MAX_HZ;

  let barsPctLayout: Array<null | { offs: number[]; size: number[] }> = [];
  let barsColors: Array<null | { fill: Array<string | null>; stroke: Array<string | null> }> = [];
  let scaleFactor = 1;
  let labels: ValueLabelTable;
  let fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
  let labelOffset = LABEL_OFFSET_MAX;

  // minimum available space for labels between bar end and plotting area bound (in canvas pixels)
  let vSpace = Infinity;
  let hSpace = Infinity;

  let useMappedColors = getColor != null;

  let mappedColorDisp = useMappedColors
    ? {
        fill: {
          unit: 3,
          values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.fill,
        },
        stroke: {
          unit: 3,
          values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.stroke,
        },
      }
    : {};

  let barsBuilder = uPlot.paths.bars!({
    radius: pctStacked
      ? 0
      : !isStacked
        ? barRadius
        : (u: uPlot, seriesIdx: number) => {
            let isTopmostSeries = seriesIdx === u.data.length - 1;
            return isTopmostSeries ? [barRadius, 0] : [0, 0];
          },
    disp: {
      x0: {
        unit: 2,
        values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.offs,
      },
      size: {
        unit: 2,
        values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.size,
      },
      ...mappedColorDisp,
    },
    // collect rendered bar geometry
    each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
      // we get back raw canvas coords (included axes & padding)
      // translate to the plotting area origin
      lft -= u.bbox.left;
      top -= u.bbox.top;

      let val = u.data[seriesIdx][dataIdx]!;
      // accum min space abvailable for labels
      if (isXHorizontal) {
        vSpace = Math.min(vSpace, val < 0 ? u.bbox.height - (top + hgt) : top);
        hSpace = wid;
      } else {
        vSpace = hgt;
        hSpace = Math.min(hSpace, val < 0 ? lft : u.bbox.width - (lft + wid));
      }

      let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx };

      if (!isStacked && opts.fullHighlight) {
        if (opts.xOri === ScaleOrientation.Horizontal) {
          barRect.y = 0;
          barRect.h = u.bbox.height;
        } else {
          barRect.x = 0;
          barRect.w = u.bbox.width;
        }
      }

      qt.add(barRect);

      if (showValue !== VisibilityMode.Never) {
        const raw = rawValue(seriesIdx, dataIdx)!;
        let divider = 1;

        if (pctStacked && alignedTotals![seriesIdx][dataIdx]!) {
          divider = alignedTotals![seriesIdx][dataIdx]!;
        }

        const v = divider === 0 ? 0 : raw / divider;
        // Format Values and calculate label offsets
        const text = formatValue(seriesIdx, v);
        labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? wid : hgt)));

        if (labels[dataIdx] === undefined) {
          labels[dataIdx] = {};
        }
        labels[dataIdx][seriesIdx] = { text: text, value: rawValue(seriesIdx, dataIdx), hidden: false };

        // Calculate font size when it's set to be automatic
        if (hasAutoValueSize) {
          const { fontSize: calculatedSize, textMetrics } = calculateFontSizeWithMetrics(
            labels[dataIdx][seriesIdx].text,
            hSpace * (isXHorizontal ? BAR_FONT_SIZE_RATIO : 1) - (isXHorizontal ? 0 : labelOffset),
            vSpace * (isXHorizontal ? 1 : BAR_FONT_SIZE_RATIO) - (isXHorizontal ? labelOffset : 0),
            1
          );

          // Save text metrics
          labels[dataIdx][seriesIdx].textMetrics = textMetrics;

          // Retrieve the new font size and use it
          let autoFontSize = Math.round(Math.min(fontSize, VALUE_MAX_FONT_SIZE, calculatedSize));

          // Calculate the scaling factor for bouding boxes
          // Take into account the fact that calculateFontSize
          // uses 14px measurement so we need to adjust the scale factor
          scaleFactor = (autoFontSize / fontSize) * (autoFontSize / 14);

          // Update the end font-size
          fontSize = autoFontSize;
        } else {
          labels[dataIdx][seriesIdx].textMetrics = measureText(labels[dataIdx][seriesIdx].text, fontSize);
        }

        let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize);
        let value = rawValue(seriesIdx, dataIdx);

        if (opts.negY?.[seriesIdx] && value != null) {
          value *= -1;
        }

        if (value != null) {
          // Calculate final co-ordinates for text position
          const x =
            u.bbox.left + (isXHorizontal ? lft + wid / 2 : value < 0 ? lft - labelOffset : lft + wid + labelOffset);
          let y =
            u.bbox.top +
            (isXHorizontal ? (value < 0 ? top + hgt + labelOffset : top - labelOffset) : top + hgt / 2 - middleShift);

          // Retrieve textMetrics with necessary default values
          // These _shouldn't_ be undefined at this point
          // but they _could_ be.
          const {
            textMetrics = {
              width: 1,
              actualBoundingBoxAscent: 1,
              actualBoundingBoxDescent: 1,
            },
          } = labels[dataIdx][seriesIdx];

          // Adjust bounding boxes based on text scale
          // factor and orientation (which changes the baseline)
          let xAdjust = 0,
            yAdjust = 0;

          if (isXHorizontal) {
            // Adjust for baseline which is "top" in this case
            xAdjust = (textMetrics.width * scaleFactor) / 2;

            // yAdjust only matters when the value isn't negative
            yAdjust =
              value > 0
                ? (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor
                : 0;
          } else {
            // Adjust from the baseline which is "middle" in this case
            yAdjust = ((textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor) / 2;

            // Adjust for baseline being "right" in the x direction
            xAdjust = value < 0 ? textMetrics.width * scaleFactor : 0;
          }

          // Force label bounding box y position to not be negative
          if (y - yAdjust < 0) {
            y = yAdjust;
          }

          // Construct final bounding box for the label text
          labels[dataIdx][seriesIdx].x = x;
          labels[dataIdx][seriesIdx].y = y;
          labels[dataIdx][seriesIdx].bbox = {
            x: x - xAdjust,
            y: y - yAdjust,
            w: textMetrics.width * scaleFactor,
            h: (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor,
          };
        }
      }
    },
  });

  const init = (u: uPlot) => {
    u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt').forEach((el) => {
      el.style.borderRadius = '0';

      if (opts.fullHighlight) {
        el.style.zIndex = '-1';
      }
    });
  };

  const cursor: uPlot.Cursor = {
    x: false,
    y: false,
    drag: {
      x: false,
      y: false,
    },
    dataIdx: (u, seriesIdx) => {
      if (seriesIdx === 0) {
        hovered.fill(null);
        hRect = null;

        let cx = u.cursor.left! * uPlot.pxRatio;
        let cy = u.cursor.top! * uPlot.pxRatio;

        qt.get(cx, cy, 1, 1, (o) => {
          if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
            hRect = hovered[0] = o;
            hovered[hRect.sidx] = hRect;

            hoverMulti &&
              findRects(qt, undefined, hRect.didx).forEach((r) => {
                hovered[r.sidx] = r;
              });
          }
        });
      }

      return hovered[seriesIdx]?.didx;
    },
    points: {
      fill: 'rgba(255,255,255,0.4)',
      bbox: (u, seriesIdx) => {
        let hRect2 = hovered[seriesIdx];
        let isHovered = hRect2 != null;

        return {
          left: isHovered ? hRect2!.x / uPlot.pxRatio : -10,
          top: isHovered ? hRect2!.y / uPlot.pxRatio : -10,
          width: isHovered ? hRect2!.w / uPlot.pxRatio : 0,
          height: isHovered ? hRect2!.h / uPlot.pxRatio : 0,
        };
      },
    },
    focus: {
      prox: 1e3,
      dist: (u, seriesIdx) => (hRect?.sidx === seriesIdx ? 0 : Infinity),
    },
  };

  // Build bars
  const drawClear = (u: uPlot) => {
    qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
    qt.clear();

    // clear the path cache to force drawBars() to rebuild new quadtree
    u.series.forEach((s) => {
      // @ts-ignore
      s._paths = null;
    });

    if (isStacked) {
      barsPctLayout = [null, ...distrOne(u.data[0].length, u.data.length - 1)];
    } else {
      barsPctLayout = [null, ...distrTwo(u.data[0].length, u.data.length - 1)];
    }

    if (useMappedColors) {
      barsColors = [null];

      // map per-bar colors
      for (let i = 1; i < u.data.length; i++) {
        let colors = (u.data[i] as Array<number | null>).map((value, valueIdx) => {
          if (value != null) {
            return getColor!(i, valueIdx, value);
          }

          return null;
        });

        barsColors.push({
          fill: fillOpacity < 1 ? colors.map((c) => (c != null ? alpha(c, fillOpacity) : null)) : colors,
          stroke: colors,
        });
      }
    }

    labels = {};
    fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
    labelOffset = LABEL_OFFSET_MAX;
    vSpace = hSpace = Infinity;
  };

  // uPlot hook to draw the labels on the bar chart.
  const draw = (u: uPlot) => {
    if (showValue === VisibilityMode.Never || fontSize < VALUE_MIN_FONT_SIZE) {
      return;
    }

    u.ctx.save();
    u.ctx.fillStyle = theme.colors.text.primary;
    u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`;

    let curAlign: CanvasTextAlign | undefined = undefined,
      curBaseline: CanvasTextBaseline | undefined = undefined;

    for (const didx in labels) {
      // exclude first label from overlap testing
      let first = true;

      for (const sidx in labels[didx]) {
        const label = labels[didx][sidx];
        const { text, x = 0, y = 0 } = label;
        let { value } = label;

        if (opts.negY?.[sidx] && value != null) {
          value *= -1;
        }

        let align: CanvasTextAlign = isXHorizontal ? 'center' : value !== null && value < 0 ? 'right' : 'left';
        let baseline: CanvasTextBaseline = isXHorizontal
          ? value !== null && value < 0
            ? 'top'
            : 'alphabetic'
          : 'middle';

        if (align !== curAlign) {
          u.ctx.textAlign = curAlign = align;
        }

        if (baseline !== curBaseline) {
          u.ctx.textBaseline = curBaseline = baseline;
        }

        if (showValue === VisibilityMode.Always) {
          u.ctx.fillText(text, x, y);
        } else if (showValue === VisibilityMode.Auto) {
          let { bbox } = label;

          let intersectsLabel = false;

          if (bbox == null) {
            intersectsLabel = true;
            label.hidden = true;
          } else if (!first) {
            // Test for any collisions
            for (const subsidx in labels[didx]) {
              if (subsidx === sidx) {
                continue;
              }

              const label2 = labels[didx][subsidx];
              const { bbox: bbox2, hidden } = label2;

              if (!hidden && bbox2 && intersects(bbox, bbox2)) {
                intersectsLabel = true;
                label.hidden = true;
                break;
              }
            }
          }

          first = false;

          !intersectsLabel && u.ctx.fillText(text, x, y);
        }
      }
    }

    u.ctx.restore();
  };

  let alignedTotals: AlignedData | null = null;

  function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
    alignedTotals = null;
    return preparePlotData2(frames[0], stackingGroups, ({ totals }) => {
      alignedTotals = totals;
    });
  }

  return {
    cursor,
    // scale & axis opts
    xRange,
    xValues,
    xSplits,
    hFilter,

    barsBuilder,

    // hooks
    init,
    drawClear,
    draw,
    prepData,
  };
}
