import uPlot, { Axis, Series } from 'uplot';
import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
import { CartesianCoords2D, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, measureText, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui';

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 horizontal bar
const HORIZONTAL_BAR_LABEL_OFFSET = 10;

/**
 * @internal
 */
export interface BarsOptions {
  xOri: ScaleOrientation;
  xDir: ScaleDirection;
  groupWidth: number;
  barWidth: number;
  showValue: BarValueVisibility;
  formatValue: (seriesIdx: number, value: any) => string;
  text?: VizTextDisplayOptions;
  onHover?: (seriesIdx: number, valueIdx: number) => void;
  onLeave?: (seriesIdx: number, valueIdx: number) => void;
}

interface LabelDescriptor extends CartesianCoords2D {
  formattedValue: string;
  value: number;
  textAlign: CanvasTextAlign;
  textBaseline: CanvasTextBaseline;
  fontSize: number;
  barWidth: number;
  barHeight: number;
  textWidth: number;
}

/**
 * @internal
 */
export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
  const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts;
  const hasAutoValueSize = !Boolean(opts.text?.valueSize);

  let qt: Quadtree;
  let labelsSizing: Array<LabelDescriptor | null> = [];

  const drawBars: Series.PathBuilder = (u, sidx) => {
    return uPlot.orient(
      u,
      sidx,
      (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
        const fill = new Path2D();
        const stroke = new Path2D();

        let numGroups = dataX.length;
        let barsPerGroup = u.series.length - 1;

        let y0Pos = valToPosY(0, scaleY, yDim, yOff);

        const _dir = dir * (ori === 0 ? 1 : -1);

        walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => {
          let left = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid));
          let barWid = Math.round(wid);
          const canvas = u.over;
          const bbox = canvas?.getBoundingClientRect();

          if (dataY[ix] != null) {
            let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff);
            let btm = Math.round(Math.max(yPos, y0Pos));
            let top = Math.round(Math.min(yPos, y0Pos));
            let barHgt = btm - top;
            let strokeWidth = series.width || 0;

            if (strokeWidth) {
              rect(stroke, left + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth);
            }

            rect(fill, left, top, barWid, barHgt);

            let x = ori === ScaleOrientation.Horizontal ? Math.round(left - xOff) : Math.round(top - yOff);
            let y = ori === ScaleOrientation.Horizontal ? Math.round(top - yOff) : Math.round(left - xOff);
            let width = ori === ScaleOrientation.Horizontal ? barWid : barHgt;
            let height = ori === ScaleOrientation.Horizontal ? barHgt : barWid;

            qt.add({ x, y, w: width, h: height, sidx: sidx, didx: ix });

            // Collect labels sizes and placements
            const value = formatValue(sidx, dataY[ix]);
            let labelX = ori === ScaleOrientation.Horizontal ? Math.round(left) : Math.round(top);
            let labelY = ori === ScaleOrientation.Horizontal ? Math.round(top) : Math.round(left);

            let availableSpaceForText;

            if (ori === ScaleOrientation.Horizontal) {
              availableSpaceForText =
                dataY[ix]! >= 0 ? y / devicePixelRatio : bbox!.height - (y + height) / devicePixelRatio;
            } else {
              availableSpaceForText =
                dataY[ix]! >= 0 ? bbox!.width - (x + width) / devicePixelRatio : x / devicePixelRatio;
            }

            /**
             * Snippet below is for debugging the available space for text. Leaving it for the future bugs...
             */
            // u.ctx.beginPath();
            // u.ctx.strokeStyle = '#0000ff';

            // if (dataY[ix]! >= 0) {
            //   if (ori === ScaleOrientation.Horizontal) {
            //     u.ctx.moveTo(left, top - availableSpaceForText * devicePixelRatio);
            //     u.ctx.lineTo(left + width, top - availableSpaceForText * devicePixelRatio);
            //     u.ctx.lineTo(left + width, top);
            //     u.ctx.lineTo(left, top);
            //   } else {
            //     u.ctx.moveTo(top + width, left);
            //     u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left);
            //     u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left + height);
            //     u.ctx.lineTo(top + width, left + height);
            //   }
            // } else {
            //   if (ori === ScaleOrientation.Horizontal) {
            //     u.ctx.moveTo(left, top + height + availableSpaceForText * devicePixelRatio);
            //     u.ctx.lineTo(left + width, top + height + availableSpaceForText * devicePixelRatio);
            //     u.ctx.lineTo(left + width, top + height);
            //     u.ctx.lineTo(left, top + height);
            //   } else {
            //     u.ctx.moveTo(top, left);
            //     u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left);
            //     u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left + height);
            //     u.ctx.lineTo(top, left + height);
            //   }
            // }
            // u.ctx.closePath();
            // u.ctx.stroke();

            let fontSize = opts.text?.valueSize ?? VALUE_MIN_FONT_SIZE;

            if (hasAutoValueSize) {
              const size =
                ori === ScaleOrientation.Horizontal
                  ? calculateFontSize(
                      value,
                      (width / devicePixelRatio) * BAR_FONT_SIZE_RATIO,
                      availableSpaceForText * BAR_FONT_SIZE_RATIO,
                      1
                    )
                  : calculateFontSize(
                      value,
                      availableSpaceForText,
                      (height * BAR_FONT_SIZE_RATIO) / devicePixelRatio,
                      1
                    );
              fontSize = size > VALUE_MAX_FONT_SIZE ? VALUE_MAX_FONT_SIZE : size;
            }

            const textAlign = ori === ScaleOrientation.Horizontal ? 'center' : 'left';
            const textBaseline = (ori === ScaleOrientation.Horizontal ? 'bottom' : 'alphabetic') as CanvasTextBaseline;
            const textMeasurement = measureText(value, fontSize * devicePixelRatio);
            let labelPosition: CartesianCoords2D = { x: labelX, y: labelY };

            // Collect labels szes
            labelsSizing.push({
              formattedValue: value,
              value: dataY[ix]!,
              textAlign,
              textBaseline,
              fontSize: Math.floor(fontSize),
              barWidth: width,
              barHeight: height,
              textWidth: textMeasurement.width,
              ...labelPosition,
            });
          } else {
            labelsSizing.push(null);
          }
        });

        return {
          stroke,
          fill,
        };
      }
    );
  };

  // uPlot hook to draw the labels on the bar chart
  const draw = (u: uPlot) => {
    let minFontSize = labelsSizing.reduce((min, s) => (s && s.fontSize < min ? s.fontSize : min), Infinity);

    if (minFontSize === Infinity) {
      return;
    }

    for (let i = 0; i < labelsSizing.length; i++) {
      const label = labelsSizing[i];
      let x = 0,
        y = 0;
      if (label === null) {
        continue;
      }

      const fontSize = hasAutoValueSize ? minFontSize : label.fontSize;

      if (showValue === BarValueVisibility.Never) {
        return;
      }

      if (showValue !== BarValueVisibility.Always) {
        if (
          hasAutoValueSize &&
          ((ori === ScaleOrientation.Horizontal && label.textWidth > label.barWidth) ||
            minFontSize < VALUE_MIN_FONT_SIZE)
        ) {
          return;
        }
      }

      // Calculate final labels positions according to unified text size
      const textMeasurement = measureText(label.formattedValue, fontSize * devicePixelRatio);
      const actualLineHeight = textMeasurement.fontBoundingBoxAscent + textMeasurement.fontBoundingBoxDescent;

      if (ori === ScaleOrientation.Horizontal) {
        x = label.x + label.barWidth / 2;
        y = label.y + (label.value >= 0 ? 0 : label.barHeight + actualLineHeight);
      } else {
        x =
          label.x +
          (label.value >= 0
            ? label.barWidth + HORIZONTAL_BAR_LABEL_OFFSET
            : -textMeasurement.width - HORIZONTAL_BAR_LABEL_OFFSET);
        y =
          label.y +
          (label.barHeight + textMeasurement.actualBoundingBoxAscent + textMeasurement.actualBoundingBoxDescent) / 2;
      }

      /**
       * Snippet below is for debugging the available space for text. Leaving it for the future bugs...
       */
      // u.ctx.beginPath();
      // u.ctx.fillStyle = '#0000ff';
      // u.ctx.arc(label.x, label.y, 10, 0, Math.PI * 2, true);
      // u.ctx.closePath();
      // u.ctx.fill();

      u.ctx.fillStyle = theme.colors.text.primary;
      u.ctx.font = `${fontSize * devicePixelRatio}px ${theme.typography.fontFamily}`;
      u.ctx.textAlign = label.textAlign;
      u.ctx.textBaseline = label.textBaseline;
      u.ctx.fillText(label.formattedValue, x, y);
    }

    return false;
  };

  const xSplits: Axis.Splits = (u: uPlot) => {
    const dim = ori === 0 ? u.bbox.width : u.bbox.height;
    const _dir = dir * (ori === 0 ? 1 : -1);

    let splits: number[] = [];

    distribute(u.data[0].length, groupWidth, groupDistr, null, (di, leftPct, widPct) => {
      let groupLftPx = (dim * leftPct) / devicePixelRatio;
      let groupWidPx = (dim * widPct) / devicePixelRatio;

      let groupCenterPx = groupLftPx + groupWidPx / 2;

      splits.push(u.posToVal(groupCenterPx, 'x'));
    });

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

  const xValues: Axis.Values = (u) => u.data[0];

  let hovered: Rect | null = null;

  let barMark = document.createElement('div');
  barMark.classList.add('bar-mark');
  barMark.style.position = 'absolute';
  barMark.style.background = 'rgba(255,255,255,0.4)';

  const init = (u: uPlot) => {
    let over = u.over;
    over.style.overflow = 'hidden';
    over.appendChild(barMark);
  };

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

    qt.clear();

    labelsSizing = [];

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

  // handle hover interaction with quadtree probing
  const interpolateTooltip: PlotTooltipInterpolator = (
    updateActiveSeriesIdx,
    updateActiveDatapointIdx,
    updateTooltipPosition
  ) => {
    return (u: uPlot) => {
      let found: Rect | null = null;
      let cx = u.cursor.left! * devicePixelRatio;
      let cy = u.cursor.top! * devicePixelRatio;

      qt.get(cx, cy, 1, 1, (o) => {
        if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
          found = o;
        }
      });

      if (found) {
        // prettier-ignore
        if (found !== hovered) {
          barMark.style.display = '';
          barMark.style.left   = found!.x / devicePixelRatio + 'px';
          barMark.style.top    = found!.y / devicePixelRatio + 'px';
          barMark.style.width  = found!.w / devicePixelRatio + 'px';
          barMark.style.height = found!.h / devicePixelRatio + 'px';
          hovered = found;
          updateActiveSeriesIdx(hovered!.sidx);
          updateActiveDatapointIdx(hovered!.didx);
          updateTooltipPosition();
        }
      } else if (hovered != null) {
        updateActiveSeriesIdx(hovered!.sidx);
        updateActiveDatapointIdx(hovered!.didx);
        updateTooltipPosition();
        hovered = null;
        barMark.style.display = 'none';
      } else {
        updateTooltipPosition(true);
      }
    };
  };

  return {
    // scale & axis opts
    xValues,
    xSplits,

    // pathbuilders
    drawBars,
    draw,

    // hooks
    init,
    drawClear,
    interpolateTooltip,
  };
}

type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void);

function walkTwo(
  groupWidth: number,
  barWidth: number,
  yIdx: number,
  xCount: number,
  yCount: number,
  xDim: number,
  xDraw?: WalkTwoCb,
  yDraw?: WalkTwoCb
) {
  distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => {
    let groupOffPx = xDim * offPct;
    let groupWidPx = xDim * dimPct;

    xDraw && xDraw(ix, groupOffPx, groupWidPx);

    yDraw &&
      distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => {
        let barOffPx = groupWidPx * offPct;
        let barWidPx = groupWidPx * dimPct;

        yDraw(ix, groupOffPx + barOffPx, barWidPx);
      });
  });
}
