import { isNaN } from 'lodash';

export type CoordinateTransform = {
  translate?: { x?: number; y?: number };
  scale?: { x?: number; y?: number };
};

export type NewBBox = {
  left: number;
  top: number;
  right: number;
  bottom: number;
  width: number;
  height: number;
  coord: CoordinateTransform;
};

// an abstract datatype for the NewBBox type that keeps the fields synchronized
export class NewBBoxClass {
  private _left?: number;
  private _top?: number;
  private _right?: number;
  private _bottom?: number;
  private _width?: number;
  private _height?: number;
  private _coord?: CoordinateTransform;
  private _setLeft?(left: number | undefined): void;
  private _setTop?(top: number | undefined): void;
  private _setRight?(right: number | undefined): void;
  private _setBottom?(bottom: number | undefined): void;
  private _setWidth?(width: number | undefined): void;
  private _setHeight?(height: number | undefined): void;
  private _setCoord?(coord: CoordinateTransform | undefined): void;

  constructor(
    bbox: Partial<NewBBox>,
    callbacks: { [K in keyof NewBBox]?: (value: NewBBox[K] | undefined) => void } = {},
  ) {
    this._setLeft = callbacks.left;
    this._setTop = callbacks.top;
    this._setRight = callbacks.right;
    this._setBottom = callbacks.bottom;
    this._setWidth = callbacks.width;
    this._setHeight = callbacks.height;
    this._setCoord = callbacks.coord;

    this.intrinsicLeft = bbox.left;
    this.intrinsicTop = bbox.top;
    this.intrinsicRight = bbox.right;
    this.intrinsicBottom = bbox.bottom;
    this.intrinsicWidth = bbox.width;
    this.intrinsicHeight = bbox.height;
    this.coord = bbox.coord;
  }

  get coord() {
    return this._coord;
  }

  set coord(coord: CoordinateTransform | undefined) {
    this._coord = coord;
    if (this._setCoord) {
      this._setCoord(coord);
    }
  }

  get left() {
    if (this.intrinsicLeft === undefined) {
      return undefined;
    }

    return this.intrinsicLeft + (this.coord?.translate?.x ?? 0);
  }

  set left(left: number | undefined) {
    if (isNaN(left)) {
      throw new Error('left is NaN');
    }

    // mutate intrinsicLeft if it hasn't been set yet
    // otherwise, mutate coord

    if (left === undefined) {
      this.intrinsicLeft = undefined;
    } else if (this.intrinsicLeft === undefined) {
      this.intrinsicLeft = left;

      // initialize coord if it's undefined
      // TODO: I'm not sure if this.coord updates require spread syntax
      if (this.coord === undefined) {
        this.coord = { translate: { x: 0 } };
      } else if (this.coord.translate === undefined) {
        this.coord = { ...this.coord, translate: { x: 0 } };
      } else if (this.coord.translate.x === undefined) {
        this.coord = { ...this.coord, translate: { ...this.coord.translate, x: 0 } };
      }
    } else {
      // translate the bbox extrinsically
      this.coord = { ...this.coord, translate: { ...this.coord!.translate, x: left - this.intrinsicLeft } };
    }
  }

  get right() {
    if (this.intrinsicRight === undefined) {
      return undefined;
    }

    return this.intrinsicRight + (this.coord?.translate?.x ?? 0);
  }

  set right(right: number | undefined) {
    if (isNaN(right)) {
      throw new Error('right is NaN');
    }

    if (right === undefined) {
      this.intrinsicRight = undefined;
    } else if (this.intrinsicRight === undefined) {
      this.intrinsicRight = right;

      // initialize coord if it's undefined
      if (this.coord === undefined) {
        this.coord = { translate: { x: 0 } };
      } else if (this.coord.translate === undefined) {
        this.coord = { ...this.coord, translate: { x: 0 } };
      } else if (this.coord.translate.x === undefined) {
        this.coord = { ...this.coord, translate: { ...this.coord.translate, x: 0 } };
      }
    } else {
      // translate the bbox extrinsically
      this.coord = { ...this.coord, translate: { ...this.coord!.translate, x: right - this.intrinsicRight } };
    }
  }

  get top() {
    if (this.intrinsicTop === undefined) {
      return undefined;
    }

    return this.intrinsicTop + (this.coord?.translate?.y ?? 0);
  }

  set top(top: number | undefined) {
    if (isNaN(top)) {
      throw new Error('top is NaN');
    }

    if (top === undefined) {
      this.intrinsicTop = undefined;
    } else if (this.intrinsicTop === undefined) {
      this.intrinsicTop = top;

      // initialize coord if it's undefined
      if (this.coord === undefined) {
        this.coord = { translate: { y: 0 } };
      } else if (this.coord.translate === undefined) {
        this.coord = { ...this.coord, translate: { y: 0 } };
      } else if (this.coord.translate.y === undefined) {
        this.coord = { ...this.coord, translate: { ...this.coord.translate, y: 0 } };
      }
    } else {
      // translate the bbox extrinsically
      this.coord = { ...this.coord, translate: { ...this.coord!.translate, y: top - this.intrinsicTop } };
    }
  }

  get bottom() {
    if (this.intrinsicBottom === undefined) {
      return undefined;
    }

    return this.intrinsicBottom + (this.coord?.translate?.y ?? 0);
  }

  set bottom(bottom: number | undefined) {
    if (isNaN(bottom)) {
      throw new Error('bottom is NaN');
    }

    if (bottom === undefined) {
      this.intrinsicBottom = undefined;
    } else if (this.intrinsicBottom === undefined) {
      this.intrinsicBottom = bottom;

      // initialize coord if it's undefined
      if (this.coord === undefined) {
        this.coord = { translate: { y: 0 } };
      } else if (this.coord.translate === undefined) {
        this.coord = { ...this.coord, translate: { y: 0 } };
      } else if (this.coord.translate.y === undefined) {
        this.coord = { ...this.coord, translate: { ...this.coord.translate, y: 0 } };
      }
    } else {
      // translate the bbox extrinsically
      this.coord = { ...this.coord, translate: { ...this.coord!.translate, y: bottom - this.intrinsicBottom } };
    }
  }

  get width() {
    if (this.intrinsicWidth === undefined) {
      return undefined;
    }

    return this.intrinsicWidth * (this.coord?.scale?.x ?? 1);
  }

  set width(width: number | undefined) {
    if (isNaN(width)) {
      throw new Error('width is NaN');
    }

    if (width === undefined) {
      this.intrinsicWidth = undefined;
    } else if (this.intrinsicWidth === undefined) {
      this.intrinsicWidth = width;

      // initialize coord if it's undefined
      if (this.coord === undefined) {
        this.coord = { scale: { x: 1 } };
      } else if (this.coord.scale === undefined) {
        this.coord = { ...this.coord, scale: { x: 1 } };
      } else if (this.coord.scale.x === undefined) {
        this.coord = { ...this.coord, scale: { ...this.coord.scale, x: 1 } };
      }
    } else {
      // scale the bbox extrinsically
      this.coord = { ...this.coord, scale: { ...this.coord!.scale, x: width / this.intrinsicWidth } };
    }
  }

  get height() {
    if (this.intrinsicHeight === undefined) {
      return undefined;
    }

    return this.intrinsicHeight * (this.coord?.scale?.y ?? 1);
  }

  set height(height: number | undefined) {
    if (isNaN(height)) {
      throw new Error('height is NaN');
    }

    if (height === undefined) {
      this.intrinsicHeight = undefined;
    } else if (this.intrinsicHeight === undefined) {
      this.intrinsicHeight = height;

      // initialize coord if it's undefined
      if (this.coord === undefined) {
        this.coord = { scale: { y: 1 } };
      } else if (this.coord.scale === undefined) {
        this.coord = { ...this.coord, scale: { y: 1 } };
      } else if (this.coord.scale.y === undefined) {
        this.coord = { ...this.coord, scale: { ...this.coord.scale, y: 1 } };
      }
    } else {
      // scale the bbox extrinsically
      this.coord = { ...this.coord, scale: { ...this.coord!.scale, y: height / this.intrinsicHeight } };
    }
  }

  get intrinsicLeft() {
    return this._left;
  }

  set intrinsicLeft(left: number | undefined) {
    if (isNaN(left)) {
      throw new Error('left must be a number');
    }
    this._left = left;
    if (this._setLeft) {
      this._setLeft(left);
    }
    if (
      this.intrinsicRight !== undefined &&
      this.intrinsicLeft !== undefined &&
      this.intrinsicWidth !== this.intrinsicRight - this.intrinsicLeft
    ) {
      this.intrinsicWidth = this.intrinsicRight - this.intrinsicLeft;
    } else if (
      this.intrinsicWidth !== undefined &&
      this.intrinsicLeft !== undefined &&
      this.intrinsicRight !== this.intrinsicLeft + this.intrinsicWidth
    ) {
      this.intrinsicRight = this.intrinsicLeft + this.intrinsicWidth;
    }
  }

  get intrinsicTop() {
    return this._top;
  }

  set intrinsicTop(top: number | undefined) {
    if (isNaN(top)) {
      throw new Error('top must be a number');
    }
    this._top = top;
    if (this._setTop) {
      this._setTop(top);
    }
    if (
      this.intrinsicBottom !== undefined &&
      this.intrinsicTop !== undefined &&
      this.intrinsicHeight !== this.intrinsicBottom - this.intrinsicTop
    ) {
      this.intrinsicHeight = this.intrinsicBottom - this.intrinsicTop;
    } else if (
      this.intrinsicHeight !== undefined &&
      this.intrinsicTop !== undefined &&
      this.intrinsicBottom !== this.intrinsicTop + this.intrinsicHeight
    ) {
      this.intrinsicBottom = this.intrinsicTop + this.intrinsicHeight;
    }
  }

  get intrinsicRight() {
    return this._right;
  }

  set intrinsicRight(right: number | undefined) {
    if (isNaN(right)) {
      throw new Error('right must be a number');
    }
    this._right = right;
    if (this._setRight) {
      this._setRight(right);
    }
    if (
      this.intrinsicLeft !== undefined &&
      this.intrinsicRight !== undefined &&
      this.intrinsicWidth !== this.intrinsicRight - this.intrinsicLeft
    ) {
      this.intrinsicWidth = this.intrinsicRight - this.intrinsicLeft;
    } else if (
      this.intrinsicWidth !== undefined &&
      this.intrinsicRight !== undefined &&
      this.intrinsicLeft !== this.intrinsicRight - this.intrinsicWidth
    ) {
      this.intrinsicLeft = this.intrinsicRight - this.intrinsicWidth;
    }
  }

  get intrinsicBottom() {
    return this._bottom;
  }

  set intrinsicBottom(bottom: number | undefined) {
    if (isNaN(bottom)) {
      throw new Error('bottom must be a number');
    }
    this._bottom = bottom;
    if (this._setBottom) {
      this._setBottom(bottom);
    }
    if (
      this.intrinsicTop !== undefined &&
      this.intrinsicBottom !== undefined &&
      this.intrinsicHeight !== this.intrinsicBottom - this.intrinsicTop
    ) {
      this.intrinsicHeight = this.intrinsicBottom - this.intrinsicTop;
    } else if (
      this.intrinsicHeight !== undefined &&
      this.intrinsicBottom !== undefined &&
      this.intrinsicTop !== this.intrinsicBottom - this.intrinsicHeight
    ) {
      this.intrinsicTop = this.intrinsicBottom - this.intrinsicHeight;
    }
  }

  get intrinsicWidth() {
    return this._width;
  }

  set intrinsicWidth(width: number | undefined) {
    if (isNaN(width)) {
      throw new Error('width must be a number');
    }
    this._width = width;
    if (this._setWidth) {
      this._setWidth(width);
    }
    if (
      this.intrinsicLeft !== undefined &&
      this.intrinsicWidth !== undefined &&
      this.intrinsicRight !== this.intrinsicLeft + this.intrinsicWidth
    ) {
      this.intrinsicRight = this.intrinsicLeft + this.intrinsicWidth;
    } else if (
      this.intrinsicRight !== undefined &&
      this.intrinsicWidth !== undefined &&
      this.intrinsicLeft !== this.intrinsicRight - this.intrinsicWidth
    ) {
      this.intrinsicLeft = this.intrinsicRight - this.intrinsicWidth;
    }
  }

  get intrinsicHeight() {
    return this._height;
  }

  set intrinsicHeight(height: number | undefined) {
    if (isNaN(height)) {
      throw new Error('height must be a number');
    }
    this._height = height;
    if (this._setHeight) {
      this._setHeight(height);
    }
    if (
      this.intrinsicTop !== undefined &&
      this.intrinsicHeight !== undefined &&
      this.intrinsicBottom !== this.intrinsicTop + this.intrinsicHeight
    ) {
      this.intrinsicBottom = this.intrinsicTop + this.intrinsicHeight;
    } else if (
      this.intrinsicBottom !== undefined &&
      this.intrinsicHeight !== undefined &&
      this.intrinsicTop !== this.intrinsicBottom - this.intrinsicHeight
    ) {
      this.intrinsicTop = this.intrinsicBottom - this.intrinsicHeight;
    }
  }
}

// a function that returns a NewBBoxClass instance
export function newBBox() {
  return new NewBBoxClass({}, {});
}
