import { Collision, System, SystemNames } from ".";
import { Game } from "..";
import { Entity } from "../entities";
import { PhysicsConstants } from "../config";
import {
  BoundingBox,
  ComponentNames,
  FacingDirection,
  Highlight,
  Grid as GridComponent,
} from "../components";
import { Coord2D, Direction, Dimension2D } from "../interfaces";
import { clamp } from "../utils";

export class Grid extends System {
  public dimension: Dimension2D;
  public grid: Set<string>[][] = [];

  constructor(
    { width: columns, height: rows }: Dimension2D,
    dimension: Dimension2D
  ) {
    super(SystemNames.Grid);

    this.dimension = dimension;
    this.grid = new Array(rows)
      .fill(null)
      .map(() => new Array(columns).fill(null).map(() => new Set()));
  }

  public update(dt: number, game: Game) {
    this.putUninitializedEntitiesInGrid(game);
    this.rebuildGrid(game);

    this.highlightEntitiesLookedAt(game);

    this.propogateEntityMovements(game);
    this.updateMovingEntities(dt, game);
  }

  private highlightEntitiesLookedAt(game: Game) {
    const highlightableEntities: [string, Direction][] = [];

    game.forEachEntityWithComponent(
      ComponentNames.FacingDirection,
      (entity) => {
        if (!entity.hasComponent(ComponentNames.Grid)) {
          return;
        }

        const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
        const facingDirection = entity.getComponent<FacingDirection>(
          ComponentNames.FacingDirection
        )!;
        const lookingAt = this.getNewGridPosition(
          grid.gridPosition,
          facingDirection.currentDirection
        );
        if (
          facingDirection.currentDirection === Direction.NONE ||
          this.isOutOfBounds(lookingAt)
        ) {
          return;
        }

        this.grid[lookingAt.y][lookingAt.x].forEach((id) => {
          highlightableEntities.push([id, facingDirection.currentDirection]);
        });
      }
    );

    highlightableEntities.forEach(([id, direction]) => {
      const entity = game.getEntity(id)!;
      if (entity.hasComponent(ComponentNames.Highlight)) {
        const highlight = entity.getComponent<Highlight>(
          ComponentNames.Highlight
        )!;
        highlight.highlight(direction);
      }
    });

    game.forEachEntityWithComponent(ComponentNames.Highlight, (entity) => {
      if (!highlightableEntities.find(([id]) => id === entity.id)) {
        const highlight = entity.getComponent<Highlight>(
          ComponentNames.Highlight
        )!;
        highlight.unhighlight();
      }
    });
  }

  private propogateEntityMovements(game: Game) {
    const movingEntities: Entity[] = [];
    game.forEachEntityWithComponent(ComponentNames.Grid, (entity) => {
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
      if (grid.movingDirection !== Direction.NONE) {
        movingEntities.push(entity);
      }
    });

    // for each moving entity, check the entities in the grid cell it's moving to
    // if they are pushable, move them in the same direction
    // continue until no more pushable entities are found
    for (const entity of movingEntities) {
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
      const { gridPosition, movingDirection } = grid;

      grid.movingDirection = Direction.NONE;
      entity.addComponent(grid); // default to not moving

      let [currentPosition, nextGridPosition] = [
        gridPosition,
        this.getNewGridPosition(gridPosition, movingDirection),
      ];

      const moving = new Set<string>();
      moving.add(entity.id);

      while (true) {
        if (this.isOutOfBounds(nextGridPosition)) {
          moving.clear();
          break;
        }

        const { x, y } = nextGridPosition;
        const entities = Array.from(this.grid[y][x]).map(
          (id) => game.getEntity(id)!
        );

        const collidingEntities = entities.filter((entity) =>
          entity.hasComponent(ComponentNames.Colliding)
        );

        if (collidingEntities.length > 0) {
          // i.e. key going into a door or function going into an application
          const allEntitiesInPreviousCellCanCollide = Array.from(
            this.grid[currentPosition.y][currentPosition.x]
          )
            .map((id) => game.getEntity(id)!)
            .every((entity) =>
              collidingEntities.every((collidingEntity) =>
                Collision.canCollide(entity.name, collidingEntity.name)
              )
            );
          if (allEntitiesInPreviousCellCanCollide) {
            break;
          }
          moving.clear();
          break;
        }

        const pushableEntities = entities.filter((entity) => {
          if (!entity.hasComponent(ComponentNames.Grid)) return false;

          const { movingDirection } = entity.getComponent<GridComponent>(
            ComponentNames.Grid
          )!;
          const pushable = entity.hasComponent(ComponentNames.Pushable);
          return movingDirection === Direction.NONE && pushable;
        });
        if (pushableEntities.length === 0) {
          break;
        }

        for (const pushableEntity of pushableEntities) {
          moving.add(pushableEntity.id);
        }

        currentPosition = nextGridPosition;
        nextGridPosition = this.getNewGridPosition(
          nextGridPosition,
          movingDirection
        );
      }

      for (const id of moving) {
        const entity = game.getEntity(id)!;
        const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
        grid.movingDirection = movingDirection;
        entity.addComponent(grid);
      }
    }
  }

  private putUninitializedEntitiesInGrid(game: Game) {
    game.forEachEntityWithComponent(ComponentNames.Grid, (entity) => {
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;

      if (grid.initialized) {
        return;
      }

      const hasBoundingBox = entity.hasComponent(ComponentNames.BoundingBox);
      if (!hasBoundingBox) {
        return;
      }

      const boundingBox = entity.getComponent<BoundingBox>(
        ComponentNames.BoundingBox
      )!;
      boundingBox.center = this.gridToScreenPosition(grid.gridPosition);
      boundingBox.dimension = this.dimension;
      entity.addComponent(boundingBox);

      grid.initialized = true;
      entity.addComponent(grid);
    });
  }

  private updateMovingEntities(
    dt: number,
    game: Game,
    velocity = PhysicsConstants.GRID_MOVEMENT_VELOCITY
  ) {
    game.forEachEntityWithComponent(ComponentNames.Grid, (entity) => {
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;

      if (grid.movingDirection === Direction.NONE) {
        return;
      }
      grid.previousDirection = grid.movingDirection;

      const boundingBox = entity.getComponent<BoundingBox>(
        ComponentNames.BoundingBox
      )!;

      const newGridPosition = this.getNewGridPosition(
        grid.gridPosition,
        grid.movingDirection
      );
      if (this.isOutOfBounds(newGridPosition)) {
        grid.movingDirection = Direction.NONE;
        entity.addComponent(grid);
        return;
      }

      let { dx, dy } = { dx: 0, dy: 0 };
      switch (grid.movingDirection) {
        case Direction.LEFT:
          dx = -velocity * dt;
          break;
        case Direction.RIGHT:
          dx = velocity * dt;
          break;
        case Direction.UP:
          dy = -velocity * dt;
          break;
        case Direction.DOWN:
          dy = velocity * dt;
          break;
      }

      const { x, y } = boundingBox.center;
      const nextPosition = { x: x + dx, y: y + dy };
      const passedCenter = this.isEntityPastCenterWhenMoving(
        grid.movingDirection,
        newGridPosition,
        nextPosition
      );

      if (passedCenter) {
        // re-align the entity to its new grid position
        this.grid[grid.gridPosition.y][grid.gridPosition.x].delete(entity.id);

        grid.gridPosition = newGridPosition;
        grid.movingDirection = Direction.NONE;
        this.grid[grid.gridPosition.y][grid.gridPosition.x].add(entity.id);
        entity.addComponent(grid);

        boundingBox.center = this.gridToScreenPosition(grid.gridPosition);
        entity.addComponent(boundingBox);
        return;
      }

      boundingBox.center = nextPosition;
      entity.addComponent(boundingBox);
    });
  }

  public getNewGridPosition(prev: Coord2D, direction: Direction) {
    let { x: newX, y: newY } = prev;
    switch (direction) {
      case Direction.LEFT:
        newX -= 1;
        break;
      case Direction.UP:
        newY -= 1;
        break;
      case Direction.DOWN:
        newY += 1;
        break;
      case Direction.RIGHT:
        newX += 1;
        break;
    }

    return { x: newX, y: newY };
  }

  public oppositeDirection(direction: Direction) {
    let opposite = Direction.NONE;
    switch (direction) {
      case Direction.LEFT:
        opposite = Direction.RIGHT;
        break;
      case Direction.RIGHT:
        opposite = Direction.LEFT;
        break;
      case Direction.UP:
        opposite = Direction.DOWN;
        break;
      case Direction.DOWN:
        opposite = Direction.UP;
        break;
    }
    return opposite;
  }

  private isEntityPastCenterWhenMoving(
    direction: Direction,
    gridPosition: Coord2D,
    entityPosition: Coord2D
  ) {
    const { x, y } = this.gridToScreenPosition(gridPosition);
    switch (direction) {
      case Direction.LEFT:
        return entityPosition.x <= x;
      case Direction.RIGHT:
        return entityPosition.x >= x;
      case Direction.UP:
        return entityPosition.y <= y;
      case Direction.DOWN:
        return entityPosition.y >= y;
    }
    return false;
  }

  public gridToScreenPosition(gridPosition: Coord2D) {
    const { width, height } = this.dimension;
    return {
      x: gridPosition.x * width + width / 2,
      y: gridPosition.y * height + height / 2,
    };
  }

  private isOutOfBounds(position: Coord2D) {
    const isOutOfBoundsX =
      clamp(position.x, 0, this.grid[0].length - 1) !== position.x;
    const isOutOfBoundsY =
      clamp(position.y, 0, this.grid.length - 1) !== position.y;
    return isOutOfBoundsX || isOutOfBoundsY;
  }

  private rebuildGrid(game: Game) {
    const movedEntities = new Set<string>();
    game.forEachEntityWithComponent(ComponentNames.Grid, (entity) => {
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
      const { x, y } = grid.gridPosition;
      if (!this.grid[y][x].has(entity.id)) {
        movedEntities.add(entity.id);
        this.grid[y][x].add(entity.id);
      }
    });
    this.grid.forEach((row) =>
      row.forEach((cell) => {
        for (const id of cell) {
          if (movedEntities.has(id) || !game.getEntity(id)) {
            cell.delete(id);
          }
        }
      })
    );
    movedEntities.forEach((id) => {
      const entity = game.getEntity(id)!;
      const grid = entity.getComponent<GridComponent>(ComponentNames.Grid)!;
      const { x, y } = grid.gridPosition;
      this.grid[y][x].add(id);
    });
  }

  public getCenterGrid() {
    return {
      x: Math.floor(this.grid[0].length / 2),
      y: Math.floor(this.grid.length / 2),
    };
  }
}
