/* eslint max-classes-per-file: 0 */
/* eslint no-loop-func: 0 */
/* eslint max-len: 0 */
/* eslint no-const-assign: 0 */

MATTIE.util = MATTIE.util || {};

// the chance on each wall tile that the rest of the wall will not generate
const chanceForWallToCollapse = 0.05;
/** the min and max amount of wall that can collapse */
const [minCollapse, maxCollapse] = [2, 5];
const wallTile = 1579;
const floorTiles = [1587];
const collapseTiles = [1661, 1662, 1663];
const floorTile = () => floorTiles[Math.floor(Math.random() * floorTiles.length)];
const collapseTile = () => collapseTiles[Math.floor(Math.random() * collapseTiles.length)];
const logging = false;
const defaultCanvasId = 'viewport';
/** the max depth to go to for width/height based splitting */
const maxDepthForWidthSplit = 10;

/** @todo remove this when done testing */
function getRandomColor() {
	var letters = '0123456789ABCDEF';
	var color = '#';
	for (var i = 0; i < 6; i++) {
		color += letters[Math.floor(Math.random() * 16)];
	}
	return color;
}

/** @description a class representing a x and y pair. */
class RougeLikePoint {
	/** @description init a new RougeLikePoint with an x and a y cord. */
	constructor(x, y) {
		/** @description the x cord */
		this.x = x;
		/** @description the y cord */
		this.y = y;
	}
}

/**
 * @description a class representing a single tile on the map
 */
class Tile {
	/**
	 *
	 * @param {number} x the x cord of this tile
	 * @param {number} y the y cord of this tile
	 * @param {boolean} isWall is this tile a wall?
	 */
	constructor(x, y, isWall = true, tileId = floorTile()) {
		/**
		 * @description the position of the tile
		 * @type {RougeLikePoint}
		 */
		this.pos = new RougeLikePoint(x, y);
		/** a variable for weather the tile is a wall or not */
		this.isWall = isWall;
		/** the variable for tile id */
		this.tileId = tileId;
	}
}

/**
 * @description a class representing a region of space on the map.
 */
class Region {
	/**
	 * @description make a new region from 4 cords, top left corner then bottom right.
	 * @param {number} topX x cord for top left corner
	 * @param {number} topY y cord for top left corner
	 * @param {number} botX x cord for top right corner
	 * @param {number} botY y cord for top right corner
	 */
	constructor(topX, topY, botX, botY, parent = null) {
		/**
		 * @description an dict of left,right,up and down arrays of all regions this region directly connects to
		 * @type {{Region[]}}
		 */
		this.connectsTo = {
			left: [],
			right: [],
			up: [],
			down: [],
		};

		/**
		 * the upper left corner of the region
		 * @type {RougeLikePoint}
		 * */
		this.upperLeftCorner = undefined;

		/**
		 * the bottom left corner of the region
		 * @type {RougeLikePoint}
		 * */
		this.bottomRightCorner = undefined;

		/**
		 * @description an array containing all other points that are not in the main rectangle
		 * @type {RougeLikePoint[]}
		 */
		this.otherPoints = [];

		/** @description a variable that marks if this is the highest layer of the region IE: a region with no parent */
		this.isRoot = false;

		/** @description a variable that marks if this is the lowest layer of the region IE: a region with no children */
		this.isLeaf = true;

		/**
		 * @description any child regions that exist within this region
		 * @type {Region[]}
		 */
		this.children = [];

		/**
		 * @description an array of cords that doors are at
		 * @type {RougeLikePoint[]}
		 */
		this.doors = [];

		/**
		 * @description the parent region to this region
		 * @type {Region}
		 * */
		this.parent = false;
		// assign values to class members
		this.upperLeftCorner = new RougeLikePoint(topX, topY);
		this.bottomRightCorner = new RougeLikePoint(botX, botY);
		this.parent = parent;

		// if this has no parent set as root
		this.checkRoot();
	}

	/**
	 *
	 * @param {Region[]} arr
	 * @param {*} canvasId
	 */
	static DrawArrayOntoCanvas(arr, canvasId = defaultCanvasId) {
		arr.forEach((region) => {
			region.drawAllChildrenOnCanvas();
		});
		arr.forEach((region) => {
			region.drawAllDoors();
		});
	}

	/**
	 * @description return an array of all leafs in this region's children
	 * @returns {Region[]}
	 *  */
	getAllLeafs() {
		if (!this.checkLeaf()) {
			let arr = [];
			this.children.forEach((child) => {
				arr = [...child.getAllLeafs(), ...arr];
			});
			return arr;
		}
		return [this];
	}

	/** @description draw all children onto the canvas */
	drawAllChildrenOnCanvas(canvasId = defaultCanvasId) {
		this.drawOnCanvas(canvasId);
		if (!this.checkLeaf()) {
			this.children.forEach((child) => {
				child.drawAllChildrenOnCanvas();
			});
		}
	}

	drawDoor(canvasId = defaultCanvasId) {
		const canvas = document.getElementById(canvasId);
		const ctx = canvas.getContext('2d');

		this.doors.forEach((door) => {
			ctx.fillRect(door.x, door.y, 2, 2);
		});
	}

	drawAllDoors(canvasId = defaultCanvasId) {
		this.drawDoor(canvasId);
		if (!this.checkLeaf()) {
			this.children.forEach((child) => {
				child.drawAllDoors();
			});
		}
	}

	drawOnCanvas(canvasId = defaultCanvasId) {
		const canvas = document.getElementById(canvasId);
		const ctx = canvas.getContext('2d');

		ctx.rect(this.upperLeftCorner.x, this.upperLeftCorner.y, this.getWidth(), this.getHeight());
		ctx.lineWidth = 2;
		ctx.strokeStyle = `#${(Math.random() * 0xFFFFFF << 0).toString(16)}`;
		ctx.stroke();
	}

	/**
	 * @description split this region into two subregions. This makes this no longer a root and adds 2 regions to its children.
	 * @param {number} numberOfSplits how many sub regions should be created
	 * @param {number} minPercentageLeft [PERCENTAGE OUT OF 100] the minimum percentage of area that the smaller sub region needs to have.
	 * @param {number} forceHorizontal above .5 to force a horizontal split, below to force a vertical
	 */
	split(minPercentageLeft = 20, percentagePadding = 10, forceHorizontal = undefined) {
		const width = this.getWidth();
		const height = this.getHeight();

		if (logging) console.log(`w:${width}`);
		if (logging) console.log(`h:${height}`);

		// the number of tiles that are dedicated to padding on all sides
		const numberOfTilesPaddingX = Math.ceil(width * (percentagePadding / 100));
		const numberOfTilesPaddingY = Math.ceil(height * (percentagePadding / 100));

		// the min number of tiles for x or y
		const minTilesInSubRegionX = Math.ceil(width * (minPercentageLeft / 100));
		const minTilesInSubRegionY = Math.ceil(height * (minPercentageLeft / 100));

		if (logging) console.log(`numberOfTilesPaddingX:${numberOfTilesPaddingX}`);
		if (logging) console.log(`numberOfTilesPaddingY:${numberOfTilesPaddingY}`);

		if (logging) console.log(`minTilesInSubRegionX:${minTilesInSubRegionX}`);
		if (logging) console.log(`minTilesInSubRegionY:${minTilesInSubRegionY}`);

		// randomly check for weather to split horizontally or vertically
		const splitHorizontally = forceHorizontal == undefined ? Math.random() > 0.5 : forceHorizontal;

		const minX = this.upperLeftCorner.x + minTilesInSubRegionX + numberOfTilesPaddingX;
		const maxX = this.bottomRightCorner.x - minTilesInSubRegionX - numberOfTilesPaddingX;
		let minY = this.upperLeftCorner.y + minTilesInSubRegionY + numberOfTilesPaddingY;
		let maxY = this.bottomRightCorner.y - minTilesInSubRegionY - numberOfTilesPaddingY;

		if (splitHorizontally) {
			// clamp to inside parent region bounds
			if (!this.checkRoot()) {
				if (minY < this.parent.upperLeftCorner.y) minY = this.parent.upperLeftCorner.y;
				if (maxY > this.parent.bottomRightCorner.y) maxY = this.parent.bottomRightCorner.y;
			}
			// get the y level to split at
			let splitYLevel = -1;
			if (this.isRoot) {
				splitYLevel = MATTIE.util.randBetween(minY, maxY);
			} else {
				while (splitYLevel < 0 || this.parent.doors.some((door) => door.y == splitYLevel)) {
					splitYLevel = MATTIE.util.randBetween(minY, maxY);
				}
			}

			if (logging) console.log(`ysplit at:${splitYLevel}`);
			if (logging) console.log(`minY:${minY}`);
			if (logging) console.log(`maxY:${maxY}`);

			const topBoxUpperLeftCorner = this.upperLeftCorner;
			const topBoxLowerLeftCorner = new RougeLikePoint(this.bottomRightCorner.x, splitYLevel);
			const topRegion = new Region(topBoxUpperLeftCorner.x, topBoxUpperLeftCorner.y, topBoxLowerLeftCorner.x, topBoxLowerLeftCorner.y, this);

			const bottomBoxUpperLeftCorner = new RougeLikePoint(this.upperLeftCorner.x, splitYLevel);
			const bottomBoxLowerLeftCorner = this.bottomRightCorner;
			const bottomRegion = new Region(bottomBoxUpperLeftCorner.x, bottomBoxUpperLeftCorner.y, bottomBoxLowerLeftCorner.x, bottomBoxLowerLeftCorner.y, this);

			// add to eachother's connection array
			bottomRegion.connectsTo.up.push(topRegion);
			topRegion.connectsTo.down.push(bottomRegion);

			const door = new RougeLikePoint(MATTIE.util.randBetween(minX + 2, maxX - 2), splitYLevel);
			topRegion.doors.push(door);
			bottomRegion.doors.push(door);

			this.children.push(topRegion);
			this.children.push(bottomRegion);
		} else {
			// clamp if parent exists
			if (!this.checkRoot()) {
				if (minX < this.parent.upperLeftCorner.x) minX = this.parent.upperLeftCorner.x;
				if (maxX > this.parent.bottomRightCorner.x) maxX = this.parent.bottomRightCorner.x;
			}

			// get the y level to split at
			let splitXLevel = -1;
			if (this.checkRoot()) {
				splitXLevel = MATTIE.util.randBetween(minX, maxX);
			} else {
				while (splitXLevel < 0 || this.parent.doors.some((door) => door.x == splitXLevel)) {
					splitXLevel = MATTIE.util.randBetween(minX, maxX);
				}
			}

			if (logging) console.log(`xsplit at:${splitXLevel}`);
			if (logging) console.log(`minX:${minX}`);
			if (logging) console.log(`maxX:${maxX}`);

			const leftBoxUpperLeftCorner = this.upperLeftCorner;
			const leftBoxLowerLeftCorner = new RougeLikePoint(splitXLevel, this.bottomRightCorner.y);
			const leftRegion = new Region(leftBoxUpperLeftCorner.x, leftBoxUpperLeftCorner.y, leftBoxLowerLeftCorner.x, leftBoxLowerLeftCorner.y, this);

			const rightBoxUpperLeftCorner = new RougeLikePoint(splitXLevel, this.upperLeftCorner.y);
			const rightBoxLowerLeftCorner = this.bottomRightCorner;
			const rightRegion = new Region(rightBoxUpperLeftCorner.x, rightBoxUpperLeftCorner.y, rightBoxLowerLeftCorner.x, rightBoxLowerLeftCorner.y, this);

			// add to eachother's connection array
			rightRegion.connectsTo.left.push(leftRegion);
			leftRegion.connectsTo.right.push(rightRegion);

			const door = new RougeLikePoint(splitXLevel, MATTIE.util.randBetween(minY + 2, maxY - 2));
			leftRegion.doors.push(door);
			rightRegion.doors.push(door);

			this.children.push(leftRegion);
			this.children.push(rightRegion);
		}

		// update the region as it is no longer a leaf.
		this.checkLeaf();
	}

	/** @description split this region into subregions, then split those into subregions and so on for x depth */
	splitXDeep(x) {
		if (x <= 0) return;
		this.split();
		if (!this.checkLeaf()) {
			this.children.forEach((child) => {
				// decrement by one and recurse
				child.splitXDeep(x - 1);
			});
		}
	}

	/**
	 * @description split this region into subregions, then split those into subregions and so on till regions are all within desired width and height
	 * @param {*} width the target width
	 * @param {*} height the target height
	 * @returns null
	 */
	splitTillWidth(width, height, currentDepth = 0) {
		let force;
		const inWidth = MATTIE.util.checkNumberInRange(this.getWidth(), 0, width);
		const inHeight = MATTIE.util.checkNumberInRange(this.getHeight(), 0, height);
		if (logging)console.log(`width:${this.getWidth()}`);
		if (logging)console.log(`Height:${this.getHeight()}`);
		// if close to width goal force split
		if (!inWidth && inHeight && (MATTIE.util.checkNumberInRange(this.getWidth(), 0, width * 1.2) || currentDepth > 3)) force = 0;
		// if close to height goal force split
		if (inWidth && !inHeight && (MATTIE.util.checkNumberInRange(this.getHeight(), 0, height * 1.2) || currentDepth > 2)) force = 1;
		if (inWidth && inHeight) return;
		if (currentDepth >= maxDepthForWidthSplit) return;

		this.split(10, 20, force);
		if (!this.checkLeaf()) {
			this.children.forEach((child) => {
				// decrement by one and recurse
				child.splitTillWidth(width, height, currentDepth + 1);
			});
		}
	}

	/**
	 * @description check if this is a leaf and update accordingly
	 */
	checkLeaf() {
		this.isLeaf = this.children.length == 0;
		return this.isLeaf;
	}

	/**
	 * @description check if this is a root and update accordingly
	 */
	checkRoot() {
		this.isRoot = this.parent == null;
		return this.isRoot;
	}

	/** @description returns the width of this region */
	getWidth() {
		return Math.abs(this.upperLeftCorner.x - this.bottomRightCorner.x);
	}

	/** @description returns the height of this region */
	getHeight() {
		return Math.abs(this.upperLeftCorner.y - this.bottomRightCorner.y);
	}

	/**
	 * @description check if a point is within the bounds of the region
	 * @param {*} x
	 * @param {*} y
	 */
	isWithinBounds(x, y) {
		const inBounds = false;
		// check if point in set of other points
		if (this.otherPoints.some((point) => point.x == x && point.y == y)) return true;

		// check if x and y are in bounds
		if (this.bottomRightCorner.x < x && this.upperLeftCorner.y < y) return false;
		if (this.bottomRightCorner.y > y && this.upperLeftCorner.x > x) return false;

		// thus in bounds
		return true;
	}
}

/**
 * @description an object representing the map data itself
 * 1: a map consists of multiple things, A a 2d array of tiles ie: the literal map data.
 * 2: the region data, ie: the subdivisions of the bulk space via the tree splitting algorithm
 * 3: RougeLikePoints of interest
 * 4: an array of events on the map
 * */
class RougeLikeMap {
	/**
	 * @description insatiate a new object of the map controller
	 * @param {number} width INTEGER the width of the map to be generated
	 * @param {number} height INTEGER the width of the map to be generated
	 * @param {number} x (OPTIONAL) x coordinate of the upper left corner
	 * @param {number} y (OPTIONAL) y coordinate of the upper left corner
	 */
	constructor(width = 100, height = 100, x = 0, y = 0) {
		/** @description the width of the map to be generated */
		this.width = 25;

		/** @description the width of the map to be generated */
		this.height = 25;

		/** @description x coordinate of the upper left corner */
		this.upperLeftX = 0;

		/** @description y coordinate of the upper left corner */
		this.upperLeftY = 0;

		/** @description the id of the map as it relates to mod saved data, not game data */
		this.mapId = undefined;

		/**
		 * @description a 1d array of tile objects that represents the map assume y increases by 1 every maxX x
		 * @type {Tile[]}
		 * */
		this.mapTiles = undefined;

		/**
		 * @description the root of the region
		 * @type {Region}
		 * */
		this.rootRegion = undefined;

		/**
		 * @description an array of all regions
		 * @type {Region[]}
		 * */
		this.regions = [];

		/**
		 * @description an array of just leafs
		 * @type {Region}
		 */
		this.rooms = [];

		const targetWidth = 13;
		this.width = width;
		this.height = height;
		this.upperLeftX = x;
		this.upperLeftY = y;
		this.rootRegion = new Region(x, y, width, height);
		this.rootRegion.splitTillWidth(targetWidth, targetWidth);
		this.updateRegions();
		this.updateTiles();
	}

	/** traverse from root region updating the list of regions */
	updateRegions(region = this.rootRegion) {
		// if at root wipe regions
		if (region.checkRoot()) {
			this.regions = [];
		}

		this.regions.push(region);
		if (region.checkLeaf()) { this.rooms.push(region); }
		region.children.forEach((child) => {
			if (child.checkLeaf()) { this.rooms.push(child); }
			this.regions.push(child);
			this.updateRegions(child);
		});
	}

	/**
	 *  @description set a tile in the array based on x,y pair
	 * @param {number} x x cord
	 * @param {number} y y cord
	 * @param {Tile} tile tile obj
	 */
	setTile(x, y, tile) {
		try {
			const realIndex = x + y * this.width;
			this.mapTiles[realIndex] = null;
			this.mapTiles[realIndex] = tile;
		} catch (error) {
			console.log(error);
		}
	}

	/** @description set the array to the correct length of empty tiles */
	makeBlankTileSet() {
		this.mapTiles = [];
		for (let y = 0; y < this.height; y++) {
			for (let x = 0; x < this.width; x++) {
				this.mapTiles.push(new Tile(x, y));
			}
		}
	}

	/** @description rebuild mapTiles */
	updateTiles() {
		this.makeBlankTileSet();
		const rooms = [...this.rooms, this.rootRegion];
		rooms.forEach((region) => {
			// draw top line
			for (let x = region.upperLeftCorner.x; x < region.bottomRightCorner.x; x++) {
				const y = region.upperLeftCorner.y;
				this.setTile(x, y, new Tile(x, y, false, wallTile));
			}

			// draw bottom line
			for (let x = region.upperLeftCorner.x; x < region.bottomRightCorner.x; x++) {
				const y = region.bottomRightCorner.y;
				this.setTile(x, y, new Tile(x, y, false, wallTile));
			}

			// draw left wall
			for (let y = region.upperLeftCorner.y; y < region.bottomRightCorner.y; y++) {
				const x = region.upperLeftCorner.x;
				this.setTile(x, y, new Tile(x, y, false, wallTile));
			}

			// draw right wall
			for (let y = region.upperLeftCorner.y; y < region.bottomRightCorner.y; y++) {
				const x = region.bottomRightCorner.x;
				this.setTile(x, y, new Tile(x, y, false, wallTile));
			}
		});

		// draw doors
		this.regions.forEach((region) => {
			if (region.doors && region.doors.length > 0) {
				region.doors.forEach((door) => {
					console.log(door);
					this.setTile(door.x, door.y, new Tile(door.x, door.y, false, floorTile()));
				});
			}
		});
	}

	/**
	 * @description push the map data from this class to the data map
	 */
	pushToDataMap() {
		$dataMap.data = this.mapTiles.map((tile) => tile.tileId);
	}
}

/**
 * @description the main class for generating and controlling maps
 */
class DungeonMap {
	/**
	 * @description the dungoen map class for rouge like
	 * @param {number} topX top left bound
	 * @param {number} topY top bound
	 * @param {number} bottomX bottom right bound
	 * @param {number} bottomY bottom bound
	 */
	constructor(topX, topY, bottomX, bottomY) {
		/**
		 * @description all the rooms of the dungeon
		 * @type {Region[]}
		 */
		this.rooms = [];

		/**
		 * @description halls
		 * @type {Region[]}
		 */
		this.halls = [];

		/** @description the min size of a room (width or height) */
		this.roomSizeMin = 10;

		/** @description the max size of a room (width or height) */
		this.roomSizeMax = 20;

		/** @description the minimum rooms in the dungeon */
		this.minRoomsInDungeon = 5;

		/** @description the maximum rooms in the dungeon */
		this.maxRoomsInDungeon = 15;

		/** @description the min padding between rooms */
		this.minPaddingBetweenRooms = 3;
		/** @description the max padding between rooms */
		this.maxPaddingBetweenRooms = 8;

		/** @description the region denoting the bounds of the map */
		this.mapBounds = new Region(topX, topY, bottomX, bottomY);

		// the min and max percentages of a region to use as a room
		this.minPercentageUsed = 0.4;
		this.maxPercentageUsed = 1;
	}

	/** @description generate random rooms */
	_generateRandomRooms() {
		const numberOfRoomsToSpawn = MATTIE.util.randBetween(this.minRoomsInDungeon, this.maxRoomsInDungeon);
		for (let index = 0; index < numberOfRoomsToSpawn; index++) {
			let room;

			let i = 0;
			do {
				const xMin = this.mapBounds.upperLeftCorner.x;
				const yMin = this.mapBounds.upperLeftCorner.y;
				const xMax = this.mapBounds.bottomRightCorner.x;
				const yMax = this.mapBounds.bottomRightCorner.y;

				const point1 = new RougeLikePoint(
					MATTIE.util.lerp(xMin, xMax, MATTIE.util.randBetween(0, 1)),
					MATTIE.util.lerp(yMin, yMax, MATTIE.util.randBetween(0, 1)),
				);

				const point2 = new RougeLikePoint(
					MATTIE.util.lerp(xMin, xMax, MATTIE.util.randBetween(0, 1)),
					MATTIE.util.lerp(yMin, yMax, MATTIE.util.randBetween(0, 1)),
				);
				room = new Region(point1.x, point1.y, point2.x, point2.y);
				i++;
			} while (!this.rooms.some((other) => other.isWithinBounds(room)) && i < 4);
			this.rooms.push(room);
		}
	}

	/** @description generate random rooms */
	generateRandomRooms() {
		const numberOfRoomsToSpawn = MATTIE.util.randBetween(this.minRoomsInDungeon, this.maxRoomsInDungeon);

		// split our space if its not split up already
		if (this.mapBounds.children.length == 0) this.mapBounds.splitTillWidth(11, 11);

		// extract all leaves and then randomly choose some
		const arrOfRoomBounds = this.mapBounds.getAllLeafs();

		const arrayOfChosenBounds = [];

		// loop for each room to spawn
		for (let index = 0; index < numberOfRoomsToSpawn; index++) {
			// choose a region that has not been used yet
			let chosenRegion = arrOfRoomBounds[MATTIE.util.randBetween(0, arrOfRoomBounds.length)];
			while (arrayOfChosenBounds.indexOf(chosenRegion) != -1) {
				chosenRegion = arrOfRoomBounds[MATTIE.util.randBetween(0, arrOfRoomBounds.length)];
			}
			arrayOfChosenBounds.push(chosenRegion);

			const xMin = chosenRegion.upperLeftCorner.x;
			const yMin = chosenRegion.upperLeftCorner.y;
			const xMax = chosenRegion.bottomRightCorner.x;
			const yMax = chosenRegion.bottomRightCorner.y;

			const alpha = MATTIE.util.randBetween(0, this.minPercentageUsed);
			const topleftPoint = new RougeLikePoint(
				MATTIE.util.lerp(xMin, xMax, alpha),
				MATTIE.util.lerp(yMin, yMax, alpha),
			);

			const alpha2 = MATTIE.util.randBetween(0, this.minPercentageUsed - alpha);
			const bottomRightPoint = new RougeLikePoint(
				MATTIE.util.lerp(xMax, xMin, alpha2),
				MATTIE.util.lerp(yMax, yMin, alpha2),
			);

			const room = new Region(topleftPoint.x, topleftPoint.y, bottomRightPoint.x, bottomRightPoint.y);
			this.rooms.push(room);
		}
	}
}