type Optional<T> = T | undefined;

export type DimensionModes = 'px' | '%' | 'fr' | 'fitContent' | 'gridUnit' | 'fillParent';

export type Dimension<Modes extends DimensionModes = DimensionModes> = {
  value: number;
  mode: Modes;
};

interface DimensionCache<Type extends object> {
  get(key: string): Type | undefined;
  set(key: string, dimension: Type): void;
  clear(): void;
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Dimension {
  let enableCaching = false;
  let cache: DimensionCache<Dimension<DimensionModes>>;

  export const configure = {
    enableCaching: (c: DimensionCache<Dimension<DimensionModes>>) => {
      enableCaching = true;
      cache = c;
    }
  };

  export function build<Mode extends DimensionModes>(dimension: Dimension<Mode>): Dimension<Mode>;
  export function build<Mode extends DimensionModes>(value: number, mode: Mode): Dimension<Mode>;
  export function build<Mode extends DimensionModes>(valueOrDimension: number | Dimension<Mode>, mode_?: Mode): Dimension<Mode> {
    const [value, mode] =
      typeof valueOrDimension === 'number' ? [valueOrDimension, mode_!] : [valueOrDimension.value, valueOrDimension.mode];

    if (!enableCaching) {
      return { value, mode } as Dimension<Mode>;
    }

    const key = `${value}-${mode}`;
    let dimension = cache.get(key) as Dimension<Mode>;

    if (!dimension) {
      dimension = Object.freeze({ value, mode }) as Dimension<Mode>;
      cache.set(key, dimension);
    }

    return dimension;
  }

  export const gridUnit = (value: number): Dimension<'gridUnit'> => build(value, 'gridUnit');

  export const fitContent = (value: number): Dimension<'fitContent'> => build(value, 'fitContent');

  export const fillParent = (value: number): Dimension<'fillParent'> => build(value, 'fillParent');

  export const px = (value: number): Dimension<'px'> => build(value, 'px');

  export const percent = (value: number): Dimension<'%'> => build(value, '%');

  interface DimensionBuilder<Mode1 extends DimensionModes, Mode2 extends DimensionModes> {
    asFirst: () => Dimension<Mode1>;
    asSecond: () => Dimension<Mode2>;
    as(mode: Mode1 | Mode2): Dimension<typeof mode>;
    value: number;
  }

  // note: return.value is represented in px if any of the inputs are in px
  const mixedOperationHelper = <T1 extends DimensionModes, T2 extends DimensionModes>(
    d: Dimension<T1>,
    d2: Dimension<T2>,
    parentSpace = 12,
    op: (a: number, b: number) => number
  ): DimensionBuilder<T1, T2> => {
    const d1Px = d.mode === 'px' ? d.value : toPx(d as Dimension<'gridUnit'>, parentSpace).value;
    const d2Px = d2.mode === 'px' ? d2.value : toPx(d2 as Dimension<'gridUnit'>, parentSpace).value;
    const valuePx = op(d1Px, d2Px);
    const valueGU = valuePx / parentSpace;
    return {
      asFirst: () => build(d.mode === 'px' ? valuePx : valueGU, d.mode),
      asSecond: () => build(d2.mode === 'px' ? valuePx : valueGU, d2.mode),
      as: (mode: T1 | T2) => build(mode === 'px' ? valuePx : valueGU, mode),
      value: d.mode === 'px' || d2.mode === 'px' ? valuePx : valueGU
    };
  };

  // Overloads for 'add'
  export function add(d: Dimension<'px'>, d2: Dimension<'px'>): DimensionBuilder<'px', 'px'>;
  export function add<M1 extends 'gridUnit' | 'fitContent' | 'fillParent', M2 extends 'gridUnit' | 'fitContent' | 'fillParent'>(
    d: Dimension<M1>,
    d2: Dimension<M2>
  ): DimensionBuilder<M1, M2>;
  export function add<
    M1 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px',
    M2 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px'
  >(d: Dimension<M1>, d2: Dimension<M2>, parentSpace: number): DimensionBuilder<M1, M2>;
  export function add(
    d: Dimension<DimensionModes>,
    d2: Dimension<DimensionModes>,
    parentSpace?: number
  ): DimensionBuilder<DimensionModes, DimensionModes> {
    return mixedOperationHelper(d, d2, parentSpace, (a, b) => a + b);
  }

  // Overloads for 'minus'
  export function minus(d: Dimension<'px'>, d2: Dimension<'px'>): DimensionBuilder<'px', 'px'>;
  export function minus<M1 extends 'gridUnit' | 'fitContent' | 'fillParent', M2 extends 'gridUnit' | 'fitContent' | 'fillParent'>(
    d: Dimension<M1>,
    d2: Dimension<M2>
  ): DimensionBuilder<M1, M2>;
  export function minus<
    M1 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px',
    M2 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px'
  >(d: Dimension<M1>, d2: Dimension<M2>, parentSpace: number): DimensionBuilder<M1, M2>;
  export function minus(
    d: Dimension<DimensionModes>,
    d2: Dimension<DimensionModes>,
    parentSpace?: number
  ): DimensionBuilder<DimensionModes, DimensionModes> {
    return mixedOperationHelper(d, d2, parentSpace, (a, b) => a - b);
  }

  // Overloads for 'min'
  export function min(d: Dimension<'px'>, d2: Dimension<'px'>): DimensionBuilder<'px', 'px'>;
  export function min<M1 extends 'gridUnit' | 'fitContent' | 'fillParent', M2 extends 'gridUnit' | 'fitContent' | 'fillParent'>(
    d: Dimension<M1>,
    d2: Dimension<M2>
  ): DimensionBuilder<M1, M2>;
  export function min<
    M1 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px',
    M2 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px'
  >(d: Dimension<M1>, d2: Dimension<M2>, parentSpace: number): DimensionBuilder<M1, M2>;
  export function min(
    d: Dimension<DimensionModes>,
    d2: Dimension<DimensionModes>,
    parentSpace?: number
  ): DimensionBuilder<DimensionModes, DimensionModes> {
    return mixedOperationHelper(d, d2, parentSpace, (a, b) => Math.min(a, b));
  }

  // Overloads for 'max'
  export function max(d: Dimension<'px'>, d2: Dimension<'px'>): DimensionBuilder<'px', 'px'>;
  export function max<M1 extends 'gridUnit' | 'fitContent' | 'fillParent', M2 extends 'gridUnit' | 'fitContent' | 'fillParent'>(
    d: Dimension<M1>,
    d2: Dimension<M2>
  ): DimensionBuilder<M1, M2>;
  export function max<
    M1 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px',
    M2 extends 'gridUnit' | 'fitContent' | 'fillParent' | 'px'
  >(d: Dimension<M1>, d2: Dimension<M2>, parentSpace: number): DimensionBuilder<M1, M2>;
  export function max(
    d: Dimension<DimensionModes>,
    d2: Dimension<DimensionModes>,
    parentSpace?: number
  ): DimensionBuilder<DimensionModes, DimensionModes> {
    return mixedOperationHelper(d, d2, parentSpace, (a, b) => Math.max(a, b));
  }

  export const toPx = <T extends Dimension<'gridUnit' | 'px' | 'fitContent' | 'fillParent'> | undefined>(
    d: T,
    parentSpace: number
  ): T extends Dimension<'gridUnit' | 'px' | 'fitContent' | 'fillParent'> ? Dimension<'px'> : Optional<Dimension<'px'>> => {
    if (d === undefined) {
      return d as never;
    }
    if (d.mode === 'px') {
      return d as never;
    }
    return build(d.value * parentSpace, 'px') as never;
  };

  export const toGridUnit = <T extends Dimension<'gridUnit' | 'px' | 'fitContent' | 'fillParent'> | undefined>(
    d: T,
    parentSpace: number
  ): GridUnitBuilder<T> => new GridUnitBuilder(d, parentSpace);
}

class GridUnitBuilder<
  Input extends Dimension<'gridUnit' | 'fitContent' | 'px' | 'fillParent'> | undefined,
  Output = Input extends Dimension<'gridUnit' | 'fitContent' | 'px' | 'fillParent'>
    ? Dimension<'gridUnit' | 'fitContent' | 'fillParent'>
    : Optional<Dimension<'gridUnit' | 'fitContent' | 'fillParent'>>
> {
  private dimension: Input;
  private parentSpace: number;

  constructor(dimension: Input, parentSpace: number) {
    this.dimension = { ...dimension };
    this.parentSpace = parentSpace;
  }

  roundDown(): Output {
    if (this.dimension === undefined) {
      return this.dimension as never;
    }
    if (this.dimension.mode === 'gridUnit' || this.dimension.mode === 'fitContent' || this.dimension.mode === 'fillParent') {
      return this.dimension as never;
    }
    return Dimension.gridUnit(Math.floor(this.dimension.value / this.parentSpace)) as never;
  }

  roundUp(): Output {
    if (this.dimension === undefined) {
      return this.dimension as never;
    }
    if (this.dimension.mode === 'gridUnit' || this.dimension.mode === 'fitContent' || this.dimension.mode === 'fillParent') {
      return this.dimension as never;
    }
    return Dimension.gridUnit(Math.ceil(this.dimension.value / this.parentSpace)) as never;
  }

  raw(): Output {
    if (this.dimension === undefined) {
      return this.dimension as never;
    }
    if (this.dimension.mode === 'gridUnit' || this.dimension.mode === 'fitContent' || this.dimension.mode === 'fillParent') {
      return this.dimension as never;
    }
    return Dimension.gridUnit(this.dimension.value / this.parentSpace) as never;
  }
}
