package org.jrenner.fps.entity;

import com.badlogic.gdx.graphics.g3d.ModelInstance;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Matrix4;
import com.badlogic.gdx.math.Quaternion;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.physics.bullet.collision.btCollisionObject;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.GdxRuntimeException;
import com.badlogic.gdx.utils.IntMap;
import org.jrenner.fps.Direction;
import org.jrenner.fps.Log;
import org.jrenner.fps.Main;
import org.jrenner.fps.Physics;
import org.jrenner.fps.Player;
import org.jrenner.fps.Tools;
import org.jrenner.fps.effects.BlueExplosion;
import org.jrenner.fps.graphics.EntityBillboard;
import org.jrenner.fps.graphics.EntityModel;
import org.jrenner.fps.move.FlyingMovement;
import org.jrenner.fps.move.Movement;

public abstract class Entity {
	public int id = -1;
	public static Array<Entity> list;
	public static Array<Integer> destroyQueue;
	public static IntMap<Entity> idMap;
	public static Array<Integer> usedIDs;
	protected static Quaternion q = new Quaternion();
	protected static Matrix4 mtx = new Matrix4();
	public EntityInterpolator interpolator = new EntityInterpolator(this);
	public float health = 100f;


	protected btCollisionObject body;
	protected Vector3 bodyOffset = new Vector3();
	public Movement movement;
	public boolean onGround;
	public float distFromGround;
	/** height, width, depth dimensions of the entity */
	protected Vector3 dimen = new Vector3();
	protected Quaternion rotation = new Quaternion();
	/** the Physics class collision callbacks will set collision position change, which is processed during update */
	protected Vector3 collisionPositionChanges = new Vector3();
	protected int collisionPositionChangeCount = 0;

	/** Entities have three choices for graphical representation: a 3d model, a billboard (decal), and none */
	private EntityModel entityModel;

	/** null for non-player entity */
	protected Player player;

	public static enum EntityGraphicsType {
		Decal,
		Model,
		None
	}

	public Entity(EntityGraphicsType graphicsType) {
		synchronized (Entity.list) {
			list.add(this);
		}
		switch(graphicsType) {
			case Decal:
				//entityDecal = new EntityDecal(this);
				entityModel = new EntityBillboard(this);
				break;
			case Model:
				entityModel = new EntityModel(this);
				break;
			case None:
				// do nothing!
				break;
			default:
				throw new GdxRuntimeException("unhandled enum type");
		}
	}

	public static Entity getEntityById(int id) {
		Entity ent = idMap.get(id);
		if (ent == null) {
			if (!Main.isClient()) {
				throw new GdxRuntimeException("Fatal Error: Server could not find entity by id: " + id);
				//Log.error("Server could not find entity by id: " + id);
			} else {
				// this is where the client sees an entity it hasn't heard of before, and asks the server
				// for info about it in order to create it
				Log.debug("Client couldn't find entity by id, requesting entity info from server");
				Main.getNetClient().requestEntityInfo(id);
			}
		}
		return ent;
	}

	public static boolean entityWithIdExists(int id) {
		synchronized (list) {
			for (Entity ent : list) {
				if (ent.id == id) {
					return true;
				}
			}
		}
		return false;
	}

	private static int nextEntityId;

	public static void assignEntityID(Entity ent) {
		if (usedIDs.contains(nextEntityId, false)) {
			nextEntityId++;
		}
		int id = nextEntityId;
		nextEntityId++;
		assignEntityID(ent, id);
	}

	public static void assignEntityID(Entity ent, int id) {
		for (Entity e : list) {
			if (e.id == id) {
				throw new GdxRuntimeException("Fatal Error: Cannot assign id to entity, other entity with id already exists: " + id);
			}
		}
		if (ent.id != -1) {
			throw new GdxRuntimeException("Fatal Error: Entity has already been assigned id: " + ent.id);
		}
		ent.id = id;
		usedIDs.add(id);
		idMap.put(id, ent);
	}

	public static void init() {
		list = new Array<>();
		destroyQueue = new Array<>();
		idMap = new IntMap<>();
		usedIDs = new Array<>();
		DynamicEntity.list = new Array<>();
		EntityModel.list = new Array<>();
	}

	public static void updateAll(float timeStep) {
		for (int i = 0; i < Entity.list.size; i++) {
			Entity ent = Entity.list.get(i);
			ent.update(timeStep);
		}
		processDestroyQueue();
	}

	void update(float timeStep) {
		// the server sets entity data directly, no need to handle updates from server or interpolate
		if (!Main.isServer()) {
			interpolator.update();
			if (player == null) {
				updateTransforms();
				return; // don't simulate movement/physics of non-player entities for clients
						// they are simulated completely server-side
			}
		}
		handleCollisions();
		updateDistFromGround();
		int groundRayHits = Physics.lastDistanceFromGroundRayHitCount;
		float avgDist = Physics.lastDistanceFromGroundAvgDist;
		// when the ray went the full length and did not hit the ground, NaN is the return value
		onGround = false;
		float embedThreshold = 0f;
		if (!Float.isNaN(distFromGround)) {
			Vector3 vel = getVelocity();
			if (distFromGround < 0.1f) {
				adjustPosition(tmp.set(0f, -distFromGround, 0f));
				if (vel.y < 0f) vel.y = 0f;
				onGround = true;
			}
			if (distFromGround < embedThreshold && groundRayHits == 4 && avgDist <= 0f) {
				// penetrating into the ground
				if (player != null) {
					Log.debug("ground embed adjust");
				}
				getPosition().y += -distFromGround;
			} else if (distFromGround > 0f) {
				Vector3 velocity = getVelocity();
				// cap velocity to distance from ground
				if (velocity.y < 0 && distFromGround - velocity.y <= 0f) {
					velocity.y = -distFromGround;
					System.out.println("cap velocity: " + velocity.y);
				}
			}
		}
		movement.update(timeStep, onGround);
		updateTransforms();
	}

	/** The way bullet works, there might be multiple collision points when two object collide with each other.
	 *  Therefore, we take the average of the position change caused by each collision and apply it as the final
	 *  position change.
	 */
	public void handleCollisions() {
		if (collisionPositionChangeCount > 0) {
			collisionPositionChanges.scl(1f / collisionPositionChangeCount);
			collisionPositionChanges.scl(-1f); // subtraction
			adjustPosition(collisionPositionChanges);
			collisionPositionChangeCount = 0;
			collisionPositionChanges.setZero();
		}
	}

	// TODO check this works properly
	public void faceTowards(Vector3 targ) {
		float desired = Tools.getAngleFromAtoB(movement.getPosition(), targ, Vector3.Y);
		rotation.setEulerAngles(-desired, rotation.getPitch(), rotation.getRoll());
	}

	public void updateTransforms() {
		// physics body transform
		q.setEulerAngles(rotation.getYaw(), 0f, 0f); // physics bodies only care about yaw
		mtx.set(q);
		mtx.setTranslation(tmp.set(getPosition()).add(bodyOffset));
		body.setWorldTransform(mtx);
	}

	public void setDestination(Vector3 d) {
		movement.setDestination(d);
	}

	public void setRelativeDestination(Vector3 delta) {
		tmp.set(movement.getPosition()).add(delta);
		setDestination(tmp);
	}

	/** Stops ground units from moving up when looking up.
	 * for example, if an entity is rotated to be facing almost straight up,
	 * this method relativizes the destination to be "in front of" the entity
	 * on the xz (ground) plane */
	public void setRelativeDestinationByYaw(Vector3 delta) {
		// TODO what happens if one of the added vectors == Vector3.Y? i.e. straight up or straight down
		relativizeByYaw(delta);
		tmp.set(movement.getPosition()).add(delta);
		setDestination(tmp);
	}

	/** transform vector based on the current rotation, but set pitch to zero, useful for relative directions like "forward" and "back" */
	public Vector3 relativizeByYaw(Vector3 v) {
		q.setEulerAngles(rotation.getYaw(), 0f, 0f);
		q.transform(v);
		return v;
	}

	/** transform vector by current rotation, makes vector relative to current facing */
	public Vector3 relativize(Vector3 v) {
		rotation.transform(v);
		return v;
	}


	public void setPosition(Vector3 pos) {
		if (pos == null) throw new NullPointerException();
		if (movement == null) throw new NullPointerException();
		movement.setPosition(pos);
	}

	public void setPosition(float x, float y, float z) {
		setPosition(tmp.set(x, y, z));
	}

	public void adjustPosition(Vector3 delta) {
		movement.getPosition().add(delta);
	}

	public void setVelocity(Vector3 vel) {
		movement.getVelocity().set(vel);
	}

	// TODO is there a more correct way to handle collision position changes?
	/** combines all collision position changes, which are averaged when processed during update */
	public void addCollisionPositionChange(Vector3 posDelta) {
		collisionPositionChangeCount++;
		collisionPositionChanges.add(posDelta);
	}

	public Movement getMovement() {
		return movement;
	}

	static Vector3 tmp = new Vector3();
	static Vector3 tmp2 = new Vector3();
	static Vector3 tmp3 = new Vector3();

	public Vector3 getPosition() {
		return movement.getPosition();
	}

	public Vector3 getVelocity() {
		return movement.getVelocity();
	}

	public void setRotation(Quaternion newRot) {
		rotation.set(newRot);
	}

	public void adjustRotation(Direction.Rotation rot) {
		System.out.println("adjust rotation: " + rot.vector);
		float rotSpeed = 4f;
		adjustYaw(-rot.vector.x * rotSpeed);
		adjustPitch(-rot.vector.y * rotSpeed);
		adjustRoll(-rot.vector.z * rotSpeed);
	}

	public Quaternion getRotation() {
		return rotation;
	}

	public void adjustVelocity(Vector3 delta) {
		movement.getVelocity().add(delta);
	}

	/** adjust velocity based on relative directions. i.e. Vector3.Z == forward, (0, 1, 1) == forward-up */
	public void adjustVelocityRelativeByYaw(Vector3 delta) {
		adjustVelocity(relativizeByYaw(delta));
	}

	public void setYawPitchRoll(float y, float p, float r) {
		getRotation().setEulerAngles(y, p, r);
	}

	public void setYawPitchRoll(Vector3 rot) {
		getRotation().setEulerAngles(rot.x, rot.y, rot.z);
	}

	public void lookAt(Vector3 pos) {
		tmp.set(pos).sub(getPosition());
		q.setFromCross(Vector3.Z, tmp.nor());
		setYawPitchRoll(q.getYaw(), getPitch(), getRoll());
	}

	public float getYaw() {
		return rotation.getYaw();
	}


	public void setYaw(float amt) {
		rotation.setEulerAngles(amt, rotation.getPitch(), rotation.getRoll());
	}

	public void adjustYaw(float amt) {
		float yaw = getYaw();
		yaw += amt;
		setYaw(yaw);
	}

	public float getPitch() {
		return rotation.getPitch();
	}

	public void setPitch(float amt) {
		rotation.setEulerAngles(rotation.getYaw(), amt, rotation.getRoll());
	}

	public void adjustPitch(float amt) {
		float pitch = getPitch();
		// avoid gimbal lock
		// technically could use Quaternions for free rotation, but not necessary for FPS
		pitch = MathUtils.clamp(pitch + amt, -89f, 89f);
		setPitch(pitch);
	}

	public float getRoll() {
		return rotation.getRoll();
	}

	public void setRoll(float amt) {
		rotation.setEulerAngles(rotation.getYaw(), rotation.getPitch(), amt);
	}

	public void adjustRoll(float amt) {
		float roll = getRoll();
		roll += amt;
		setRoll(roll);
	}

	public Vector3 getDimensions() {
		return dimen;
	}

	public btCollisionObject getBody() {
		return body;
	}

	public boolean isFlyingEntity() {
		return movement instanceof FlyingMovement;
	}

	public Player getPlayer() {
		return player;
	}

	public void setPlayer(Player player) {
		if (player == null) throw new GdxRuntimeException("cannot set to null (yet!)");
		this.player = player;
	}

	public static void destroy(int id) {
		Entity ent = getEntityById(id);
		ent.destroy();
	}

	private boolean destroyed;

	public void destroy() {
		if (destroyed) return;
		destroyed = true;
		synchronized (destroyQueue) {
			Log.debug("destroy entity, id: " + id);
			destroyQueue.add(id);
			if (Main.isServer()) {
				Main.inst.server.queueDestroyedEntity(id);
			}
			if (Main.isClient()) {
				new BlueExplosion(getPosition());
			}
		}
	}

	protected void removeFromGame() {
		synchronized (Entity.list) {
			list.removeValue(this, true);
		}
		if (entityModel != null) {
			EntityModel.list.removeValue(entityModel, true);
		}
		Physics.inst.removeBody(body);
	}

	public static void processDestroyQueue() {
		synchronized (destroyQueue) {
			for (int id : destroyQueue) {
				synchronized (Entity.list) {
					for (Entity ent : list) {
						if (ent.id == id) {
							ent.removeFromGame();
							// id should be unique, unless something is broken
							break;
						}
					}
				}
			}
			destroyQueue.clear();
		}
	}

	public void applyDamage(float dmg) {
		//Log.debug("Entity[" + id + "] took damage: " + dmg);
		health -= dmg;
		if (health <= 0f) {
			destroy();
		}
	}

	public void updateDistFromGround() {
		if (Main.isMobile()) {
			distFromGround = Physics.inst.distanceFromGroundFast(movement.getPosition(), dimen);
		} else {
			distFromGround = Physics.inst.distanceFromGround(movement.getPosition(), dimen);
		}
	}

	public EntityModel getEntityModel() {
		return entityModel;
	}

	public float getHeight() {
		return dimen.y;
	}

	public float getWidth() {
		return dimen.x;
	}

	public float getDepth() {
		return dimen.z;
	}

	// TODO make this more accurate
	public float getRadius() {
		return Math.max(dimen.z, (Math.max(dimen.x, dimen.y)));
	}

}