const {
	tiles: T,
	forChar,
	getType,
	isPortal,
	snakeVersion, nonSnakeVersion
}=require('tiles');

class SnekGame {
	constructor(settings, canvas, rules) {
		// setup the delay
		this.delay=settings.delay || Infinity;

		// score starts at 0
		this.score=0;

		// world is given in the level
		if(settings.world) { // explicitly

			// convert the world
			this.world=Array(settings.world[0].length);
			for(let x=0; x<this.world.length; x++) {
				this.world[x]=Array(settings.world.length);
				for(let y=0; y<this.world[x].length; y++) {
					this.world[x][y]=forChar(settings.world[y][x]);
				}
			}

			// extract the dimensions
			this.dimensions=[this.world.length, this.world[0].length];

			// extract the fruits
			this.fruits=this.getTilesOfType(T.FOOD);

			// extract the decaying fruits
			this.decayFood=this.getTilesOfType(T.DECAY_FOOD);

			// extract the portals
			this.portals={};
			this.world.forEach((l, x) =>
				l.forEach((c, y) => {
					if(c==T.PORTAL_A) this.portals.a=[x, y];
					if(c==T.PORTAL_B) this.portals.b=[x, y];
					if(c==T.PORTAL_C) this.portals.c=[x, y];
					if(c==T.PORTAL_D) this.portals.d=[x, y];
				})
			);
		} else { // dimension and objects

			// get the dimensions
			this.dimensions=[...settings.dimensions];

			// build an empty world
			this.world=Array(settings.dimensions[0]);
			for(let i=0; i<settings.dimensions[0]; i++) {
				this.world[i]=Array(settings.dimensions[1]);
				this.world[i].fill(T.EMPTY);
			}

			// add the walls
			if(settings.walls) settings.walls.forEach(([x, y]) => this.world[x][y]=T.WALL);

			// add the holes
			if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=T.HOLE);

			// add the fires and flammable tiles
			if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=T.FIRE);
			if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=T.FLAMMABLE);

			// add the food
			settings.food.forEach(([x, y]) => this.world[x][y]=T.FOOD);
			this.fruits=[...settings.food];

			// add the super food
			if(settings.superFood) settings.superFood.forEach(([x, y]) => this.world[x][y]=T.SUPER_FOOD);

			// add the decaying food
			if(settings.decayFood) {
				settings.decayFood.forEach(([x, y]) => this.world[x][y]=T.DECAY_FOOD);
				this.decayFood=settings.decayFood.map(([x, y]) => [x, y, 0]);
			} else {
				this.decayFood=[];
			}

			// add the portals
			if(settings.portals) {
				if(settings.portals.a) this.world[settings.portals.a[0]][settings.portals.a[1]]=T.PORTAL_A;
				if(settings.portals.b) this.world[settings.portals.b[0]][settings.portals.b[1]]=T.PORTAL_B;
				if(settings.portals.c) this.world[settings.portals.c[0]][settings.portals.c[1]]=T.PORTAL_C;
				if(settings.portals.d) this.world[settings.portals.d[0]][settings.portals.d[1]]=T.PORTAL_D;
				this.portals={...settings.portals};
			} else {
				this.portals={};
			}

			// add the keys
			if(settings.keys) settings.keys.forEach(([x, y]) => this.world[x][y]=T.KEY);

			// add the doors
			if(settings.doors) settings.doors.forEach(([x, y]) => this.world[x][y]=T.DOOR);

			// add the switches
			if(settings.switches) settings.switches.forEach(([x, y]) => this.world[x][y]=T.SWITCH_OFF);

			// add the spikes
			if(settings.spikesOn) settings.spikesOn.forEach(([x, y]) => this.world[x][y]=T.SPIKES_ON);
			if(settings.spikesOff) settings.spikesOff.forEach(([x, y]) => this.world[x][y]=T.SPIKES_OFF);
		}

		// add the snake to the world
		settings.snake.forEach(([x, y]) => this.world[x][y]=T.SNAKE);


		// get the head and initial direction
		this.head=[...settings.snake[0]];
		if(settings.snake.length>=2) this.direction=[
			settings.snake[0][0]-settings.snake[1][0],
			settings.snake[0][1]-settings.snake[1][1]
		];
		else this.direction=[
			1,
			0
		];
		this.lastDirection=this.direction

		// store the snake
		this.snake=[...settings.snake];
		this.length=this.snake.length;

		// get our canvas, like, if we want to actually draw
		this.canvas=canvas;
		this.ctx=canvas.getContext('2d');

		// load the custom rules
		this.rules=Object.assign({
			fruitRegrow: true,
			superFruitGrow: false,
			decayingFruitGrow: false,
			speedIncrease: true,
			worldWrap: true,
			winCondition: 'none',
			scoreSystem: 'fruit',
			fireTickSpeed: 10,
			autoSizeGrow: false,
			autoSpeedIncrease: false,
			timeFlow: true
		}, rules, settings.rules || {});

		// reset direction if time doesn't flow
		if(!this.rules.timeFlow) {
			this.lastDirection=[0, 0];
			this.direction=[0, 0];
		}

		// set score if move-based
		if(this.rules.scoreSystem=='moves') {
			this.score=this.rules.moveCount;
		}
	}

	get playTime() {
		return Date.now()-this.firstStep;
	}

	get speed() {
		return Math.round(1000/this.delay);
	}

	getTile(x, y) {
		return (this.world[x]||[])[y];
	}
	getTileA([x, y]) {
		return (this.world[x]||[])[y];
	}
	putTile(x, y, t) {
		this.world[x][y]=t;
	}
	putTileA([x, y], t) {
		this.world[x][y]=t;
	}

	getTilesOfType(type) {
		return this
			.world
			.map(
				(l, x) => l
					.map(
						(r, y) => r==type?[x,y]:null
					).filter(
						a => a
					)
			).flat();
	}
	replaceTilesOfType(type, newType) {
		this.world.forEach(l =>
			l.forEach((t, i) => {
				if(t==type) l[i]=newType;
			})
		);
	}

	draw() {
		require('renderer').draw(this, this.canvas, this.ctx);
	}

	step() {
		this.tickId++;
		this.lastDirection=this.direction;

		// compute our new head
		let head;
		if(!this.portaled && isPortal(this.getTileA(this.snake[0]))) {
			const tile=this.getTileA(this.snake[0]);
			if(tile==T.PORTAL_A_S) head=this.portals.b;
			if(tile==T.PORTAL_B_S) head=this.portals.a;
			if(tile==T.PORTAL_C_S) head=this.portals.d;
			if(tile==T.PORTAL_D_S) head=this.portals.c;
			this.portaled=true;
		} else {
			head=[
				this.snake[0][0]+this.direction[0],
				this.snake[0][1]+this.direction[1]
			];
			this.portaled=false;
		}

		// get our tail out of the way
		const tail=this.snake.pop();
		this.putTileA(tail, nonSnakeVersion(this.getTileA(tail)));

		// check for out of world conditions
		if(head[0]<0 || head[0]>=this.dimensions[0] || head[1]<0 || head[1]>=this.dimensions[1]) {
			if(this.rules.worldWrap) {
				head[0]=(head[0]+this.dimensions[0])%this.dimensions[0];
				head[1]=(head[1]+this.dimensions[1])%this.dimensions[1];
			} else {
				return this.die("literally fell out of the world", "exited the grid");
			}
		}

		let tile=this.getTileA(head);
		switch(getType(tile)) {
			// you hit, you die
			case 'wall':
				switch(tile) {
					case T.WALL: return this.die("thought walls were edible", "hit a wall");
					case T.FIRE: return this.die("burned to a crisp", "hit fire");
					case T.DOOR: return this.die("forgot to OPEN the door", "hit a door");
					case T.SPIKES_ON: return this.die("thought they were a girl's drink in a nightclub", "hit spikes");
				}

			// congratilations, you played yourself!
			case 'snake':
				return this.die("achieved every dog's dream", "ate their own tail");

			// if either 3 consecutive segments or the whole snake is on a hole, you die
			case 'hole':
				if(
					this.snake.length==0 ||
					this.snake.length==1 &&
						this.getTileA(this.snake[0])==T.HOLE_S ||
					this.snake.length>=2 &&
						this.getTileA(this.snake[0])==T.HOLE_S &&
						this.getTileA(this.snake[1])==T.HOLE_S
				) return this.die("fell harder than their grades", "fell in a hole");
				break;

			// you eat, you get a massive score boost
			case 'bonus':
				this.putTileA(head, T.EMPTY);
				switch(tile) {
					case T.SUPER_FOOD:
						this.score+=10;

					// you eat, you get a small score boost
					case T.DECAY_FOOD:
						this.score+=5;
						this.decayFood=this.decayFood.filter(
							([x, y, _]) => !(x==head[0] && y==head[1])
						);
				} break;

			// you eat, you destroy all doors
			case 'key':
				this.putTileA(head, T.EMPTY);
				this.replaceTilesOfType(T.DOOR, T.EMPTY);
				break;

			// you step on, you trigger
			case 'switch': {
				this.putTileA(head, tile==T.SWITCH_ON?T.SWITCH_OFF:T.SWITCH_ON);
				if(this.getTilesOfType(T.SPIKES_OFF_S).length) return this.die("spiked themselves", "activated spikes");
				const oldSpikes=this.getTilesOfType(T.SPIKES_ON);
				this.replaceTilesOfType(T.SPIKES_OFF, T.SPIKES_ON);
				oldSpikes.forEach(pos => this.putTileA(pos, T.SPIKES_OFF));
			} break;

			// you eat, you grow
			case 'food':
				// re-grow the snake
				this.snake.push(tail);
				this.putTileA(tail, snakeVersion(this.getTileA(tail)));
				this.length++;

				// remove the fruit from existence
				this.putTileA(head, T.EMPTY);
				this.fruits=this.fruits.filter(
					([x, y]) => !(x==head[0] && y==head[1])
				);

				// increase score
				this.score++;

				// custom rules
				if(this.rules.fruitRegrow) {
					const emptyCells=this.getTilesOfType(T.EMPTY);

					const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
					this.fruits.push(cell);
					this.putTileA(cell, T.FOOD);
				}

				if(this.rules.superFruitGrow) {
					if(Math.random()<.1) { // 10% chance
						const emptyCells=this.getTilesOfType(T.EMPTY);
						const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
						this.putTileA(cell, T.SUPER_FOOD);
					}
				}

				if(this.rules.decayingFruitGrow) {
					if(Math.random()<.2) { // 20% chance
						const emptyCells=this.getTilesOfType(T.EMPTY);
						const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
						this.putTileA(cell, T.DECAY_FOOD);
						this.decayFood.push([cell[0], cell[1], this.playTime]);
					}
				}

				if(this.rules.speedIncrease) {
					this.delay*=this.rules.speedMultiplier;
					if(this.delay<this.rules.speedCap) this.delay=this.rules.speedCap;
				}
		}

		// move our head forward
		tile=this.getTileA(head);
		this.putTileA(head, snakeVersion(tile));
		this.snake.unshift(head);

		// decay decaying food
		this.decayFood.forEach(
			([x, y, birth]) => {
				if(this.playTime>=birth+2000) this.putTile(x, y, T.EMPTY);
			}
		);
		this.decayFood=this.decayFood.filter(
			([_, __, birth]) => this.playTime<birth+2000
		);

		// automatic speed increase
		if(this.rules.autoSpeedIncrease) {
			if(this.delay>50 && this.tickId%this.rules.autoSpeadIncreaseTicks==0) this.delay--;
		}

		// automatic size grow
		if(this.rules.autoSizeGrow) {
			if(this.tickId%this.rules.autoSizeGrowTicks==0) this.snake.push(tail);
		}

		// fire tick
		if(this.tickId%this.rules.fireTickSpeed==0) {
			const touchingFire=([x, y]) => {
				const surrounding=[
					this.getTile(x, y-1),
					this.getTile(x, y+1),
					this.getTile(x-1, y),
					this.getTile(x-1, y)
				];
				return surrounding.some(tile => tile==T.FIRE);
			};
			if(this.getTilesOfType(T.FLAMMABLE_S).some(touchingFire)) return this.die("didn't know oil was flammable", "stood on oil when it caught on fire");
			this.getTilesOfType(T.FLAMMABLE).filter(touchingFire).forEach(pos => this.putTileA(pos, T.FIRE));
		}

		// THE WORLD!
		if(!this.rules.timeFlow) {
			this.lastDirection=[0, 0];
			this.direction=[0, 0];
		}

		// victory condition
		if(this.rules.winCondition=='fruit') {
			if(!this.fruits.length) return this.win();
		}
		if(this.rules.winCondition=='time') {
			if(this.playTime>=this.rules.gameDuration) return this.win();
		}
		if(this.rules.winCondition=='score') {
			if(this.score>=this.rules.scoreObjective) return this.win();
		}
		if(this.rules.scoreSystem=='moves') {
			if(this.score) this.score--;
		}
	}

	tick() {
		if(!this.playing) return;
		if(!this.lastStep) this.lastStep=this.firstStep;
		this.draw();
		if(this.callback) this.callback('tick');
		if(this.rules.timeFlow && this.lastStep+this.delay<Date.now()) {
			this.lastStep+=this.delay;
			this.step();
		}
		if(!this.rules.timeFlow && (this.direction[0]!=0 || this.direction[1]!=0)) {
			this.step();
		}
		requestAnimationFrame(() => this.tick());
	}

	win() {
		this.playing=false;
		this.endPlayTime=this.playTime;
		if(this.callback) this.callback('win');
	}

	die(message='died', reason='died') {
		this.playing=false;
		this.endPlayTime=this.playTime;
		this.death={message, reason};
		if(this.callback) this.callback('die');
	}

	handleInputs(inputs) {
		const config=require('config');

		// change direction if the input is valid
		const trySet=(dir) => {
			if(!dir.every((e, i) => e==this.lastDirection[i] || e==-this.lastDirection[i])) {
				this.direction=dir;
				return true;
			}
		}

		// reduce buffer duration
		Object
			.keys(inputs)
			.forEach(k => {
				let v=inputs[k];
				if(v===true) v=5;
				v--;
				if(!v) delete inputs[k];
				else inputs[k]=v;
			});

		// try all inputs in order and unbuffer them if valid
		if(inputs.left && trySet([-1, 0])) delete inputs.left;
		else if(inputs.right && trySet([ 1, 0])) delete inputs.right;
		else if(inputs.up && trySet([ 0,-1])) delete inputs.up;
		else if(inputs.down &&  trySet([ 0, 1])) delete inputs.down;

		// buffering might be disabled
		if(!config.getB('input.buffer')) {
			Object
				.keys(inputs)
				.forEach(k => delete inputs[k]);
		}
	}

	start() {
		this.firstStep=Date.now();
		this.tickId=0;
		this.playing=true;
		requestAnimationFrame(() => this.tick());
	}
}

return module.exports=SnekGame;
