import { assign, forIn, isArray } from '@antv/util';
import { DataSet } from '../../data-set';
import { getFields } from '../../util/option-parser';
import { View } from '../../view';

const DEFAULT_OPTIONS: Partial<Options> = {
  as: ['x', 'y', 'count'],
  bins: [30, 30], // Numeric vector giving number of bins in both horizontal and vertical directions
  offset: [0, 0],
  sizeByCount: false, // calculate bin size by binning count
  // fields: ['field0', 'field1'], // required
  // binWidth: [ 30, 30 ], // Numeric vector giving bin width in both horizontal and vertical directions. Overrides bins if both set.
};

export interface Options {
  as?: [string, string, string];
  bins?: number[];
  offset?: number[];
  sizeByCount?: boolean;
  fields: [string, string];
  binWidth?: number[];
}

const SQRT3 = Math.sqrt(3);
const THIRD_PI = Math.PI / 3;
const ANGLES = [0, THIRD_PI, 2 * THIRD_PI, 3 * THIRD_PI, 4 * THIRD_PI, 5 * THIRD_PI];

function distance(x0: number, y0: number, x1: number, y1: number): number {
  return Math.sqrt((x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1));
}

function nearestBinsCenters(value: number, scale: number, offset: number): [number, number] {
  const temp = value - offset;
  scale = scale / 2;
  const div = Math.floor(temp / scale);
  const rounded = scale * (div + (Math.abs(div % 2) === 1 ? 1 : 0));
  const roundedScaled = scale * (div + (Math.abs(div % 2) === 1 ? 0 : 1));
  return [rounded + offset, roundedScaled + offset];
}

function generateBins(points: [number, number][], binWidth = [1, 1], offset = [0, 0]): object {
  // processing aligned data
  const bins: any = {};
  const [binWidthX, binWidthY] = binWidth;
  const [offsetX, offsetY] = offset;
  points.forEach((point) => {
    const [x, y] = point;
    // step3.1: nearest two centers
    const [xRounded, xRoundedScaled] = nearestBinsCenters(x, binWidthX, offsetX);
    const [yRounded, yRoundedScaled] = nearestBinsCenters(y, binWidthY, offsetY);
    // step3.2: compare distances
    const d1 = distance(x, y, xRounded, yRounded);
    const d2 = distance(x, y, xRoundedScaled, yRoundedScaled);
    let binKey;
    let binX;
    let binY;

    if (d1 < d2) {
      binKey = `x${xRounded}y${yRounded}`;
      [binX, binY] = [xRounded, yRounded];
    } else {
      binKey = `x${xRoundedScaled}y${yRoundedScaled}`;
      [binX, binY] = [xRoundedScaled, yRoundedScaled];
    }
    bins[binKey] = bins[binKey] || {
      x: binX,
      y: binY,
      count: 0,
    };
    bins[binKey].count++;
  });
  return bins;
}

function transform(dataView: View, options: Options): void {
  // step1: get binWidth, etc.
  options = assign({} as Options, DEFAULT_OPTIONS, options);
  const fields = getFields(options);
  if (!isArray(fields) || fields.length !== 2) {
    throw new TypeError('Invalid fields: it must be an array with 2 strings!');
  }
  const [fieldX, fieldY] = fields;
  const rangeFieldX = dataView.range(fieldX);
  const rangeFieldY = dataView.range(fieldY);
  const widthX = rangeFieldX[1] - rangeFieldX[0];
  const widthY = rangeFieldY[1] - rangeFieldY[0];
  let binWidth = options.binWidth || [];
  if (binWidth.length !== 2) {
    const [binsX, binsY] = options.bins;
    if (binsX <= 0 || binsY <= 0) {
      throw new TypeError('Invalid bins: must be an array with two positive numbers (e.g. [ 30, 30 ])!');
    }
    binWidth = [widthX / binsX, widthY / binsY];
  }
  // step2: align scale (squash Y)
  /*
   * binWidthX / binWidthY should be Math.sqrt3 / 1.5
   * -: binWidthX |: binWidthY
   *           3
   *           |
   *   4       |        2
   *           |
   *           |
   *   5----------------1
   *
   *           0
   */
  const [offsetX, offsetY] = options.offset;
  const yScale = (3 * binWidth[0]) / (SQRT3 * binWidth[1]);
  // const yScale = binWidth[0] / (SQRT3 * binWidth[1]);
  const points: [number, number][] = dataView.rows.map((row) => [row[fieldX], yScale * row[fieldY]]);
  // step3: binning
  const bins = generateBins(points, [binWidth[0], yScale * binWidth[1]], [offsetX, yScale * offsetY]);
  // step4: restore scale (for Y)
  const [asX, asY, asCount] = options.as;
  if (!asX || !asY || !asCount) {
    throw new TypeError('Invalid as: it must be an array with three elements (e.g. [ "x", "y", "count" ])!');
  }
  const radius = binWidth[0] / SQRT3;
  const hexagonPoints = ANGLES.map((angle) => [Math.sin(angle) * radius, -Math.cos(angle) * radius]);
  const result: any[] = [];
  let maxCount = 0;
  if (options.sizeByCount) {
    forIn(bins, (bin) => {
      if (bin.count > maxCount) {
        maxCount = bin.count;
      }
    });
  }
  forIn(bins, (bin) => {
    const { x, y, count } = bin;
    const row: any = {};
    row[asCount] = count;
    if (options.sizeByCount) {
      row[asX] = hexagonPoints.map((p) => x + (bin.count / maxCount) * p[0]);
      row[asY] = hexagonPoints.map((p) => (y + (bin.count / maxCount) * p[1]) / yScale);
    } else {
      row[asX] = hexagonPoints.map((p) => x + p[0]);
      row[asY] = hexagonPoints.map((p) => (y + p[1]) / yScale);
    }
    result.push(row);
  });

  dataView.rows = result;
}

DataSet.registerTransform('bin.hexagon', transform);
DataSet.registerTransform('bin.hex', transform);
DataSet.registerTransform('hexbin', transform);
