const [EMPTY, FOOD, SUPER_FOOD, DECAY_FOOD, WALL, FIRE, FLAMMABLE, FLAMMABLE_S, HOLE, HOLE_S, SNAKE]=Array(255).keys();

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

		// 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]=(() => {
						switch(settings.world[y][x]) {
							case ' ': return EMPTY;
							case 'f': return FOOD;
							case 'F': return SUPER_FOOD;
							case 'd': return DECAY_FOOD;
							case 'w': return WALL;
							case 'o': return HOLE;
							case 'i': return FIRE;
							case 'I': return FLAMMABLE;
						}
					})();
				}
			}

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

			// extract the fruits
			this.fruits=[];
			this.world
				.forEach((l, x) => l.forEach(
					(c, y) => {
						if(c==FOOD) this.fruits.push([x, y]);
					}
				));

			// extract the decaying fruits
			this.decayFood=[];
			this.world
				.forEach((l, x) => l.forEach(
					(c, y) => {
						if(c==DECAY_FOOD) this.decaying.push([x, y, 0]);
					}
				));
		} 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(EMPTY);
			}

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

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

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

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

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

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

		// add the snake to the world
		settings.snake.forEach(([x, y]) => this.world[x][y]=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
		}, rules, settings.rules || {});
	}

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

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

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

	draw() {
		const assets=require('assets');
		const config=require('config');

		// clear the canvas, because it's easier than having to deal with everything
		this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

		// get the cell size and offset
		const cellSize=Math.min(
			this.canvas.width/this.dimensions[0],
			this.canvas.height/this.dimensions[1]
		);
		const offsetX=(this.canvas.width-cellSize*this.dimensions[0])/2;
		const offsetY=(this.canvas.height-cellSize*this.dimensions[1])/2;

		// draw a grid/checkerboard if requested
		if(config.getS('appearance.grid')=='grid') {
			this.ctx.strokeStyle='rgba(0, 0, 0, 50%)';
			this.ctx.lineCap='square';
			this.ctx.lineWidth=1;
			this.ctx.beginPath();
			for(let x=1; x<this.dimensions[0]; x++) {
				this.ctx.moveTo(offsetX+x*cellSize, offsetY);
				this.ctx.lineTo(offsetX+x*cellSize, this.canvas.height-offsetY);
			}
			for(let y=1; y<this.dimensions[1]; y++) {
				this.ctx.moveTo(offsetX, offsetY+y*cellSize);
				this.ctx.lineTo(this.canvas.width-offsetX, offsetY+y*cellSize);
			}
			this.ctx.stroke();
		} else if(config.getS('appearance.grid')=='checkerboard') {
			this.ctx.fillStyle='rgba(0, 0, 0, 10%)';
			for(let x=0; x<this.dimensions[0]; x++) {
				for(let y=(x+1)%2; y<this.dimensions[1]; y+=2) {
					this.ctx.fillRect(offsetX+x*cellSize, offsetY+y*cellSize, cellSize, cellSize);
				}
			}
		}

		// draw our tiles
		const wall=assets.get('wall');
		const hole=assets.get('hole');
		const fire=assets.get('fire');
		const flammable=assets.get('flammable');
		const superFruit=assets.get('superFruit');
		const decayFruit=assets.get('decayFruit');
		const putTile=(x, y, tile) => this.ctx.drawImage(
			tile,
			offsetX+cellSize*x,
			offsetY+cellSize*y,
			cellSize,
			cellSize
		);
		const putTileAnim=(x, y, tile) => putTile(x, y, tile[
			Math.floor(Date.now()/1000*60+x+y)%tile.length
		]);
		const putTileAnimPercent=(x, y, tile, percent) => putTile(x, y, tile[
			Math.min(Math.round(percent*tile.length), tile.length-1)
		]);
		const checkAdj=(x, y) => {
			let adj={};
			adj.u=this.world[x][y-1];
			adj.d=this.world[x][y+1];
			adj.l=(this.world[x-1] || [])[y];
			adj.r=(this.world[x+1] || [])[y];
			adj.ul=(this.world[x-1] || [])[y-1];
			adj.ur=(this.world[x+1] || [])[y-1];
			adj.dl=(this.world[x-1] || [])[y+1];
			adj.dr=(this.world[x+1] || [])[y+1];
			return adj;
		};
		for(let x=0; x<this.dimensions[0]; x++) {
			for(let y=0; y<this.dimensions[1]; y++) {
				switch(this.world[x][y]) {
					case WALL:
						putTile(x, y, wall);
						break;

					case FIRE:
						putTileAnim(x, y, fire);
						break;

					case HOLE:
					case HOLE_S: {
						putTile(x, y, hole.base);
						let adj=checkAdj(x, y);
						Object
							.keys(adj)
							.filter(k => adj[k]==HOLE || adj[k]==HOLE_S)
							.forEach(k => putTile(x, y, hole[k]));
						break;
						// technically, this works for all shapes
						// however, the tileset only handles convex shapes
					}

					case FLAMMABLE:
					case FLAMMABLE_S:
						putTile(x, y, flammable);
						break;

					case SUPER_FOOD:
						putTileAnim(x, y, superFruit);
						break;
				}
			}
		}

		// draw our decaying fruits (they have more information than just XY, so they need to be drawn here
		this.decayFood.forEach(([x, y, birth]) =>
			putTileAnimPercent(x, y, decayFruit, (this.playTime-birth)/2000)
		);

		// draw our snake (it gets drawn completely differently, so here it goes)
		const snake=assets.get('snake');
		this.ctx.fillStyle=snake.color;
		this.ctx.strokeStyle=snake.color;
		this.ctx.lineCap=snake.cap;
		this.ctx.lineJoin=snake.join;
		this.ctx.lineWidth=cellSize*snake.tailSize;

		this.ctx.beginPath();
		this.ctx.ellipse(
			offsetX+cellSize*(this.snake[0][0]+1/2),
			offsetY+cellSize*(this.snake[0][1]+1/2),
			cellSize/2*snake.headSize,
			cellSize/2*snake.headSize,
			0,
			0,
			Math.PI*2
		);
		this.ctx.fill();

		this.ctx.beginPath();
		this.snake.forEach(([x, y], i, a) => {
			this.ctx.lineTo(
				offsetX+cellSize*(x+1/2),
				offsetY+cellSize*(y+1/2)
			);
			if(i!=0 && Math.hypot(x-a[i-1][0], y-a[i-1][1])>1) {
				this.ctx.lineWidth=cellSize*snake.tailWrapSize;
			} else {
				this.ctx.lineWidth=cellSize*snake.tailSize;
			}
			this.ctx.stroke();
			this.ctx.beginPath()
			this.ctx.moveTo(
				offsetX+cellSize*(x+1/2),
				offsetY+cellSize*(y+1/2)
			);
		});
		this.ctx.stroke();

		// our fruit has a nice animation to it between .8 and 1.2 scale
		const ms=Date.now();
		const fruitScale=Math.sin(ms/400*Math.PI)*.2+1
		const fruit=assets.get('fruit');
		this.fruits.forEach(([x, y]) => {
			this.ctx.drawImage(
				fruit,
				offsetX+cellSize*x+(1-fruitScale)*cellSize/2,
				offsetY+cellSize*y+(1-fruitScale)*cellSize/2,
				cellSize*fruitScale,
				cellSize*fruitScale
			);
		});

		// show the timer
		if(this.rules.winCondition=='time') {
			if(config.getS('appearance.timer')=='border' || config.getS('appearance.timer')=='both') {
				let remaining=(this.rules.gameDuration-this.playTime)/this.rules.gameDuration;
				const w=this.dimensions[0]*cellSize;
				const h=this.dimensions[1]*cellSize;
				const p=w*2+h*2;

				const wp=w/p;
				const hp=h/p;

				const pdst=(st, ed, frac) =>
					(ed-st)*frac+st;

				this.ctx.strokeStyle='#930a16';
				this.ctx.lineJoin='miter';
				this.ctx.lineCap='round';
				this.ctx.lineWidth=5;
				this.ctx.beginPath();
				this.ctx.moveTo(this.canvas.width/2, offsetY+2);

				let sp=Math.min(wp/2, remaining);
				remaining-=sp;
				this.ctx.lineTo(pdst(this.canvas.width/2, w+offsetX-2, sp/wp*2), offsetY+2);
				if(remaining) {
					sp=Math.min(hp, remaining);
					remaining-=sp;
					this.ctx.lineTo(w+offsetX-2, pdst(offsetY+2, offsetY+h-2, sp/hp));
				}
				if(remaining) {
					sp=Math.min(wp, remaining);
					remaining-=sp;
					this.ctx.lineTo(pdst(w+offsetX-2, offsetX+2, sp/wp), offsetY+h-2);
				}
				if(remaining) {
					sp=Math.min(hp, remaining);
					remaining-=sp;
					this.ctx.lineTo(offsetX+2, pdst(offsetY+h-2, offsetY+2, sp/hp));
				}
				if(remaining) {
					this.ctx.lineTo(pdst(offsetX+2, this.canvas.width/2, remaining/wp*2), offsetY+2);
				}
				this.ctx.stroke();
			}
			if(config.getS('appearance.timer')=='number' || config.getS('appearance.timer')=='both') {
				let remaining=''+Math.ceil((this.rules.gameDuration-this.playTime)/1000);
				while(remaining.length<(''+this.rules.gameDuration/1000).length) remaining='0'+remaining;

				this.ctx.fillStyle='#930a16';
				this.ctx.textAlign='center';
				this.ctx.textBaseline='middle';
				this.ctx.font='4rem "Fira Code"';
				this.ctx.fillText(remaining, this.canvas.width/2, this.canvas.height/2);
			}
		}

		// draw the border around our game area
		this.ctx.fillStyle='black';
		this.ctx.fillRect(0, 0, this.canvas.width, offsetY);
		this.ctx.fillRect(0, 0, offsetX, this.canvas.height);
		this.ctx.fillRect(offsetX+cellSize*this.dimensions[0], 0, offsetX, this.canvas.height);
		this.ctx.fillRect(0, offsetY+cellSize*this.dimensions[1], this.canvas.width, offsetY);
	}

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

		// compute our new head
		const head=[
			this.snake[0][0]+this.direction[0],
			this.snake[0][1]+this.direction[1]
		];

		// get our tail out of the way
		const tail=this.snake.pop();
		switch(this.world[tail[0]][tail[1]]) {
			case HOLE_S:
				this.world[tail[0]][tail[1]]=HOLE;
				break;
			case FLAMMABLE_S:
				this.world[tail[0]][tail[1]]=FLAMMABLE;
				break;
			default:
				this.world[tail[0]][tail[1]]=EMPTY;
		}

		// 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();
			}
		}

		switch(this.world[head[0]][head[1]]) {
			// you hit, you die
			case WALL:
			case FIRE:
			case SNAKE:
			case HOLE_S:
				return this.die();

			// 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.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S ||
					this.snake.length>=2 &&
						this.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S &&
						this.world[this.snake[1][0]][this.snake[1][1]]==HOLE_S
				) return this.die();
				break;

			// you eat, you get a massive score boost
			case SUPER_FOOD:
				this.score+=10;
				break;

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

			// you eat, you grow
			case FOOD:
				// re-grow the snake partially (can't hit the tail, but it's there for all other intents and purposes
				this.snake.push(tail);
				this.length++;

				// remove the fruit from existence
				this.world[head[0]][head[1]]=SNAKE;
				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(EMPTY);

					const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
					this.fruits.push(cell);
					this.world[cell[0]][cell[1]]=FOOD;
				}

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

				if(this.rules.decayingFruitGrow) {
					if(Math.random()<.2) { // 20% chance
						const emptyCells=this.getTilesOfType(EMPTY);
						const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
						this.world[cell[0]][cell[1]]=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
		switch(this.world[head[0]][head[1]]) {
			case HOLE:
				this.world[head[0]][head[1]]=HOLE_S;
				break;
			case FLAMMABLE:
				this.world[head[0]][head[1]]=FLAMMABLE_S;
				break;
			default:
				this.world[head[0]][head[1]]=SNAKE;
		}
		this.snake.unshift(head);

		// decay decaying food
		this.decayFood.forEach(
			([x, y, birth]) => {
				if(this.playTime>=birth+2000) this.world[x][y]=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.world[x][y-1],
					this.world[x][y+1],
					(this.world[x-1]||[])[y],
					(this.world[x+1]||[])[y]
				];
				return surrounding.some(tile => tile==FIRE);
			};
			if(this.getTilesOfType(FLAMMABLE_S).some(touchingFire)) return this.die();
			this.getTilesOfType(FLAMMABLE).filter(touchingFire).forEach(([x, y]) => this.world[x][y]=FIRE);
		}

		// 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();
		}
	}

	tick() {
		if(!this.playing) return;
		if(!this.lastStep) this.lastStep=this.firstStep;
		this.draw();
		if(this.callback) this.callback('tick');
		if(this.lastStep+this.delay<Date.now()) {
			this.lastStep+=this.delay;
			this.step();
		}
		requestAnimationFrame(() => this.tick());
	}

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

	die() {
		this.playing=false;
		this.endPlayTime=this.playTime;
		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;
		this.score=0;
		requestAnimationFrame(() => this.tick());
	}
}

return module.exports=SnekGame;
