import assertIsPositiveNumber from './helpers/generic/assertIsPositiveNumber';
import {
  rect_containsPoint,
  rect_detectCollision,
  rect_getDiff,
  rect_getNearestCorner,
  rect_getSegmentIntersectionIndices,
  rect_getSquareDistance,
  rect_isIntersecting,
} from './rect';
import {
  grid_toCell,
  grid_toCellRect,
  grid_toWorld,
  grid_traverse,
} from './grid';
import assertIsRect from './helpers/generic/assertIsRect';
import {
  bounce,
  cross,
  Response,
  slide,
  touch,
} from './helpers/world/responses';
import sortByTiAndDistance from './helpers/world/sortByTiAndDistance';

function defaultFilter(): 'slide' {
  return 'slide';
}

type ResponseType = 'bounce' | 'slide' | 'cross' | 'touch';

type Filter = (item: string, other: string) => ResponseType;

interface IItemInfo {
  item: string;
  ti1: number;
  ti2: number;
  weight: number;
}

export interface IRect {
  x: number;
  y: number;
  w: number;
  h: number;
}

export interface ICoords {
  x: number;
  y: number;
}

export interface Collision {
  other: any | null | undefined;
  item: string | null | undefined;
  type?: ResponseType;
  overlaps: boolean;
  ti: number;
  move: ICoords;
  normal: ICoords;
  touch: ICoords;
  itemRect: IRect;
  otherRect: IRect;
  slide?: ICoords;
  bounce?: ICoords;
}

export type Cell = {
  ID: string;
  x: number;
  y: number;
  items: Record<string, boolean>;
};

function sortByWeight(a: IItemInfo, b: IItemInfo): number {
  return a.weight - b.weight;
}

function getCellsTouchedBySegment(
  self: World,
  x1: number,
  y1: number,
  x2: number,
  y2: number
): Cell[] {
  const cells: Cell[] = [];
  const visited: Record<string, boolean> = {};

  grid_traverse(self.cellSize, x1, y1, x2, y2, function(
    cx: number,
    cy: number
  ) {
    let row: Cell[] = self.rows[cy];

    if (!row) return;

    let cell = row[cx];

    if (!cell || visited[cell.ID]) return;

    visited[cell.ID] = true;

    cells.push(cell);
  });

  return cells;
}

function getInfoAboutItemsTouchedBySegment(
  self: World,
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  filter?: (other?: string) => boolean
): { item: string; ti1: number; ti2: number; weight: number | null }[] {
  let cells = getCellsTouchedBySegment(self, x1, y1, x2, y2);
  let rect, ti1, ti2;

  let visited: Record<string, boolean> = {};
  let itemInfo: IItemInfo[] = [];

  for (let i = 0; i < cells.length; i++) {
    const cell = cells[i];

    if (cell?.items)
      for (const itemID of Object.keys(cell.items)) {
        if (!visited[itemID]) {
          visited[itemID] = true;

          if (!filter || filter(itemID)) {
            rect = self['getRect'](itemID);

            const intersections1 = rect_getSegmentIntersectionIndices(
              rect,
              x1,
              y1,
              x2,
              y2,
              0,
              1
            );

            if (!intersections1) continue;

            ti1 = intersections1[0];
            ti2 = intersections1[1];

            if (
              !isNaN(ti1 as number) &&
              ((0 < ti1! && ti1! < 1) || (0 < ti2! && ti2! < 1))
            ) {
              // -- the sorting is according to the t of an infinite line, not the segment
              const intersections2 = rect_getSegmentIntersectionIndices(
                rect,
                x1,
                y1,
                x2,
                y2,
                -Number.MAX_SAFE_INTEGER,
                Number.MAX_SAFE_INTEGER
              );

              if (!intersections2) continue;

              const [tii0, tii1] = intersections2;

              itemInfo.push({
                item: itemID,
                ti1: ti1!,
                ti2: ti2!,
                weight: Math.min(tii0 || 0, tii1 || 0),
              });
            }
          }
        }
      }
  }

  return itemInfo.sort(sortByWeight);
}

export class World {
  responses: Record<ResponseType, Response> = {} as Record<
    ResponseType,
    Response
  >;
  cellSize: number = 0;
  rows: Cell[][];
  rects: Map<string, IRect> = new Map<string, IRect>();
  nonEmptyCells: Record<string, boolean>;

  constructor(input: {
    cellSize: number;
    rects: {};
    rows: [];
    nonEmptyCells: {};
    responses: Record<ResponseType, Response>;
  }) {
    this.cellSize = input.cellSize;

    this.rects = new Map(Object.entries(input.rects));

    this.rows = input.rows;
    this.nonEmptyCells = input.nonEmptyCells;
    this.responses = input.responses;
  }

  addResponse(name: ResponseType, response: Response): void {
    this.responses[name] = response;
  }

  getResponseByName(name: ResponseType): Response {
    const response = this.responses[name];

    if (!response)
      throw new Error(`Unknown collision type: ${name} (${typeof name})`);

    return response;
  }

  project(
    itemID: string | null,
    rect: IRect,
    goalX?: number,
    goalY?: number,
    filter?: (item: string, other: string) => ResponseType | false
  ): Collision[] {
    const _goalX = goalX ?? rect.x;
    const _goalY = goalY ?? rect.y;

    const _filter = filter || defaultFilter;

    const collisions: Collision[] = [];

    let visited: Set<string> = new Set();

    if (itemID) visited.add(itemID);

    // This could probably be done with less cells using a polygon raster over
    // the cells instead of a bounding rect of the whole movement. Conditional
    // to building a queryPolygon method.
    const tl: number = _goalX !== rect.x ? Math.min(_goalX, rect.x) : _goalX;
    const tt: number = _goalY !== rect.y ? Math.min(_goalY, rect.y) : _goalY;

    const tr: number =
      _goalX !== rect.x
        ? Math.max(_goalX + rect.w, rect.x + rect.w)
        : _goalX + rect.w;
    const tb: number =
      _goalY !== rect.y
        ? Math.max(_goalY + rect.h, rect.y + rect.h)
        : _goalY + rect.h;

    const tw: number = tr - tl;
    const th: number = tb - tt;

    const cellRect = grid_toCellRect(this.cellSize, {
      x: tl,
      y: tt,
      w: tw,
      h: th,
    });

    const itemsInCellRect = this.getItemsInCellRect(cellRect);

    for (let i = 0; i < itemsInCellRect.length; i++) {
      const other = itemsInCellRect[i];

      if (!visited.has(other)) {
        visited.add(other);

        if (!this.hasItem(other)) continue;

        const responseName: ResponseType | false = _filter(itemID!, other);

        if (responseName !== false) {
          const collision:
            | Partial<Collision>
            | undefined = rect_detectCollision(
            rect,
            this.getRect(other),
            _goalX,
            _goalY
          );

          if (collision)
            collisions.push({
              ...collision,
              other,
              item: itemID,
              type: responseName,
            } as Collision);
        }
      }
    }

    return collisions.sort(sortByTiAndDistance);
  }

  countCells(): number {
    let count = 0;

    for (const row of this.rows.filter(row => !!row))
      for (const _col of row) if (!!_col) count++;

    return count;
  }

  hasItem(item: string): boolean {
    return this.rects.has(item);
  }

  getItems(): IRect[] {
    return Array.from(this.rects.values());
  }

  countItems(): number {
    return this.rects.size;
  }

  private addItemToCell(itemID: string, cx: number, cy: number): void {
    this.rows[cy] = this.rows[cy] || [];

    const row = this.rows[cy];

    // Initialize a cell if no cell is present at this point.
    if (!row[cx])
      row[cx] = {
        ID: `Cell_${cx}:${cy}`,
        x: cx,
        y: cy,
        items: {},
      };

    const cell = row[cx];

    this.nonEmptyCells[cell.ID] = true;

    if (!cell.items[itemID]) cell.items[itemID] = true;
  }

  getRect(itemID: string): IRect {
    const rect = this.rects.get(itemID);

    if (!rect)
      throw new Error(
        `Item "${itemID}" must be added to the world before getting its rect. Use world:add(item, x,y,w,h) to add it first.`
      );

    return rect;
  }

  private getItemsInCellRect(cellRect: IRect): IItemInfo['item'][] {
    const results = [];

    for (let cy = cellRect.y; cy <= cellRect.y + cellRect.h - 1; cy++) {
      let row = this.rows[cy];

      if (row)
        for (let cx = cellRect.x; cx <= cellRect.x + cellRect.w - 1; cx++) {
          let cell = row[cx];

          if (cell?.items && Object.keys(cell.items)?.length > 0)
            // no cell.itemCount > 1 because tunneling
            results.push(Object.keys(cell.items));
        }
    }

    return results.flat();
  }

  // Optimized version of getDictItemsInCellRect only used in
  // queryPoint, made to avoid an unneeded object creation.
  getItemsInCellPoint(x: number, y: number): IItemInfo['item'][] {
    const items: string[] = [];

    for (let cy = y; cy <= y; cy++) {
      let row = this.rows[cy];

      if (row)
        for (let cx = x; cx <= x; cx++) {
          let cell = row[cx];

          if (cell?.items && Object.keys(cell.items)?.length > 0)
            // no cell.itemCount > 1 because tunneling
            items.push(...Object.keys(cell.items));
        }
    }

    return items;
  }

  private removeItemFromCell(itemID: string, cx: number, cy: number): boolean {
    let row = this.rows[cy];

    if (!row?.[cx]?.['items']?.[itemID]) return false;

    let cell = row[cx];

    delete cell.items[itemID];

    if (Object.keys(cell.items)?.length === 0)
      delete this.nonEmptyCells[cell.ID];

    return true;
  }

  toWorld(cx: number, cy: number): [number, number] {
    return grid_toWorld(this.cellSize, cx, cy);
  }

  toCell(x: number, y: number): [number, number] {
    return grid_toCell(this.cellSize, x, y);
  }

  queryRect(
    x: number,
    y: number,
    w: number,
    h: number,
    filter?: (other?: string) => boolean
  ): string[] {
    assertIsRect(x, y, w, h);

    const cellRect = grid_toCellRect(this.cellSize, { x, y, w, h });
    const itemsInCellRect = this.getItemsInCellRect(cellRect);

    const items: string[] = [];

    for (const itemID of itemsInCellRect)
      if (
        (!filter || filter(itemID)) &&
        rect_isIntersecting(x, y, w, h, this.getRect(itemID))
      )
        items.push(itemID);

    return [...new Set(items)];
  }

  queryPoint(
    x: number,
    y: number,
    filter?: (other?: string) => boolean
  ): string[] {
    const [cx, cy] = this.toCell(x, y);
    const itemsInCellRect = this.getItemsInCellPoint(cx, cy);

    const items: string[] = [];

    for (let i = 0; i < itemsInCellRect.length; i++) {
      const itemID = itemsInCellRect[i];

      if (
        (!filter || filter(itemID)) &&
        rect_containsPoint(this.getRect(itemID), x, y)
      )
        items.push(itemID);
    }

    return items;
  }

  querySegment(
    x1: number,
    y1: number,
    x2: number,
    y2: number,
    filter?: (other?: string) => boolean
  ): string[] {
    const itemsInfo = getInfoAboutItemsTouchedBySegment(
      this,
      x1,
      y1,
      x2,
      y2,
      filter
    );

    const items: string[] = [];

    if (itemsInfo)
      for (let i = 0; i < itemsInfo.length; i++) items.push(itemsInfo[i].item);

    return items;
  }

  querySegmentWithCoords(
    x1: number,
    y1: number,
    x2: number,
    y2: number,
    filter?: (other?: string) => boolean
  ): {
    item: string;
    ti1: number;
    ti2: number;
    weight: number | null;
    x1: number;
    x2: number;
    y1: number;
    y2: number;
  }[] {
    let itemInfo: any = getInfoAboutItemsTouchedBySegment(
      this,
      x1,
      y1,
      x2,
      y2,
      filter
    );

    let dx: number = x2 - x1;
    let dy: number = y2 - y1;

    let info: any;
    let ti1: number;
    let ti2: number;

    for (const item of itemInfo) {
      info = item;

      ti1 = info.ti1;
      ti2 = info.ti2;

      info.weight = null;
      info.x1 = x1 + dx * ti1;
      info.y1 = y1 + dy * ti1;
      info.x2 = x1 + dx * ti2;
      info.y2 = y1 + dy * ti2;
    }

    return itemInfo;
  }

  public add(
    itemID: string,
    x: number,
    y: number,
    w: number,
    h: number
  ): string {
    const rect: IRect | undefined = this.rects.get(itemID);

    if (rect) throw new Error(`Item "${itemID}" added to the world twice.`);

    assertIsRect(x, y, w, h);

    const rectToAdd = { x, y, w, h };

    this.rects.set(itemID, rectToAdd);

    const cellRect = grid_toCellRect(this.cellSize, rectToAdd);

    for (let cy = cellRect.y; cy < cellRect.y + cellRect.h; cy++)
      for (let cx = cellRect.x; cx < cellRect.x + cellRect.w; cx++)
        this.addItemToCell(itemID, cx, cy);

    return itemID;
  }

  remove(itemID: string): void {
    const _itemRect = this.getRect(itemID);
    const itemRect: IRect = {
      x: _itemRect.x,
      y: _itemRect.y,
      w: _itemRect.w,
      h: _itemRect.h,
    };

    this.rects.delete(itemID);

    let cellRect = grid_toCellRect(this.cellSize, itemRect);

    for (let cy = cellRect.y; cy < cellRect.y + cellRect.h; cy++)
      for (let cx = cellRect.x; cx < cellRect.x + cellRect.w; cx++)
        this.removeItemFromCell(itemID, cx, cy);
  }

  update(
    itemID: string,
    x2: number,
    y2: number,
    w2?: number,
    h2?: number
  ): void {
    let itemRect = this.getRect(itemID);

    w2 = isNaN(w2!) ? itemRect.w : w2;
    h2 = isNaN(h2!) ? itemRect.h : h2;

    assertIsRect(x2, y2, w2!, h2!);

    if (
      itemRect.x != x2 ||
      itemRect.y != y2 ||
      itemRect.w != w2 ||
      itemRect.h != h2
    ) {
      const cellRect1 = grid_toCellRect(this.cellSize, itemRect);

      const cellRect2 = grid_toCellRect(this.cellSize, {
        x: x2,
        y: y2,
        w: w2!,
        h: h2!,
      });

      if (
        cellRect1.x != cellRect2.x ||
        cellRect1.y != cellRect2.y ||
        cellRect1.w != cellRect2.w ||
        cellRect1.h != cellRect2.h
      ) {
        const cr1: number = cellRect1.x + cellRect1.w - 1;
        const cb1: number = cellRect1.y + cellRect1.h - 1;

        const cr2: number = cellRect2.x + cellRect2.w - 1;
        const cb2: number = cellRect2.y + cellRect2.h - 1;

        let cyOut: boolean;

        for (let cy = cellRect1.y; cy <= cb1; cy++) {
          cyOut = Number(cy) < cellRect2.y || cy > cb2;

          for (let cx = cellRect1.x; cx <= cr1; cx++)
            if (cyOut || cx < cellRect2.x || cx > cr2)
              this.removeItemFromCell(itemID, cx, cy);
        }

        for (let cy = cellRect2.y; cy <= cb2; cy++) {
          cyOut = cy < cellRect1.y || cy > cb1;

          for (let cx = cellRect2.x; cx <= cr2; cx++)
            if (cyOut || cx < cellRect1.x || cx > cr1)
              this.addItemToCell(itemID, cx, cy);
        }
      }

      this.rects.set(itemID, {
        x: x2,
        y: y2,
        w: w2!,
        h: h2!,
      });
    }
  }

  public move(
    itemID: string,
    goalX: number,
    goalY: number,
    filter?: Filter
  ): ReturnType<Response> {
    const { x, y, collisions } = this.check(itemID, goalX, goalY, filter);

    this.update(itemID, x, y);

    return { x, y, collisions };
  }

  check(
    itemID: string,
    goalX: number,
    goalY: number,
    filter?: Filter
  ): ReturnType<Response> {
    const checkFilter: Filter = filter || defaultFilter;

    let visited: Record<string, boolean> = {};
    visited[itemID] = true;

    const visitedFilter = (item: string, other: string) =>
      !!visited[other] ? false : checkFilter(item, other);

    let detectedCollisions: Collision[] = [];

    const itemRect: IRect = this.getRect(itemID);

    let projectedCollisions: Collision[] = this.project(
      itemID,
      itemRect,
      goalX,
      goalY,
      visitedFilter
    );

    let collisionsCounter: number = projectedCollisions?.length || 0;

    while (collisionsCounter > 0) {
      const collision: Collision = projectedCollisions[0];

      detectedCollisions.push(collision);

      visited[collision.other] = true;

      let response = this.getResponseByName(collision.type!);

      const { x, y, collisions } = response(
        this,
        collision,
        itemRect,
        goalX,
        goalY,
        visitedFilter
      );

      goalX = x;
      goalY = y;
      projectedCollisions = collisions;

      collisionsCounter = collisions?.length || 0;
    }

    return { x: goalX, y: goalY, collisions: detectedCollisions };
  }
}

// Public library functions

const bump = {
  newWorld: function(cellSize: number): World {
    cellSize = cellSize || 64;

    assertIsPositiveNumber(cellSize, 'cellSize');

    const world: World = new World({
      cellSize: cellSize,
      rects: {},
      rows: [],
      nonEmptyCells: {},
      responses: {} as Record<string, Response>,
    });

    world.addResponse('touch', touch);
    world.addResponse('cross', cross);
    world.addResponse('slide', slide);
    world.addResponse('bounce', bounce);

    return world;
  },
  rect: {
    getNearestCorner: rect_getNearestCorner,
    getSegmentIntersectionIndices: rect_getSegmentIntersectionIndices,
    getDiff: rect_getDiff,
    containsPoint: rect_containsPoint,
    isIntersecting: rect_isIntersecting,
    getSquareDistance: rect_getSquareDistance,
    detectCollision: rect_detectCollision,
  },
  responses: {
    touch,
    cross,
    slide,
    bounce,
  },
};

export default Object.freeze(bump);
