package inf101.v18.rogue101.game;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.function.Supplier;

import inf101.v18.gfx.Screen;
import inf101.v18.gfx.gfxmode.ITurtle;
import inf101.v18.gfx.gfxmode.TurtlePainter;
import inf101.v18.gfx.textmode.Printer;
import inf101.v18.grid.GridDirection;
import inf101.v18.grid.IGrid;
import inf101.v18.grid.ILocation;
import inf101.v18.rogue101.Main;
import inf101.v18.rogue101.examples.Carrot;
import inf101.v18.rogue101.items.Manga;
import inf101.v18.rogue101.examples.Rabbit;
import inf101.v18.rogue101.map.GameMap;
import inf101.v18.rogue101.map.IGameMap;
import inf101.v18.rogue101.map.IMapView;
import inf101.v18.rogue101.map.MapReader;
import inf101.v18.rogue101.objects.*;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.KeyCode;
import javafx.scene.paint.Color;

public class Game implements IGame {
	/**
	 * All the IActors that have things left to do this turn
	 */
	private List<IActor> actors = Collections.synchronizedList(new ArrayList<>());
	/**
	 * For fancy solution to factory problem
	 */
	private Map<String, Supplier<IItem>> itemFactories = new HashMap<>();
	/**
	 * Useful random generator
	 */
	private Random random = new Random();
	/**
	 * The game map. {@link IGameMap} gives us a few more details than
	 * {@link IMapView} (write access to item lists); the game needs this but
	 * individual items don't.
	 */
	private IGameMap map;
	private IActor currentActor;
	private ILocation currentLocation;
	private int movePoints = 0;
	private final ITurtle painter;
	private final Printer printer;
	private int numPlayers = 0;

	public Game(Screen screen, ITurtle painter, Printer printer) {
		this.painter = painter;
		this.printer = printer;

		addFactory();

		// NOTE: in a more realistic situation, we will have multiple levels (one map
		// per level), and (at least for a Roguelike game) the levels should be
		// generated
		//
		// inputGrid will be filled with single-character strings indicating what (if
		// anything)
		// should be placed at that map square
		IGrid<String> inputGrid = MapReader.readFile("maps/level1.txt");
		if (inputGrid == null) {
			System.err.println("Map not found – falling back to builtin map");
			inputGrid = MapReader.readString(Main.BUILTIN_MAP);
		}
		this.map = new GameMap(inputGrid.getArea());
		for (ILocation loc : inputGrid.locations()) {
			IItem item = createItem(inputGrid.get(loc));
			if (item != null) {
				map.add(loc, item);
			}
		}

	}

	public Game(String mapString) {
		printer = new Printer(1280, 720);
		painter = new TurtlePainter(1280, 720);

		addFactory();

		IGrid<String> inputGrid = MapReader.readString(mapString);
		this.map = new GameMap(inputGrid.getArea());
		for (ILocation loc : inputGrid.locations()) {
			IItem item = createItem(inputGrid.get(loc));
			if (item != null) {
				map.add(loc, item);
			}
		}
	}

	@Override
	public void addItem(IItem item) {
		map.add(currentLocation, item);
	}

	@Override
	public void addItem(String sym) {
		IItem item = createItem(sym);
		if (item != null)
			map.add(currentLocation, item);
	}

	@Override
	public ILocation attack(GridDirection dir, IItem target) {
		ILocation loc = currentLocation.go(dir);
		if (!map.has(loc, target)) {
			throw new IllegalMoveException("Target isn't there!");
		}
		int damage = currentActor.getAttack() + random.nextInt(20) + 1;
		if (damage >= target.getDefence() + 10) {
			int actualDamage = target.handleDamage(this, target, damage);
			formatMessage("%s hits %s for %d damage", currentActor.getName(), target.getName(), actualDamage);
		} else {
			displayMessage("The attack missed.");
		}
		
		map.clean(loc);

		if (target.isDestroyed()) {
			return move(dir);
		} else {
			movePoints--;
			return currentLocation;
		}
	}

	/**
	 * Begin a new game turn, or continue to the previous turn
	 * 
	 * @return True if the game should wait for more user input
	 */
	public boolean doTurn() {
		do {
			if (actors.isEmpty()) {
				// System.err.println("new turn!");

				// no one in the queue, we're starting a new turn!
				// first collect all the actors:
				beginTurn();
			}

			if (random.nextInt(100) < 20) {
				ILocation loc = map.getLocation(random.nextInt(map.getWidth()), random.nextInt(map.getHeight()));
				if (!map.hasActors(loc) && !map.hasItems(loc) && !map.hasWall(loc)) {
					map.add(loc, new Carrot());
				}
			}

			// process actors one by one; for the IPlayer, we return and wait for keypresses
			// Possible TODO: for INonPlayer, we could also return early (returning
			// *false*), and then insert a little timer delay between each non-player move
			// (the timer
			// is already set up in Main)
			while (!actors.isEmpty()) {
				// get the next player or non-player in the queue
				currentActor = actors.remove(0);
				if (currentActor.isDestroyed()) // skip if it's dead
					continue;
				currentLocation = map.getLocation(currentActor);
				if (currentLocation == null) {
					displayDebug("doTurn(): Whoops! Actor has disappeared from the map: " + currentActor);
				}
				movePoints = 1; // everyone gets to do one thing

				if (currentActor instanceof INonPlayer) {
					// computer-controlled players do their stuff right away
					((INonPlayer) currentActor).doTurn(this);
					// remove any dead items from current location
					map.clean(currentLocation);
				} else if (currentActor instanceof IPlayer) {
					if (currentActor.isDestroyed()) {
						// a dead human player gets removed from the game
						// TODO: you might want to be more clever here
						displayMessage("YOU DIE!!!");
						map.remove(currentLocation, currentActor);
						currentActor = null;
						currentLocation = null;
					} else {
						// For the human player, we need to wait for input, so we just return.
						// Further keypresses will cause keyPressed() to be called, and once the human
						// makes a move, it'll lose its movement point and doTurn() will be called again
						//
						// NOTE: currentActor and currentLocation are set to the IPlayer (above),
						// so the game remembers who the player is whenever new keypresses occur. This
						// is also how e.g., getLocalItems() work – the game always keeps track of
						// whose turn it is.
						return true;
					}
				} else {
					displayDebug("doTurn(): Hmm, this is a very strange actor: " + currentActor);
				}
			}
		} while (numPlayers > 0); // we can safely repeat if we have players, since we'll return (and break out of
									// the loop) once we hit the player
		return true;
	}

	/**
	 * Go through the map and collect all the actors.
	 */
	private void beginTurn() {
		numPlayers = 0;
		// this extra fancy iteration over each map location runs *in parallel* on
		// multicore systems!
		// that makes some things more tricky, hence the "synchronized" block and
		// "Collections.synchronizedList()" in the initialization of "actors".
		// NOTE: If you want to modify this yourself, it might be a good idea to replace
		// "parallelStream()" by "stream()", because weird things can happen when many
		// things happen
		// at the same time! (or do INF214 or DAT103 to learn about locks and threading)
		map.getArea().parallelStream().forEach((loc) -> { // will do this for each location in map
			List<IItem> list = map.getAllModifiable(loc); // all items at loc
			Iterator<IItem> li = list.iterator(); // manual iterator lets us remove() items
			while (li.hasNext()) { // this is what "for(IItem item : list)" looks like on the inside
				IItem item = li.next();
				if (item.getCurrentHealth() < 0) {
					// normally, we expect these things to be removed when they are destroyed, so
					// this shouldn't happen
					synchronized (this) {
						formatDebug("beginTurn(): found and removed leftover destroyed item %s '%s' at %s%n",
								item.getName(), item.getSymbol(), loc);
					}
					li.remove();
					map.remove(loc, item); // need to do this too, to update item map
				} else if (item instanceof IPlayer) {
					actors.add(0, (IActor) item); // we let the human player go first
					synchronized (this) {
						numPlayers++;
					}
				} else if (item instanceof IActor) {
					actors.add((IActor) item); // add other actors to the end of the list
				} else if (item instanceof Carrot) {
					((Carrot) item).doTurn();
				}
			}
		});
	}

	@Override
	public boolean canGo(GridDirection dir) {
		return map.canGo(currentLocation, dir);
	}

	private void addFactory() {
		itemFactories.put("#", Wall::new);
		itemFactories.put("@", Player::new);
		itemFactories.put("C", Carrot::new);
		itemFactories.put("R", Rabbit::new);
		itemFactories.put("M", Manga::new);
		itemFactories.put(".", Dust::new);
	}

	@Override
	public IItem createItem(String sym) {
		switch (sym) {
		case " ":
			return null;
		default:
			// alternative/advanced method
			Supplier<IItem> factory = itemFactories.get(sym);
			if (factory != null) {
				return factory.get();
			} else {
				System.err.println("createItem: Don't know how to create a '" + sym + "'");
				return null;
			}
		}
	}

	@Override
	public void displayDebug(String s) {
		printer.clearLine(Main.LINE_DEBUG);
		printer.printAt(1, Main.LINE_DEBUG, s, Color.DARKRED);
		System.err.println(s);
	}

	@Override
	public void displayMessage(String s) {
		// it should be safe to print to lines Main.LINE_MSG1, Main.LINE_MSG2,
		// Main.LINE_MSG3
		// TODO: you can save the last three lines, and display/scroll them
		printer.clearLine(Main.LINE_MSG1);
		printer.printAt(1, Main.LINE_MSG1, s);
		System.out.println("Message: «" + s + "»");
	}

	@Override
	public void displayStatus(String s) {
		printer.clearLine(Main.LINE_STATUS);
		printer.printAt(1, Main.LINE_STATUS, s);
		System.out.println("Status: «" + s + "»");
	}

	public void draw() {
		map.draw(painter, printer);
	}

	@Override
	public boolean drop(IItem item) {
		if (item != null) {
			map.add(currentLocation, item);
			return true;
		} else
			return false;
	}

	@Override
	public void formatDebug(String s, Object... args) {
		displayDebug(String.format(s, args));
	}

	@Override
	public void formatMessage(String s, Object... args) {
		displayMessage(String.format(s, args));
	}

	@Override
	public void formatStatus(String s, Object... args) {
		displayStatus(String.format(s, args));
	}

	@Override
	public int getHeight() {
		return map.getHeight();
	}

	@Override
	public List<IItem> getLocalItems() {
		return map.getItems(currentLocation);
	}

	@Override
	public ILocation getLocation() {
		return currentLocation;
	}

	@Override
	public ILocation getLocation(GridDirection dir) {
		if (currentLocation.canGo(dir))
			return currentLocation.go(dir);
		else
			return null;
	}

	/**
	 * Return the game map. {@link IGameMap} gives us a few more details than
	 * {@link IMapView} (write access to item lists); the game needs this but
	 * individual items don't.
	 */
	@Override
	public IMapView getMap() {
		return map;
	}

	@Override
	public List<GridDirection> getPossibleMoves() {
		List<GridDirection> moves = new ArrayList<>();
		for (GridDirection dir : GridDirection.FOUR_DIRECTIONS) {
			if (canGo(dir)) {
				moves.add(dir);
			}
		}
		return moves;
	}

	@Override
	public List<ILocation> getVisible() {
		List<ILocation> neighbours = map.getNeighbourhood(currentLocation, currentActor.getVision());
		List<ILocation> valid = new ArrayList<>();
		for (ILocation neighbour : neighbours) {
			boolean blocked = false;
			for (ILocation tile : currentLocation.gridLineTo(neighbour)) {
				if (map.hasWall(tile)) {
					blocked = true;
					break;
				}
			}
			if (!blocked) {
				valid.add(neighbour);
			}
		}
		return valid;
	}

	@Override
	public int getWidth() {
		return map.getWidth();
	}

	public void keyPressed(KeyCode code) {
		// only an IPlayer/human can handle keypresses, and only if it's the human's
		// turn
		if (currentActor instanceof IPlayer) {
			((IPlayer) currentActor).keyPressed(this, code); // do your thing
			if (movePoints <= 0)
				doTurn(); // proceed with turn if we're out of moves
		}
	}

	@Override
	public ILocation move(GridDirection dir) {
		if (movePoints < 1)
			throw new IllegalMoveException("You're out of moves!");
		ILocation newLoc = map.go(currentLocation, dir);
		map.remove(currentLocation, currentActor);
		map.add(newLoc, currentActor);
		currentLocation = newLoc;
		movePoints--;
		return currentLocation;
	}

	@Override
	public IItem pickUp(IItem item) {
		if (item != null && map.has(currentLocation, item)) {
			// TODO: bruk getAttack()/getDefence() til å avgjøre om man får til å plukke opp
			// tingen
			// evt.: en IActor kan bare plukkes opp hvis den har få/ingen helsepoeng igjen
			map.remove(currentLocation, item);
			return item;
		} else {
			return null;
		}
	}

	@Override
	public ILocation rangedAttack(GridDirection dir, IItem target) {
		return currentLocation;
	}

	@Override
	public ITurtle getPainter() {
		return painter;
	}

	@Override
	public Printer getPrinter() {
		return printer;
	}

	@Override
	public int[] getFreeTextAreaBounds() {
		int[] area = new int[4];
		area[0] = getWidth() + 1;
		area[1] = 1;
		area[2] = printer.getLineWidth();
		area[3] = printer.getPageHeight() - 5;
		return area;
	}

	@Override
	public void clearFreeTextArea() {
		printer.clearRegion(getWidth() + 1, 1, printer.getLineWidth() - getWidth(), printer.getPageHeight() - 5);
	}

	@Override
	public void clearFreeGraphicsArea() {
		painter.as(GraphicsContext.class).clearRect(getWidth() * printer.getCharWidth(), 0,
				painter.getWidth() - getWidth() * printer.getCharWidth(),
				(printer.getPageHeight() - 5) * printer.getCharHeight());
	}

	@Override
	public double[] getFreeGraphicsAreaBounds() {
		double[] area = new double[4];
		area[0] = getWidth() * printer.getCharWidth();
		area[1] = 0;
		area[2] = painter.getWidth();
		area[3] = getHeight() * printer.getCharHeight();
		return area;
	}

	@Override
	public IActor getActor() {
		return currentActor;
	}

	public ILocation setCurrent(IActor actor) {
		currentLocation = map.getLocation(actor);
		if (currentLocation != null) {
			currentActor = actor;
			movePoints = 1;
		}
		return currentLocation;
	}

	public IActor setCurrent(ILocation loc) {
		List<IActor> list = map.getActors(loc);
		if (!list.isEmpty()) {
			currentActor = list.get(0);
			currentLocation = loc;
			movePoints = 1;
		}
		return currentActor;
	}

	public IActor setCurrent(int x, int y) {
		return setCurrent(map.getLocation(x, y));
	}
	
	@Override
	public Random getRandom() {
		return random;
	}

	@Override
	public GridDirection locationDirection(ILocation start, ILocation target) {
		int targetX = target.getX(), targetY = target.getY();
		int startX = start.getX(), startY = start.getY();
		GridDirection dir = GridDirection.CENTER;
		if (targetX > startX && targetY > startY) {
			if (Math.abs(targetX - startX) < Math.abs(targetY - startY)) {
				dir = GridDirection.SOUTH;
			} else {
				dir = GridDirection.EAST;
			}
		} else if (targetX > startX && targetY < startY) {
			if (Math.abs(targetX - startX) < Math.abs(targetY - startY)) {
				dir = GridDirection.NORTH;
			} else {
				dir = GridDirection.EAST;
			}
		} else if (targetX < startX && targetY > startY) {
			if (Math.abs(targetX - startX) < Math.abs(targetY - startY)) {
				dir = GridDirection.SOUTH;
			} else {
				dir = GridDirection.WEST;
			}
		} else if (targetX < startX && targetY < startY) {
			if (Math.abs(targetX - startX) < Math.abs(targetY - startY)) {
				dir = GridDirection.NORTH;
			} else {
				dir = GridDirection.WEST;
			}
		} else if (targetX > startX) {
			dir = GridDirection.EAST;
		} else if (targetX < startX) {
			dir = GridDirection.WEST;
		} else if (targetY > startY) {
			dir = GridDirection.SOUTH;
		} else if (targetY < startY) {
			dir = GridDirection.NORTH;
		}
		return dir;
	}
}