package raycaster;

import lime.math.RGBA;
import openfl.display.Bitmap;
import openfl.display.BitmapData;
import openfl.geom.Point;

using Lambda;
using Math;
using Std;

class Raycaster extends Bitmap {
	public var camera:Camera;

	public var fog:Bool = true;

	public final sprites:Array<Sprite> = [];

	/**
		Field of view angle in degrees
	**/
	public var fov(default, set):Float;

	var fov_rad:Float; // Fild of view angle in radians

	var screenWidth:Int;
	var screenHeight:Int;

	public var map:MapInterface;

	var hScale:Float = 1;

	var zBuffer:Array<Float>;

	function set_fov(value:Float):Float {
		fov_rad = value * Math.PI / 180;
		camera.planeDistance = (1 / (fov_rad / 2).sin()) * (fov_rad / 2).cos();
		return fov = value;
	}

	public function new(screenWidth:Int, screenHeight:Int, fov:Float, map:MapInterface) {
		super(new BitmapData(screenWidth, screenHeight, false));

		this.screenWidth = screenWidth;
		this.screenHeight = screenHeight;
		this.map = map;

		camera = {
			angle: Math.PI / 3,
			direction: new Point(),
			rays: [for (_ in 0...screenWidth) new Ray(new Point())],
			position: map.cameraPosition,
			radius: 0.3,
			plane: new Point(),
			planeDistance: 1
		};

		zBuffer = [for (_ in 0...screenWidth) 0];

		this.fov = fov;
	}

	/**
		Circle vs map collision
	**/
	function collision(px:Float, py:Float, radius:Float):Null<{
		nx:Float,
		ny:Float,
		dx:Float,
		dy:Float
	}> {
		var mapX:Int = px.int();
		var mapY:Int = py.int();

		var mapWidth = map.getSize().mapWidth;
		var mapHeight = map.getSize().mapHeight;

		var newX:Float = px;
		var newY:Float = py;

		// Left
		if (((mapX >= 1 && map.getTile(mapX - 1, mapY).collision) || (mapX == 0)) && newX < mapX + radius)
			newX = mapX + radius;

		// Top
		if (((mapY >= 1 && map.getTile(mapX, mapY - 1).collision) || (mapY == 0)) && newY < mapY + radius)
			newY = mapY + radius;

		// Right
		if (((mapX < mapWidth - 1 && map.getTile(mapX + 1, mapY).collision) || (mapX == mapWidth - 1)) && newX > mapX + 1 - radius)
			newX = mapX + 1 - radius;

		// Bottom
		if (((mapY < mapHeight - 1 && map.getTile(mapX, mapY + 1).collision) || (mapY == mapHeight - 1)) && newY > mapY + 1 - radius)
			newY = mapY + 1 - radius;

		var corners:Array<{x:Float, y:Float}> = [];
		if ((mapX >= 1 && mapY >= 1 && map.getTile(mapX - 1, mapY - 1).collision) || (mapX == 0 && mapY == 0)) {
			corners.push({x: mapX, y: mapY});
		}
		if ((mapX < mapWidth - 1 && mapY >= 1 && map.getTile(mapX + 1, mapY - 1).collision) || (mapX == mapWidth - 1 && mapY == 0)) {
			corners.push({x: mapX + 1, y: mapY});
		}
		if ((mapX < mapWidth - 1 && mapY < mapHeight - 1 && map.getTile(mapX + 1, mapY + 1).collision)
			|| (mapX == mapWidth - 1 && mapY == mapHeight - 1)) {
			corners.push({x: mapX + 1, y: mapY + 1});
		}
		if ((mapX >= 1 && mapY < mapHeight - 1 && map.getTile(mapX - 1, mapY + 1).collision) || (mapX == 1 && mapY == mapHeight - 1)) {
			corners.push({x: mapX, y: mapY + 1});
		}

		for (corner in corners) {
			var v = new Point(newX - corner.x, newY - corner.y);
			var len = v.length;
			if (len < radius) {
				newX = corner.x + (v.x / len) * radius;
				newY = corner.y + (v.y / len) * radius;
			}
		}

		return {
			nx: newX,
			ny: newY,
			dx: newX - px,
			dy: newY - py
		}
	}

	public function cameraCollision() {
		var collisionShift = collision(camera.position.x, camera.position.y, camera.radius);

		camera.position.x = collisionShift.nx;
		camera.position.y = collisionShift.ny;
	}

	public function addSprite(sprite:Sprite) {
		sprite._raycaster = this;
		sprites.push(sprite);
	}

	public function removeSprite(sprite:Sprite) {
		sprite._raycaster = null;
		sprites.remove(sprite);
	}

	function fogColor(color:Int, distance:Float):Int {
		var rgba:RGBA = color;
		var m = Math.pow(1 / distance, 2);
		rgba.r = (rgba.r * m).int();
		rgba.g = (rgba.g * m).int();
		rgba.b = (rgba.b * m).int();
		rgba.a = (rgba.a * m).int();
		return rgba;
	}

	function renderFloorCeiling() {
		for (line in 0...screenHeight) {
			var rayDirX0 = camera.direction.x - camera.plane.x;
			var rayDirY0 = camera.direction.y - camera.plane.y;
			var rayDirX1 = camera.direction.x + camera.plane.x;
			var rayDirY1 = camera.direction.y + camera.plane.y;

			var p:Int = (line - screenHeight / 2).int();

			var posZ:Float = screenHeight / 2;

			var rowDistance:Float = posZ / p;

			// calculate the real world step vector we have to add for each x (parallel to camera plane)
			// adding step by step avoids multiplications with a weight in the inner loop
			var floorStepX:Float = rowDistance * (rayDirX1 - rayDirX0) / screenWidth;
			var floorStepY:Float = rowDistance * (rayDirY1 - rayDirY0) / screenWidth;

			// real world coordinates of the leftmost column. This will be updated as we step to the right.
			var floorX:Float = camera.position.x + rowDistance * rayDirX0;
			var floorY:Float = camera.position.y + rowDistance * rayDirY0;

			for (bar in 0...screenWidth) {
				// the cell coord is simply got from the integer parts of floorX and floorY
				var cellX:Int = floorX.int();
				var cellY:Int = floorY.int();

				function oneof(t1:BitmapData, ?t2:BitmapData):BitmapData {
					if (t1 != null)
						return t1;
					if (t2 != null)
						return t2;
					return null;
				}

				var tile = map.getTile(cellX, cellY);
				var floorTexture:BitmapData;
				var ceilingTexture:BitmapData;

				if (tile != null) {
					floorTexture = oneof(map.getTile(cellX, cellY).floorTexture, map.defaultFloorTexture);
					ceilingTexture = oneof(map.getTile(cellX, cellY).ceilTexture, map.defaultCeilTexture);
				} else {
					floorTexture = oneof(map.defaultFloorTexture);
					ceilingTexture = oneof(map.defaultCeilTexture);
				}

				// TODO: DRY
				if (floorTexture != null) {
					var tx:Int = (floorTexture.width * (floorX - cellX)).int() & (floorTexture.width - 1);
					var ty:Int = (floorTexture.height * (floorY - cellY)).int() & (floorTexture.height - 1);

					var floorColor:Int = fogColor(floorTexture.getPixel32(tx, ty), rowDistance);

					bitmapData.setPixel32(bar, line, floorColor);
				}

				if (ceilingTexture != null) {
					var tx:Int = (ceilingTexture.width * (floorX - cellX)).int() & (ceilingTexture.width - 1);
					var ty:Int = (ceilingTexture.height * (floorY - cellY)).int() & (ceilingTexture.height - 1);

					var ceilColor:Int = fogColor(ceilingTexture.getPixel32(tx, ty), rowDistance);
					bitmapData.setPixel32(bar, screenHeight - line - 1, ceilColor);
				}

				floorX += floorStepX;
				floorY += floorStepY;
			}
		}
	}

	public function renderSprites() {
		if (sprites.length > 0) {
			for (sprite in sprites) {
				if (sprite.visible)
					sprite.distance = Math.pow(camera.position.x - sprite.x, 2) + Math.pow(camera.position.y - sprite.y, 2);
			}

			sprites.sort((a:Sprite, b:Sprite) -> b.distance > a.distance ? 1 : -1);

			for (sprite in sprites.filter(s -> s.visible)) {
				var spriteX:Float = sprite.x - camera.position.x;
				var spriteY:Float = sprite.y - camera.position.y;

				var invDet = 1.0 / (camera.plane.x * camera.direction.y - camera.direction.x * camera.plane.y);

				var transformX = invDet * (camera.direction.y * spriteX - camera.direction.x * spriteY);
				var transformY = invDet * (-camera.plane.y * spriteX + camera.plane.x * spriteY);

				var spriteScreenX:Int = sprite.screenX = ((screenWidth / 2) * (1 + transformX / transformY)).floor();

				if (sprite.bitmapData != null) {
					var spriteHeight:Int = Math.abs(screenHeight / transformY).floor(); // using 'transformY' instead of the real distance prevents fisheye

					var drawStartY:Int = (-spriteHeight / 2 + screenHeight / 2).floor();

					if (drawStartY < 0)
						drawStartY = 0;

					var drawEndY:Int = (spriteHeight / 2 + screenHeight / 2).floor();

					if (drawEndY >= screenHeight)
						drawEndY = screenHeight;

					var spriteWidth:Int = (screenHeight / transformY).abs().floor();

					var drawStartX:Int = (-spriteWidth / 2 + spriteScreenX).floor();

					if (drawStartX < 0)
						drawStartX = 0;

					var drawEndX:Int = (spriteScreenX + spriteWidth / 2).floor();

					if (drawEndX >= screenWidth)
						drawEndX = screenWidth;

					for (col in drawStartX...drawEndX) {
						var texX:Int = ((256 * (col - (-spriteWidth / 2 + spriteScreenX)) * sprite.bitmapData.width / spriteWidth).int() / 256).floor();

						if (transformY > 0 && col >= 0 && col < screenWidth && transformY < zBuffer[col]) {
							for (line in drawStartY...drawEndY) {
								var d:Int = line * 256 - screenWidth * 128 + spriteHeight * 128;
								var texY:Int = (((d * sprite.bitmapData.height) / spriteHeight) / 256).floor();
								// var color = fogColor(sprite.bitmapData.getPixel32(texX, texY), sprite.distance);
								var color = sprite.bitmapData.getPixel32(texX, texY);

								if (color != 0x00000000)
									bitmapData.setPixel32(col, line, color);
							}
						}
					}
				}
			}
		}
	}

	public function render() {
		bitmapData.lock();
		bitmapData.fillRect(bitmapData.rect, 0x0);

		var perpAngle:Float = camera.angle + Math.PI / 2;
		camera.direction.setTo(camera.angle.cos() * camera.planeDistance, camera.angle.sin() * camera.planeDistance);
		camera.plane.setTo(perpAngle.cos() * 0.6, perpAngle.sin() * 0.6); // Magic numbers???

		renderFloorCeiling();

		for (ray_n in 0...screenWidth) {
			var ray = camera.rays[ray_n];
			var cameraX:Float = (2 * ray_n / screenWidth - 1);
			ray.direction.setTo(camera.direction.x + camera.plane.x * cameraX, camera.direction.y + camera.plane.y * cameraX);

			var mapX:Int = camera.position.x.int();
			var mapY:Int = camera.position.y.int();

			ray.clip.setTo(camera.position.x, camera.position.y);

			var deltaDistX:Float = ray.direction.x == 0 ? 0 : (1 / ray.direction.x).abs();
			var deltaDistY:Float = ray.direction.y == 0 ? 0 : (1 / ray.direction.y).abs();

			var texture:BitmapData = null;
			var textureU:Float = 0;
			var side:Int = 0;
			var stepX:Int = 0;
			var stepY:Int = 0;
			while (true) {
				var sideDx:Float;
				var sideDy:Float;

				if (ray.direction.x < 0) {
					sideDx = (ray.clip.x - mapX) * deltaDistX;
					stepX = -1;
				} else {
					sideDx = (mapX + 1 - ray.clip.x) * deltaDistX;
					stepX = 1;
				}

				if (ray.direction.y < 0) {
					sideDy = (ray.clip.y - mapY) * deltaDistY;
					stepY = -1;
				} else {
					sideDy = (mapY + 1 - ray.clip.y) * deltaDistY;
					stepY = 1;
				}

				ray.clip.x += ray.direction.x * Math.min(sideDx, sideDy);
				ray.clip.y += ray.direction.y * Math.min(sideDx, sideDy);

				if (sideDx < sideDy) {
					mapX += stepX;
					textureU = ray.clip.y - mapY;
					side = 0;
				} else {
					mapY += stepY;
					textureU = ray.clip.x - mapX;
					side = 1;
				}

				if (mapX >= 0 && mapX < map.getSize().mapWidth && mapY >= 0 && mapY < map.getSize().mapHeight) {
					var tileDesc = map.getTile(mapX, mapY);
					if (tileDesc.wallTexture != null) {
						texture = tileDesc.wallTexture;
						break;
					}
				} else {
					ray.clip.setTo(Math.POSITIVE_INFINITY, Math.POSITIVE_INFINITY);
					break;
				}
			}

			if (Math.isFinite(ray.clip.x) && Math.isFinite(ray.clip.x)) {
				var distance:Float = 0;

				distance = zBuffer[ray_n] = side == 0 ? (mapX - camera.position.x + (1 - stepX) / 2) / ray.direction.x : (mapY - camera.position.y
					+ (1 - stepY) / 2) / ray.direction.y;

				var columnLength = (screenHeight / distance) * hScale;

				var columnStart = ((screenHeight - columnLength) / 2).int();
				var columnEnd = ((screenHeight + columnLength) / 2).int();

				for (line in Math.max(0, columnStart).int()...Math.min(screenHeight, columnEnd).int()) {
					var textureV:Float = (line - columnStart) / (columnEnd - columnStart);
					var color:Int;
					if (texture != null) {
						color = texture.getPixel32((texture.width * textureU).int(), (texture.height * textureV).int());
					} else {
						color = 0xffffffff;
					}

					if (fog && distance > 1) {
						color = fogColor(color, distance);
					}

					bitmapData.setPixel(ray_n, line, color);
				}
			}
		}

		renderSprites();

		bitmapData.unlock();
	}
}
