const NUMBER_OF_ROOMS = 12;
const TUNNEL_PASSES = 3;

class Cell {
  static CORRIDOR = "#";
  static DOOR_CLOSED = "+";

  character;
  characterColor;
  backgroundColor;

  constructor(char, charColor) {
    this.character = char;
  }

  empty() { this.character = " "; }
  floor() { this.character = "."; }
  upStair() { this.character = "<"; }
  downStair() { this.character = ">"; }
  corridor() { this.character = Cell.CORRIDOR; }
  topLeftWall() { this.character = "┌"; }
  topRightWall() { this.character = "┐"; }
  bottomLeftWall() { this.character = "└"; }
  bottomRightWall() { this.character = "┘"; }
  horizontalWall() { this.character = "─"; }
  verticalWall() { this.character = "│"; }
  doorClosed() { this.character = Cell.DOOR_CLOSED; }

  isEmpty() { return !this.character || this.character === " "; }
  isDoor() { return this.character === Cell.DOOR_CLOSED; }
  isCorridor() { return this.character === Cell.CORRIDOR; }

  canBecomeDoor() { return this.character === "─" || this.character === "│" }
}

class Point {
  x = 0;
  y = 0;

  constructor(x, y) {
    if (x) { this.x = x; }
    if (y) { this.y = y; }
  }

  *neighbors() {
    const x = this.x;
    const y = this.y;

    yield new Point(x - 1, y - 1);
    yield new Point(x,     y - 1);
    yield new Point(x + 1, y - 1);
    yield new Point(x - 1, y);
    yield new Point(x + 1, y);
    yield new Point(x - 1, y + 1);
    yield new Point(x,     y + 1);
    yield new Point(x + 1, y + 1);
  }

  equalsPoint(other) { return this.x === other.x && this.y === other.y; }
}

class Size {
  width = 0;
  height = 0;

  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
}

class Rect {
  origin = new Point();
  size = new Size();

  static fromCoordinates(x, y, w, h) {
    return new Rect(new Point(x, y), new Size(w, h));
  }

  constructor(origin, size) {
    this.origin = origin;
    this.size = size;
  }

  get minX() { return this.origin.x; }
  get minY() { return this.origin.y; }
  get maxX() { return this.origin.x + this.size.width; }
  get maxY() { return this.origin.y + this.size.height; }

  get width() { return this.size.width; }
  get height() { return this.size.height; }
  get area() { return this.size.width * this.size.height; }

  *xCoordinates() {
    for (let x = this.minX; x <= this.maxX; x++) {
      yield x;
    }
  }

  *yCoordinates() {
    for (let y = this.minY; y <= this.maxY; y++) {
      yield y;
    }
  }

  insetRect(inset) {
    const twiceInset = 2 * inset;

    return Rect.fromCoordinates(
      this.origin.x + inset,
      this.origin.y + inset,
      this.size.width - twiceInset,
      this.size.height - twiceInset
    );
  }

  intersects(otherRect) {
    if (otherRect.minX > this.maxX)
      return false;

    if (otherRect.maxX < this.minX)
      return false;

    if (otherRect.minY > this.maxY)
      return false;

    if (otherRect.maxY < this.minY)
      return false;

    return true;
  }
}

class Grid {
  #size;
  #cells = [];
  #rooms = [];

  constructor(width, height) {
    this.#size = new Size(width, height);

    this.#cells = new Array(width * height);
    for (let i = 0; i < this.#cells.length; i++) {
      this.#cells[i] = new Cell(" ");
    }
  }

  get bounds() { return new Rect(new Point(), this.#size); }
  get width() { return this.#size.width; }
  get height() { return this.#size.height; }
  get rooms() { return this.#rooms; }

  generate(p, roomGeneratorClass, tunnelGeneratorClass) {
    this.#generateRooms(p, new roomGeneratorClass(this, NUMBER_OF_ROOMS));
    this.#placeStairs();
    this.#digCorridors(p, new tunnelGeneratorClass(this, TUNNEL_PASSES));
  }

  #generateRooms(p, generator) {
    generator.generate(p);
    this.#rooms = generator.rooms;
  }

  #placeStairs() {
    const indexOfRoomWithUpStairs = randomInt(this.#rooms.length);
    const coordinateOfUpStair = this.#rooms[indexOfRoomWithUpStairs].randomPoint();
    this.cellAt(coordinateOfUpStair.x, coordinateOfUpStair.y).upStair();

    while (true) {
      let indexOfRoomForDownStair = randomInt(this.#rooms.length);
      if (indexOfRoomForDownStair == indexOfRoomWithUpStairs) {
        continue;
      }

      const coordinateOfDownStair = this.#rooms[indexOfRoomForDownStair].randomPoint();
      this.cellAt(coordinateOfDownStair.x, coordinateOfDownStair.y).downStair();

      break;
    }
  }

  #digCorridors(p, generator) {
    generator.generate(p);
  }

  pointIsInBounds(pt) { return pt.x >= 0 && pt.x < this.width && pt.y >= 0 && pt.y < this.height; }

  cellAt(x, y) { return this.#cells[y * this.width + x]; }
}

class Room {
  #rect;

  constructor(rect) {
    this.#rect = rect;
  }

  get bounds() { return this.#rect; }

  get minX() { return this.#rect.minX; }
  get minY() { return this.#rect.minY; }
  get maxX() { return this.#rect.maxX; }
  get maxY() { return this.#rect.maxY; }

  randomPoint() {
    return new Point(
      this.#rect.minX + 1 + randomInt(this.#rect.size.width - 2),
      this.#rect.minY + 1 + randomInt(this.#rect.size.height - 2)
    );
  }

  transformCellAt(pt, cell) {
    const minX = this.minX;
    const minY = this.minY;
    const maxX = this.maxX;
    const maxY = this.maxY;

    const x = pt.x;
    const y = pt.y;

    if (y === minY && x === minX) { cell.topLeftWall(); }
    else if (y === minY && x === maxX) { cell.topRightWall(); }
    else if (y === maxY && x === minX) { cell.bottomLeftWall(); }
    else if (y === maxY && x === maxX) { cell.bottomRightWall(); }
    else if (y === minY || y === maxY) { cell.horizontalWall(); }
    else if (x === minX || x === maxX) { cell.verticalWall(); }
    else if ((x > minX && x < maxX) && (y > minY && y < maxY)) { return cell.floor(); }
  }
}

class NRandomRoomsGenerator {
  static MIN_ROOM_DIMENSION = 7;
  static MAX_ROOM_DIMENSION = 12;

  #numberOfRooms = 12;
  #rooms;
  #grid;

  constructor(grid, numberOfRooms) {
    this.#grid = grid;

    if (numberOfRooms) {
      this.#numberOfRooms = numberOfRooms;
    }
  }

  get rooms() {
    if (!this.#rooms) {
      this.#generateRooms();
    }

    return this.#rooms;
  }

  get #bounds() { return this.#grid.bounds; }

  generate(p) {
    this.#generateRooms();

    for (let i = 0; i < this.#rooms.length; i++) {
      let room = this.#rooms[i];
      for (let y = room.minY; y <= room.maxY; y++) {
        for (let x = room.minX; x <= room.maxX; x++) {
          let point = new Point(x, y);
          let cell = this.#grid.cellAt(x, y);

          room.transformCellAt(point, cell);
          cell.characterColor = p.color(255);
        }
      }
    }
  }

  #generateRooms() {
    let rects = new Array();

    const sizeRange = NRandomRoomsGenerator.MAX_ROOM_DIMENSION - NRandomRoomsGenerator.MIN_ROOM_DIMENSION;

    while (rects.length < this.#numberOfRooms) {
      const randomSize = new Size(
        NRandomRoomsGenerator.MIN_ROOM_DIMENSION + randomInt(sizeRange),
        NRandomRoomsGenerator.MIN_ROOM_DIMENSION + randomInt(sizeRange)
      );

      const randomOrigin = new Point(
        this.#bounds.minX + randomInt(this.#bounds.maxX - randomSize.width),
        this.#bounds.minY + randomInt(this.#bounds.maxY - randomSize.height)
      );

      const proposedRoomRect = new Rect(randomOrigin, randomSize);

      // Check that the rect doesn't intersect with any other rects.
      if (rects.some(e => e.intersects(proposedRoomRect))) {
        continue;
      }

      rects.push(proposedRoomRect);
    }

    rects.sort((a, b) => {
      if (a.origin.x < b.origin.x) { return -1; }
      if (a.origin.x > b.origin.x) { return 1; }
      if (a.origin.y < b.origin.y) { return -1; }
      if (a.origin.y > b.origin.y) { return 1; }
      return 0;
    });

    this.#rooms = rects.map(r => new Room(r.insetRect(1)));
  }
}

class TunnelGenerator {
  static MAX_DOORS = 100;

  #grid;
  #passes = 3;
  #numberOfDoorsPlaced = 0;

  constructor(grid, passes) {
    this.#grid = grid;
    if (passes) { this.#passes = passes; }
  }

  generate(p) {
    console.group("Digging tunnels");

    if (this.#passes >= 1) {
      this.#doPassOne(p);
    }

    if (this.#passes >= 2) {
      this.#doPassTwo(p);
    }

    if (this.#passes >= 3) {
      this.#doPassThree(p);
    }

    console.groupEnd();
  }

  #doPassOne(p) {
    console.group("Pass 1");
    this.#iterateAndConnectRoomsWithOffset(p, 1);
    console.groupEnd();
  }

  #doPassTwo(p) {
    console.group("Pass 2");
    this.#iterateAndConnectRoomsWithOffset(p, 2);
    console.groupEnd();
  }

  #doPassThree(p) {
    console.group("Pass 3");
    this.#iterateAndConnectRoomsWithOffset(p, 3);
    console.groupEnd();
  }

  #iterateAndConnectRoomsWithOffset(p, offset) {
    let rooms = this.#grid.rooms;
    const numberOfRooms = rooms.length;
    for (let i = 0; i < numberOfRooms - offset; i++) {
      let fromRoom = rooms[i];
      let toRoom = rooms[i + offset];

      let [fromPoint, toPoint] = this.#findPointFromRoomToRoom(fromRoom, toRoom);
      if (!fromPoint || !toPoint) {
        continue;
      }

      for (let neighbor of fromPoint.neighbors()) {
        if (!this.#grid.pointIsInBounds(neighbor)) {
          continue;
        }

        let cell = this.#grid.cellAt(neighbor.x, neighbor.y);
        if ((neighbor.x === fromPoint.x || neighbor.y === fromPoint.y) && cell.canBecomeDoor()) {
          cell.doorClosed();
          this.#numberOfDoorsPlaced++;
          break;
        }
      }

      const successfullyFoundTargetPoint = this.#digCorridorFromPointToPoint(p, fromPoint, toPoint);

      if (successfullyFoundTargetPoint) {
        for (let neighbor of toPoint.neighbors()) {
          if (!this.#grid.pointIsInBounds(neighbor)) {
            continue;
          }

          let cell = this.#grid.cellAt(neighbor.x, neighbor.y);
          if ((neighbor.x === toPoint.x || neighbor.y === toPoint.y) && cell.canBecomeDoor()) {
            cell.doorClosed();
            this.#numberOfDoorsPlaced++;
            break;
          }
        }
      }
    }
  }

  /**
   * Dig a corridor from fromPoint to toPoint, assuming that both points are
   * adjacent to valid locations for doors on the map.
   *
   * This is as close a copy of dig_corridor in the Nethack source as I could
   * muster. It's not exactly pretty. This method assumed
   */
  #digCorridorFromPointToPoint(p, fromPoint, toPoint) {
    const MAX_STEPS = 500;

    if (!fromPoint || !toPoint) {
      return;
    }

    const fromX = fromPoint.x;
    const toX = toPoint.x;
    const fromY = fromPoint.y;
    const toY = toPoint.y;

    let curX = fromX;
    let curY = fromY;

    let dx = 0;
    let dy = 0;

    // Set up the initial direction of the dig.
    if (toX > curX) {
      dx = 1;
    } else if (toY > curY) {
      dy = 1;
    } else if (toX < curX) {
      dx = -1;
    } else {
      dy = -1;
    }

    curX -= dx;
    curY -= dy;

    let steps = 0;
    while (curX !== toX || curY !== toY) {
      if (steps++ > MAX_STEPS) {
        return false;
      }

      curX += dx;
      curY += dy;

      if (curX >= this.#grid.width - 1 || curX <= 0 || curY <= 0 || curY >= this.#grid.height - 1) {
        return false;
      }

      let cell = this.#grid.cellAt(curX, curY);
      if (cell.isEmpty()) {
        cell.corridor();
      } else if (!cell.isCorridor()) {
        return false;
      }

      let dix = Math.abs(curX - toX);
      let diy = Math.abs(curY - toY);

      if (dix > diy && diy) {
        const random = randomInt(dix - diy + 1);
        if (!random) {
          dix = 0;
        }
      } else if (diy > dix && dix) {
        const random = randomInt(dix - diy + 1);
        if (!random) {
          diy = 0;
        }
      }

      if (dy && dix > diy) {
        const ddx = curX > toX ? -1 : 1;

        let cell = this.#grid.cellAt(curX + ddx, curY);
        if (cell.isEmpty() || cell.isCorridor()) {
          dx = ddx;
          dy = 0;
          continue;
        }
      } else if (dx && diy > dix) {
        const ddy = curY > toY ? -1 : 1;

        let cell = this.#grid.cellAt(curX, curY + ddy);
        if (cell.isEmpty() || cell.isCorridor()) {
          dy = ddy;
          dx = 0;
          continue;
        }
      }

      cell = this.#grid.cellAt(curX + dx, curY + dy);
      if (cell.isEmpty() || cell.isCorridor()) {
        continue;
      }

      if (dx) {
        dx = 0;
        dy = toY < curY ? -1 : 1;
      } else {
        dy = 0;
        dx = toX < curX ? -1 : 1;
      }

      cell = this.#grid.cellAt(curX + dx, curY + dy);
      if (cell.isEmpty() || cell.isCorridor()) {
        continue;
      }

      dy = -dy;
      dx = -dx;
    }

    return true;
  }

  #findPointFromRoomToRoom(fromRoom, toRoom) {
    const fromRoomBounds = fromRoom.bounds;
    const toRoomBounds = toRoom.bounds;

    let foundFromPoint = false;
    let foundToPoint = false;

    let fromPoint;
    let toPoint;
    if (fromRoomBounds.maxX < toRoomBounds.minX) {
      // fromRoom is farther left than toRoom
      
      fromPoint = new Point(fromRoomBounds.maxX, fromRoomBounds.minY + 1 + randomInt(fromRoomBounds.height - 2));
      foundFromPoint = this.#canPlaceDoorAt(fromPoint);

      for (let y of fromRoomBounds.yCoordinates()) {
        fromPoint.y = y;
        foundFromPoint = this.#canPlaceDoorAt(fromPoint);
        if (foundFromPoint) {
          break;
        }
      }

      if (!foundFromPoint) {
        return [];
      }

      toPoint = new Point(toRoomBounds.minX, toRoomBounds.minY + 1 + randomInt(toRoomBounds.height - 2));
      foundToPoint = this.#canPlaceDoorAt(toPoint);

      for (let y of toRoomBounds.yCoordinates()) {
        toPoint.y = y;
        foundToPoint = this.#canPlaceDoorAt(toPoint);
        if (foundToPoint) {
          break;
        }
      }

      if (!foundToPoint) {
        return [];
      }

      fromPoint.x += 1;
      toPoint.x -= 1;
    } else if (fromRoomBounds.minX > toRoomBounds.maxX) {
      // fromRoom is farther right than toRoomBounds.

      fromPoint = new Point(toRoomBounds.maxX, toRoomBounds.minY + 1 + randomInt(toRoomBounds.height - 2));
      foundFromPoint = this.#canPlaceDoorAt(fromPoint);

      for (let y of toRoomBounds.yCoordinates()) {
        fromPoint.y = y;
        foundFromPoint = this.#canPlaceDoorAt(fromPoint);
        if (foundFromPoint) {
          break;
        }
      }

      if (!foundFromPoint) {
        return [];
      }

      toPoint = new Point(fromRoomBounds.minX, fromRoomBounds.minY + 1 + randomInt(fromRoomBounds.height - 2));
      foundToPoint = this.#canPlaceDoorAt(toPoint);

      for (let y of fromRoomBounds.yCoordinates()) {
        toPoint.y = y;
        foundToPoint = this.#canPlaceDoorAt(toPoint);
        if (foundToPoint) {
          break;
        }
      }

      if (!foundToPoint) {
        return [];
      }

      fromPoint.x -= 1;
      toPoint.x += 1;
    } else if (fromRoomBounds.maxY < (toRoomBounds.minY - 1)) {
      // fromRoom is above toRoom

      fromPoint = new Point(fromRoomBounds.minX + 1 + randomInt(fromRoomBounds.width - 2), fromRoomBounds.maxY);
      foundFromPoint = this.#canPlaceDoorAt(fromPoint);

      for (let x of fromRoomBounds.xCoordinates()) {
        fromPoint.x = x;
        foundFromPoint = this.#canPlaceDoorAt(fromPoint);
        if (foundFromPoint) {
          break;
        }
      }

      if (!foundFromPoint) {
        return [];
      }

      toPoint = new Point(toRoomBounds.minX + 1 + randomInt(toRoomBounds.width - 2), toRoomBounds.minY);
      foundToPoint = this.#canPlaceDoorAt(toPoint);

      for (let x of toRoomBounds.xCoordinates()) {
        toPoint.x = x;
        foundToPoint = this.#canPlaceDoorAt(toPoint);
        if (foundToPoint) {
          break;
        }
      }

      if (!foundToPoint) {
        return [];
      }

      fromPoint.y += 1;
      toPoint.y -= 1;
    } else if (fromRoomBounds.minY > (toRoomBounds.maxY + 1)) {
      // fromRoom is below toRoom

      fromPoint = new Point(toRoomBounds.minX + 1 + randomInt(toRoomBounds.width - 2), toRoomBounds.maxY);
      foundFromPoint = this.#canPlaceDoorAt(fromPoint);

      for (let x of toRoomBounds.xCoordinates()) {
        fromPoint.x = x;
        foundFromPoint = this.#canPlaceDoorAt(fromPoint);
        if (foundFromPoint) {
          break;
        }
      }

      if (!foundFromPoint) {
        return [];
      }

      toPoint = new Point(fromRoomBounds.minX + 1 + randomInt(fromRoomBounds.width - 2), fromRoomBounds.minY);
      foundToPoint = this.#canPlaceDoorAt(toPoint);

      for (let x of fromRoomBounds.xCoordinates()) {
        toPoint.x = x;
        foundToPoint = this.#canPlaceDoorAt(toPoint);
        if (foundToPoint) {
          break;
        }
      }

      if (!foundToPoint) {
        return [];
      }

      fromPoint.y += 1;
      toPoint.y -= 1;
    }

    return [fromPoint, toPoint];
  }

  #canPlaceDoorAt(pt) {
    if (this.#numberOfDoorsPlaced > TunnelGenerator.MAX_DOORS) {
      return false;
    }

    if (!this.#grid.cellAt(pt.x, pt.y).canBecomeDoor()) {
      return false;
    }

    for (let neighbor of pt.neighbors()) {
      if (!this.#grid.pointIsInBounds(neighbor)) {
        continue;
      }

      let cell = this.#grid.cellAt(neighbor.x, neighbor.y);
      if (cell.isDoor()) {
        return false;
      }
    }

    return true;
  }
}

function randomInt(n) {
  max = Math.floor(n);
  return Math.floor(Math.random() * max);
}

let grid;

new p5(p => {
  const CELL_WIDTH = 20;
  const CELL_HEIGHT = Math.floor(CELL_WIDTH * 1.3);

  p.setup = () => {
    const container = document.querySelector('#dungeon-background');
    console.assert(container, "Missing #dungeon-background element");

    canvasWidth = parseFloat(getComputedStyle(container).width);
    canvasHeight = parseFloat(getComputedStyle(container).height);

    let canvas = p.createCanvas(canvasWidth, canvasHeight);
    canvas.canvas.removeAttribute('style');
    container.appendChild(canvas.canvas);

    p.pixelDensity(p.displayDensity());
    p.textFont("Courier");

    const gridBounds = Rect.fromCoordinates(
      0, 0,
      Math.max(80, Math.ceil(canvasWidth / CELL_WIDTH) - 1),
      Math.max(24, Math.ceil(canvasHeight / CELL_HEIGHT) - 1)
    );

    console.log(`Generating grid with size ${gridBounds.size.width} x ${gridBounds.size.height}`);

    grid = new Grid(gridBounds.size.width, gridBounds.size.height);
    grid.generate(p, NRandomRoomsGenerator, TunnelGenerator);
  };

  p.draw = () => {
    console.log("Drawing");

    p.textSize(CELL_HEIGHT);

    for (let y = 0; y < grid.height; y++) {
      for (let x = 0; x < grid.width; x++) {
        let cell = grid.cellAt(x, y);

        let fillColor = cell.characterColor ? cell.characterColor : p.color(255);
        p.fill(fillColor);

        p.textAlign(p.CENTER, p.CENTER);
        p.text(cell.character, x * CELL_WIDTH, y * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT);
      }
    }

    p.noLoop();
  };

}, '#dungeon-background');
