/*
 * ShootOFF - Software for Laser Dry Fire Training
 * Copyright (C) 2016 phrack
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.shootoff.gui.targets;

import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.shootoff.config.Configuration;
import com.shootoff.gui.CanvasManager;
import com.shootoff.targets.Hit;
import com.shootoff.targets.ImageRegion;
import com.shootoff.targets.RectangleRegion;
import com.shootoff.targets.RegionType;
import com.shootoff.targets.Target;
import com.shootoff.targets.TargetRegion;
import com.shootoff.targets.animation.SpriteAnimation;

import javafx.animation.Animation.Status;
import com.shootoff.util.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Dimension2D;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;

/**
 * This is contains the code required to display, resize, and move targets. It
 * also implements required functions like animating targets and determine if a
 * target was hit and where if it was hit. This class needs to be re-implemented
 * to make ShootOFF work on platforms that don't support JavaFX.
 * 
 * @author phrack
 */
public class TargetView implements Target {
	private static final Logger logger = LoggerFactory.getLogger(TargetView.class);

	private static final double ANCHOR_WIDTH = 10;
	private static final double ANCHOR_HEIGHT = ANCHOR_WIDTH;

	protected static final int MOVEMENT_DELTA = 1;
	protected static final int SCALE_DELTA = 1;
	private static final int RESIZE_MARGIN = 5;

	private final File targetFile;
	private final Group targetGroup;
	private final Map<String, String> targetTags;
	private final Set<Node> resizeAnchors = new HashSet<>();
	private final Optional<Configuration> config;
	private final Optional<CanvasManager> parent;
	private final Optional<List<Target>> targets;
	private final boolean userDeletable;
	private final String cameraName;
	private boolean keepInBounds = false;
	private boolean isSelected = false;
	private boolean move;
	private boolean resize;
	private boolean top;
	private boolean bottom;
	private boolean left;
	private boolean right;
	private double x;
	private double y;

	private final double origWidth;
	private final double origHeight;

	private TargetSelectionListener selectionListener;

	public TargetView(File targetFile, Group target, Map<String, String> targetTags, CanvasManager parent,
			boolean userDeletable) {
		this.targetFile = targetFile;
		targetGroup = target;
		this.targetTags = targetTags;
		config = Optional.ofNullable(Configuration.getConfig());
		this.parent = Optional.of(parent);
		targets = Optional.empty();
		this.userDeletable = userDeletable;
		cameraName = parent.getCameraName();
		origWidth = targetGroup.getBoundsInParent().getWidth();
		origHeight = targetGroup.getBoundsInParent().getHeight();

		targetGroup.setOnMouseClicked((event) -> {
			// Skip target selection if click to shoot is being used
			if (config.isPresent() && config.get().inDebugMode() && (event.isShiftDown() || event.isControlDown()))
				return;

			parent.toggleTargetSelection(Optional.of(this));
			targetGroup.requestFocus();
			event.consume();
		});

		mousePressed();
		mouseDragged();
		mouseMoved();
		mouseReleased();
		keyPressed();
	}

	// Used by the session viewer, target pane, and for testing
	public TargetView(Group target, Map<String, String> targetTags, List<Target> targets) {
		targetFile = null;
		targetGroup = target;
		this.targetTags = targetTags;
		config = Optional.empty();
		parent = Optional.empty();
		this.targets = Optional.of(targets);
		userDeletable = false;
		cameraName = null;
		origWidth = targetGroup.getBoundsInParent().getWidth();
		origHeight = targetGroup.getBoundsInParent().getHeight();

		mousePressed();
		mouseDragged();
		mouseMoved();
		mouseReleased();
		keyPressed();

	}

	public boolean isUserDeletable() {
		return userDeletable;
	}

	@Override
	public File getTargetFile() {
		return targetFile;
	}

	public Group getTargetGroup() {
		return targetGroup;
	}

	@Override
	public int getTargetIndex() {
		if (parent.isPresent())
			return parent.get().getTargets().indexOf(this);
		else
			return -1;
	}

	@Override
	public void fillParent() {
		if (parent.isPresent()) {
			final Bounds b = parent.get().getCanvasGroup().getBoundsInParent();
			setDimensions(b.getWidth(), b.getHeight());
			final Point2D p = targetGroup.localToParent(0, 0);
			setPosition(p.getX() * -1, p.getY() * -1);
		}
	}

	@Override
	public void addTargetChild(Node child) {
		getTargetGroup().getChildren().add(child);
	}

	@Override
	public void removeTargetChild(Node child) {
		getTargetGroup().getChildren().remove(child);
	}

	@Override
	public List<TargetRegion> getRegions() {
		final List<TargetRegion> regions = new ArrayList<>();

		for (final Node n : getTargetGroup().getChildren()) {
			if (n instanceof TargetRegion) regions.add((TargetRegion) n);
		}

		return regions;
	}

	@Override
	public boolean hasRegion(TargetRegion region) {
		return getTargetGroup().getChildren().contains(region);
	}

	@Override
	public void setVisible(boolean isVisible) {
		getTargetGroup().setVisible(isVisible);
	}

	@Override
	public boolean isVisible() {
		return getTargetGroup().isVisible();
	}

	@Override
	public void setPosition(double x, double y) {
		targetGroup.setLayoutX(x);
		targetGroup.setLayoutY(y);

		if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
			config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this, (int) targetGroup.getLayoutX(),
					(int) targetGroup.getLayoutY());
		}
	}

	@Override
	public Point2D getPosition() {
		return new Point2D(targetGroup.getLayoutX(), targetGroup.getLayoutY());
	}

	@Override
	public void setDimensions(double newWidth, double newHeight) {
		final double currentWidth = targetGroup.getBoundsInParent().getWidth();
		final double currentHeight = targetGroup.getBoundsInParent().getHeight();

		if (Math.abs(currentWidth - newWidth) > .001) {
			final double scaleXDelta = 1.0 + ((newWidth - currentWidth) / currentWidth);
			targetGroup.setScaleX(targetGroup.getScaleX() * scaleXDelta);

			// Keep unresizable regions the same size
			for (final Node n : targetGroup.getChildren()) {
				final TargetRegion r = (TargetRegion) n;

				if (r.tagExists(Target.TAG_RESIZABLE) && !Boolean.parseBoolean(r.getTag(Target.TAG_RESIZABLE))) {
					final double width = n.getBoundsInParent().getWidth();
					final double scaledPercentChange = (width / (width * targetGroup.getScaleX()));

					n.setScaleX(scaledPercentChange);
				}
			}
		}

		if (Math.abs(currentHeight - newHeight) > .001) {
			final double scaleYDelta = 1.0 + ((newHeight - currentHeight) / currentHeight);
			targetGroup.setScaleY(targetGroup.getScaleY() * scaleYDelta);

			// Keep unresizable regions the same size
			for (final Node n : targetGroup.getChildren()) {
				final TargetRegion r = (TargetRegion) n;

				if (r.tagExists(Target.TAG_RESIZABLE) && !Boolean.parseBoolean(r.getTag(Target.TAG_RESIZABLE))) {
					final double height = n.getBoundsInParent().getHeight();
					final double scaledPercentChange = (height / (height * targetGroup.getScaleY()));

					n.setScaleY(scaledPercentChange);
				}
			}
		}
	}

	@Override
	public Dimension2D getDimension() {
		return new Dimension2D(targetGroup.getBoundsInParent().getWidth(), targetGroup.getBoundsInParent().getHeight());
	}

	@Override
	public double getScaleX() {
		return targetGroup.getBoundsInParent().getWidth() / origWidth;
	}

	@Override
	public double getScaleY() {
		return targetGroup.getBoundsInParent().getHeight() / origHeight;
	}

	@Override
	public void scale(double widthFactor, double heightFactor) {
		final double newWidth = getDimension().getWidth() * widthFactor;
		final double widthDelta = newWidth - getDimension().getWidth();
		final double newX = getBoundsInParent().getMinX() * widthFactor;
		final double deltaX = newX - getBoundsInParent().getMinX() + (widthDelta / 2);

		final double newHeight = getDimension().getHeight() * heightFactor;
		final double heightDelta = newHeight - getDimension().getHeight();
		final double newY = getBoundsInParent().getMinY() * heightFactor;
		final double deltaY = newY - getBoundsInParent().getMinY() + (heightDelta / 2);

		setPosition(getPosition().getX() + deltaX, getPosition().getY() + deltaY);

		setDimensions(newWidth, newHeight);
	}

	@Override
	public Bounds getBoundsInParent() {
		return targetGroup.getBoundsInParent();
	}

	@Override
	public Point2D parentToLocal(double x, double y) {
		return getTargetGroup().parentToLocal(x, y);
	}

	@Override
	public void setClip(Rectangle clip) {
		getTargetGroup().setClip(clip);
	}

	/**
	 * Sets whether or not the target should stay in the bounds of its parent.
	 * 
	 * @param keepInBounds
	 *            <tt>true</tt> if the target should stay in bounds,
	 *            <tt>false</tt> otherwise.
	 */
	public void setKeepInBounds(boolean keepInBounds) {
		this.keepInBounds = keepInBounds;
	}

	public boolean getKeepInBounds() {
		return keepInBounds;
	}

	public static void parseCommandTag(TargetRegion region, CommandProcessor commandProcessor) {
		if (!region.tagExists("command")) return;

		final String commandsSource = region.getTag("command");
		final List<String> commands = Arrays.asList(commandsSource.split(";"));

		for (final String command : commands) {
			final int openParen = command.indexOf('(');
			String commandName;
			List<String> args;

			if (openParen > 0) {
				commandName = command.substring(0, openParen);
				args = Arrays.asList(command.substring(openParen + 1, command.indexOf(')')).split(","));
			} else {
				commandName = command;
				args = new ArrayList<>();
			}

			commandProcessor.process(commands, commandName, args);
		}
	}

	public static Optional<TargetRegion> getTargetRegionByName(List<Target> targets, TargetRegion region, String name) {
		for (final Target target : targets) {
			if (target.hasRegion(region)) {
				for (final TargetRegion r : target.getRegions()) {
					if (r.tagExists("name") && r.getTag("name").equals(name)) return Optional.of(r);
				}
			}
		}

		return Optional.empty();
	}

	@Override
	public void animate(TargetRegion region, List<String> args) {
		ImageRegion imageRegion;

		boolean resetAfterAnimation = false;

		if (args.size() == 0) {
			imageRegion = (ImageRegion) region;
		} else if (args.get(0).equals("true")) {
			imageRegion = (ImageRegion) region;
			resetAfterAnimation = true;
		} else {
			Optional<TargetRegion> r;

			if (targets.isPresent()) {
				r = getTargetRegionByName(targets.get(), region, args.get(0));
			} else if (parent.isPresent()) {
				r = getTargetRegionByName(parent.get().getTargets(), region, args.get(0));
			} else {
				r = Optional.empty();
			}

			if (r.isPresent()) {
				imageRegion = (ImageRegion) r.get();
			} else {
				logger.error("Request to animate region named {}, but it doesn't exist.", args.get(0));
				return;
			}
		}

		// Don't repeat animations for fallen targets
		if (!imageRegion.onFirstFrame()) return;

		if (imageRegion.getAnimation().isPresent()) {
			final SpriteAnimation animation = imageRegion.getAnimation().get();
			animation.play();

			if (resetAfterAnimation) {
				animation.setOnFinished((e) -> {
					animation.reset();
					animation.setOnFinished(null);
				});
			}
		} else {
			logger.error("Request to animate region, but region does not contain an animation.");
		}
	}

	@Override
	public void reverseAnimation(TargetRegion region) {
		if (region.getType() != RegionType.IMAGE) {
			logger.error("A reversal was requested on a non-image region.");
			return;
		}

		final ImageRegion imageRegion = (ImageRegion) region;
		if (imageRegion.getAnimation().isPresent()) {
			final SpriteAnimation animation = imageRegion.getAnimation().get();

			if (animation.getStatus() == Status.RUNNING) {
				animation.setOnFinished((e) -> {
					animation.reverse();
					animation.setOnFinished(null);
				});
			} else {
				animation.reverse();
			}
		} else {
			logger.error("A reversal was requested on an image region that isn't animated.");
		}
	}

	public void toggleSelected() {
		isSelected = !isSelected;

		final Color stroke = isSelected ? TargetRegion.SELECTED_STROKE_COLOR : TargetRegion.UNSELECTED_STROKE_COLOR;

		for (final Node node : getTargetGroup().getChildren()) {
			if (!(node instanceof TargetRegion)) continue;

			final TargetRegion region = (TargetRegion) node;
			if (region.getType() != RegionType.IMAGE) {
				((Shape) region).setStroke(stroke);
			}
		}

		if (isSelected) {
			addResizeAnchors();
		} else {
			getTargetGroup().getChildren().removeAll(resizeAnchors);
			resizeAnchors.clear();
		}

		if (selectionListener != null) selectionListener.targetSelected(this, isSelected);
	}

	@Override
	public void setTargetSelectionListener(TargetSelectionListener selectionListener) {
		this.selectionListener = selectionListener;
	}

	public interface TargetSelectionListener {
		void targetSelected(Target target, boolean isSelected);
	}

	public boolean isSelected() {
		return isSelected;
	}

	private void addResizeAnchors() {
		final Bounds localBounds = getTargetGroup().getBoundsInLocal();
		final double horizontalMiddle = localBounds.getMinX() + (localBounds.getWidth() / 2) - (ANCHOR_WIDTH / 2);
		final double verticleMiddle = localBounds.getMinY() + (localBounds.getHeight() / 2) - (ANCHOR_HEIGHT / 2);

		// Top left
		addAnchor(localBounds.getMinX(), localBounds.getMinY());
		// Top middle
		addAnchor(horizontalMiddle, localBounds.getMinY());
		// Top right
		addAnchor(localBounds.getMaxX() - ANCHOR_WIDTH, localBounds.getMinY());
		// Middle left
		addAnchor(localBounds.getMinX(), verticleMiddle);
		// Middle right
		addAnchor(localBounds.getMaxX() - ANCHOR_WIDTH, verticleMiddle);
		// Bottom left
		addAnchor(localBounds.getMinX(), localBounds.getMaxY() - ANCHOR_HEIGHT);
		// Bottom middle
		addAnchor(horizontalMiddle, localBounds.getMaxY() - ANCHOR_HEIGHT);
		// Bottom right
		addAnchor(localBounds.getMaxX() - ANCHOR_WIDTH, localBounds.getMaxY() - ANCHOR_HEIGHT);
	}

	private RectangleRegion addAnchor(final double x, final double y) {
		final RectangleRegion anchor = new RectangleRegion(x, y, ANCHOR_WIDTH, ANCHOR_HEIGHT);

		// Make the anchor regions unshootable and unresizable
		final Map<String, String> regionTags = ((TargetRegion) anchor).getAllTags();
		regionTags.put(TargetView.TAG_IGNORE_HIT, "true");
		regionTags.put(TargetView.TAG_RESIZABLE, "false");

		anchor.setFill(Color.GOLD);
		anchor.setStroke(Color.BLACK);

		getTargetGroup().getChildren().add(anchor);

		// Ensure anchors appear the intended visual size even if the target
		// has been scaled
		if (targetGroup.getScaleX() != 1.0f) {
			final double scaledPercentChange = (ANCHOR_WIDTH / (ANCHOR_WIDTH * targetGroup.getScaleX()));
			anchor.setScaleX(scaledPercentChange);
		}

		if (targetGroup.getScaleY() != 1.0f) {
			final double scaledPercentChange = (ANCHOR_HEIGHT / (ANCHOR_HEIGHT * targetGroup.getScaleY()));
			anchor.setScaleY(scaledPercentChange);
		}

		resizeAnchors.add(anchor);

		return anchor;
	}

	@Override
	public Optional<Hit> isHit(double x, double y) {
		if (targetGroup.getBoundsInParent().contains(x, y)) {
			// Target was hit, see if a specific region was hit
			for (int i = targetGroup.getChildren().size() - 1; i >= 0; i--) {
				final Node node = targetGroup.getChildren().get(i);

				if (!(node instanceof TargetRegion)) continue;

				final Bounds nodeBounds = targetGroup.getLocalToParentTransform().transform(node.getBoundsInParent());

				final int adjustedX = (int) (x - nodeBounds.getMinX());
				final int adjustedY = (int) (y - nodeBounds.getMinY());

				if (nodeBounds.contains(x, y)) {
					// If we hit an image region on a transparent pixel,
					// ignore it
					final TargetRegion region = (TargetRegion) node;

					// Ignore regions where ignoreHit tag is true
					if (region.tagExists(TargetView.TAG_IGNORE_HIT)
							&& Boolean.parseBoolean(region.getTag(TargetView.TAG_IGNORE_HIT)))
						continue;

					if (region.getType() == RegionType.IMAGE) {
						// The image you get from the image view is its
						// original size. We need to resize it if it has
						// changed size to accurately determine if a pixel
						// is transparent
						final Image currentImage = ((ImageRegion) region).getImage();

						if (adjustedX < 0 || adjustedY < 0) {
							logger.debug(
									"An adjusted pixel is negative: Adjusted ({}, {}), Original ({}, {}), "
											+ " nodeBounds.getMin ({}, {})",
									adjustedX, adjustedY, x, y, nodeBounds.getMaxX(),
									nodeBounds.getMinY());
							return Optional.empty();
						}

						if (Math.abs(currentImage.getWidth() - nodeBounds.getWidth()) > .0000001
								|| Math.abs(currentImage.getHeight() - nodeBounds.getHeight()) > .0000001) {

							final BufferedImage bufferedOriginal = SwingFXUtils.fromFXImage(currentImage, null);

							final java.awt.Image tmp = bufferedOriginal.getScaledInstance((int) nodeBounds.getWidth(),
									(int) nodeBounds.getHeight(), java.awt.Image.SCALE_SMOOTH);
							final BufferedImage bufferedResized = new BufferedImage((int) nodeBounds.getWidth(),
									(int) nodeBounds.getHeight(), BufferedImage.TYPE_INT_ARGB);

							final Graphics2D g2d = bufferedResized.createGraphics();
							g2d.drawImage(tmp, 0, 0, null);
							g2d.dispose();

							try {
								if (adjustedX >= bufferedResized.getWidth() || adjustedY >= bufferedResized.getHeight()
										|| bufferedResized.getRGB(adjustedX, adjustedY) >> 24 == 0) {
									continue;
								}
							} catch (final ArrayIndexOutOfBoundsException e) {
								final String message = String.format(
										"Index out of bounds while trying to find adjusted coordinate (%d, %d) "
												+ "from original (%.2f, %.2f) in adjusted BufferedImage for target %s "
												+ "with width = %d, height = %d",
										adjustedX, adjustedY, x, y, getTargetFile().getPath(),
										bufferedResized.getWidth(), bufferedResized.getHeight());
								logger.error(message, e);
								return Optional.empty();
							}
						} else {
							if (adjustedX >= currentImage.getWidth() || adjustedY >= currentImage.getHeight()
									|| currentImage.getPixelReader().getArgb(adjustedX, adjustedY) >> 24 == 0) {
								continue;
							}
						}
					} else {
						// The shot is in the bounding box but make sure it
						// is in the shape's
						// fill otherwise we can get a shot detected where
						// there isn't actually
						// a region showing
						final Point2D localCoords = targetGroup.parentToLocal(x, y);
						if (!node.contains(localCoords)) continue;
					}

					return Optional.of(new Hit(this, (TargetRegion) node, adjustedX, adjustedY));
				}
			}
		}

		return Optional.empty();
	}

	private void mousePressed() {
		targetGroup.setOnMousePressed((event) -> {
			if (!isInResizeZone(event)) {
				move = true;

				return;
			}

			resize = true;
			top = isTopZone(event);
			bottom = isBottomZone(event);
			left = isLeftZone(event);
			right = isRightZone(event);
		});
	}

	private void mouseDragged() {
		targetGroup.setOnMouseDragged((event) -> {

			if (!resize && !move) return;

			boolean fixedAspectRatioResize = false;
			double aspectScaleDelta = 0.0;

			if (move) {
				if (config.isPresent() && config.get().inDebugMode() && (event.isControlDown() || event.isShiftDown()))
					return;

				final double deltaX = event.getX() - x;
				final double deltaY = event.getY() - y;

				if (!keepInBounds || (targetGroup.getBoundsInParent().getMinX() + deltaX >= 0
						&& targetGroup.getBoundsInParent().getMaxX() + deltaX <= config.get().getDisplayWidth())) {

					targetGroup.setLayoutX(targetGroup.getLayoutX() + (deltaX * targetGroup.getScaleX()));
				}

				if (!keepInBounds || (targetGroup.getBoundsInParent().getMinY() + deltaY >= 0
						&& targetGroup.getBoundsInParent().getMaxY() + deltaY <= config.get().getDisplayHeight())) {

					targetGroup.setLayoutY(targetGroup.getLayoutY() + (deltaY * targetGroup.getScaleY()));
				}

				if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
					config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
							(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
				}

				return;
			}

			if ((top || bottom) && (left || right) && event.isControlDown()) fixedAspectRatioResize = true;

			if (left || right) {
				double gap; // The gap between the mouse and nearest
				// target edge

				if (right) {
					gap = (event.getX() - targetGroup.getLayoutBounds().getMaxX()) * targetGroup.getScaleX();
				} else {
					gap = (event.getX() - targetGroup.getLayoutBounds().getMinX()) * targetGroup.getScaleX();
				}

				final double currentWidth = targetGroup.getBoundsInParent().getWidth();
				final double newWidth = currentWidth + gap;

				double scaleDelta = (newWidth - currentWidth) / currentWidth;

				if (fixedAspectRatioResize) aspectScaleDelta = scaleDelta;

				final double currentOriginX = targetGroup.getBoundsInParent().getMinX();
				double newOriginX;

				if (right) {
					scaleDelta *= -1.0;
					newOriginX = currentOriginX - ((newWidth - currentWidth) / 2);
				} else {
					newOriginX = currentOriginX + ((newWidth - currentWidth) / 2);
				}

				double originXDelta = newOriginX - currentOriginX;

				if (right) originXDelta *= -1.0;

				final double oldLayoutX = targetGroup.getLayoutX();
				final double oldScaleX = targetGroup.getScaleX();
				final double newScaleX = oldScaleX * (1.0 - scaleDelta);

				// If we scale too small the target can do weird things
				if (newScaleX < 0.001 || Double.isNaN(newScaleX) || Double.isInfinite(newScaleX)) return;

				targetGroup.setLayoutX(targetGroup.getLayoutX() + originXDelta);
				targetGroup.setScaleX(newScaleX);

				if (keepInBounds && (targetGroup.getBoundsInParent().getMinX() <= 0
						|| targetGroup.getBoundsInParent().getMaxX() >= config.get().getDisplayWidth())) {

					// Target went out of bounds, so go back to the old size
					targetGroup.setLayoutX(oldLayoutX);
					targetGroup.setScaleX(oldScaleX);

				} else {
					// Target stayed in bounds so make sure that unresizable
					// target regions stay the same size
					for (final Node n : targetGroup.getChildren()) {
						if (!(n instanceof TargetRegion)) continue;

						final TargetRegion r = (TargetRegion) n;

						if (r.tagExists(Target.TAG_RESIZABLE)
								&& !Boolean.parseBoolean(r.getTag(Target.TAG_RESIZABLE))) {
							n.setScaleX(n.getScaleX() * (1.0 + scaleDelta));
						}
					}
				}
			}

			if (top || bottom) {
				double gap;

				if (bottom) {
					gap = (event.getY() - targetGroup.getLayoutBounds().getMaxY()) * targetGroup.getScaleY();
				} else {
					gap = (event.getY() - targetGroup.getLayoutBounds().getMinY()) * targetGroup.getScaleY();
				}

				final double currentHeight = targetGroup.getBoundsInParent().getHeight();
				double newHeight = currentHeight + gap;

				if (fixedAspectRatioResize) {
					if ((left && bottom) || (right && top)) aspectScaleDelta *= -1.0;

					newHeight = currentHeight + (currentHeight * aspectScaleDelta);
				}

				double scaleDelta = (newHeight - currentHeight) / currentHeight;

				final double currentOriginY = targetGroup.getBoundsInParent().getMinY();
				double newOriginY;

				if (bottom) {
					scaleDelta *= -1.0;
					newOriginY = currentOriginY - ((newHeight - currentHeight) / 2);
				} else {
					newOriginY = currentOriginY + ((newHeight - currentHeight) / 2);
				}

				double originYDelta = newOriginY - currentOriginY;

				if (bottom) originYDelta *= -1.0;

				final double oldLayoutY = targetGroup.getLayoutY();
				final double oldScaleY = targetGroup.getScaleY();
				final double newScaleY = oldScaleY * (1.0 - scaleDelta);

				// If we scale too small the target can do weird things
				if (newScaleY < 0.001 || Double.isNaN(newScaleY) || Double.isInfinite(newScaleY)) return;

				targetGroup.setLayoutY(targetGroup.getLayoutY() + originYDelta);
				targetGroup.setScaleY(newScaleY);

				if (keepInBounds && (targetGroup.getBoundsInParent().getMinY() <= 0
						|| targetGroup.getBoundsInParent().getMaxY() >= config.get().getDisplayHeight())) {

					// Target went out of bounds, so go back to the old size
					targetGroup.setLayoutY(oldLayoutY);
					targetGroup.setScaleY(oldScaleY);
				} else {
					// Target stayed in bounds so make sure that unresizable
					// target regions stay the same size
					for (final Node n : targetGroup.getChildren()) {
						if (!(n instanceof TargetRegion)) continue;

						final TargetRegion r = (TargetRegion) n;

						if (r.tagExists(Target.TAG_RESIZABLE)
								&& !Boolean.parseBoolean(r.getTag(Target.TAG_RESIZABLE))) {
							n.setScaleY(n.getScaleY() * (1.0 + scaleDelta));
						}
					}
				}
			}

			if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
				config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
						(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
			}

			if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
				config.get().getSessionRecorder().get().recordTargetResized(cameraName, this,
						targetGroup.getBoundsInParent().getWidth(), targetGroup.getBoundsInParent().getHeight());
			}
		});
	}

	private void mouseMoved() {
		targetGroup.setOnMouseMoved((event) -> {
			x = event.getX();
			y = event.getY();

			if (isTopZone(event) && isLeftZone(event)) {
				targetGroup.setCursor(Cursor.NW_RESIZE);
			} else if (isTopZone(event) && isRightZone(event)) {
				targetGroup.setCursor(Cursor.NE_RESIZE);
			} else if (isBottomZone(event) && isLeftZone(event)) {
				targetGroup.setCursor(Cursor.SW_RESIZE);
			} else if (isBottomZone(event) && isRightZone(event)) {
				targetGroup.setCursor(Cursor.SE_RESIZE);
			} else if (isTopZone(event)) {
				targetGroup.setCursor(Cursor.N_RESIZE);
			} else if (isBottomZone(event)) {
				targetGroup.setCursor(Cursor.S_RESIZE);
			} else if (isLeftZone(event)) {
				targetGroup.setCursor(Cursor.W_RESIZE);
			} else if (isRightZone(event)) {
				targetGroup.setCursor(Cursor.E_RESIZE);
			} else {
				targetGroup.setCursor(Cursor.DEFAULT);
			}
		});
	}

	private void mouseReleased() {
		targetGroup.setOnMouseReleased((event) -> {
			resize = false;
			move = false;
			targetGroup.setCursor(Cursor.DEFAULT);
		});
	}

	private void keyPressed() {
		targetGroup.setOnKeyPressed((event) -> {
			final double currentWidth = targetGroup.getBoundsInParent().getWidth();
			final double currentHeight = targetGroup.getBoundsInParent().getHeight();

			switch (event.getCode()) {
			case DELETE:
			case BACK_SPACE:
				if (userDeletable && parent.isPresent()) parent.get().removeTarget(this);
				break;

			case LEFT: {
				if (event.isShiftDown()) {
					final double newWidth = currentWidth - SCALE_DELTA;
					final double scaleDelta = (newWidth - currentWidth) / currentWidth;

					targetGroup.setScaleX(targetGroup.getScaleX() * (1.0 - scaleDelta));

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetResized(cameraName, this,
								targetGroup.getBoundsInParent().getWidth(),
								targetGroup.getBoundsInParent().getHeight());
					}
				} else {
					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinX() - MOVEMENT_DELTA >= 0
							&& targetGroup.getBoundsInParent().getMaxX() - MOVEMENT_DELTA <= config.get()
									.getDisplayWidth())) {

						targetGroup.setLayoutX(targetGroup.getLayoutX() - MOVEMENT_DELTA);
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
								(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
					}
				}
			}

				break;

			case RIGHT: {
				if (event.isShiftDown()) {
					final double newWidth = currentWidth + SCALE_DELTA;
					final double scaleDelta = (newWidth - currentWidth) / currentWidth;

					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinX() + (SCALE_DELTA / 2) >= 0
							&& targetGroup.getBoundsInParent().getMaxX() + (SCALE_DELTA / 2) <= config.get()
									.getDisplayWidth())) {
						targetGroup.setScaleX(targetGroup.getScaleX() * (1.0 - scaleDelta));
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetResized(cameraName, this,
								targetGroup.getBoundsInParent().getWidth(),
								targetGroup.getBoundsInParent().getHeight());
					}
				} else {
					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinX() + MOVEMENT_DELTA >= 0
							&& targetGroup.getBoundsInParent().getMaxX() + MOVEMENT_DELTA <= config.get()
									.getDisplayWidth())) {

						targetGroup.setLayoutX(targetGroup.getLayoutX() + MOVEMENT_DELTA);
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
								(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
					}
				}
			}

				break;

			case UP: {
				if (event.isShiftDown()) {
					final double newHeight = currentHeight - SCALE_DELTA;
					final double scaleDelta = (newHeight - currentHeight) / currentHeight;

					targetGroup.setScaleY(targetGroup.getScaleY() * (1.0 - scaleDelta));

					// Scale up proportionally if ctrl is down
					if (event.isControlDown()) {
						final double newWidth = currentWidth - (SCALE_DELTA * (currentWidth / currentHeight));
						final double widthDelta = (newWidth - currentWidth) / currentWidth;

						targetGroup.setScaleX(targetGroup.getScaleX() * (1.0 - widthDelta));
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetResized(cameraName, this,
								targetGroup.getBoundsInParent().getWidth(),
								targetGroup.getBoundsInParent().getHeight());
					}
				} else {
					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinY() - MOVEMENT_DELTA >= 0
							&& targetGroup.getBoundsInParent().getMaxY() - MOVEMENT_DELTA <= config.get()
									.getDisplayHeight())) {

						targetGroup.setLayoutY(targetGroup.getLayoutY() - MOVEMENT_DELTA);
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
								(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
					}
				}
			}

				break;

			case DOWN: {
				if (event.isShiftDown()) {
					final double newHeight = currentHeight + SCALE_DELTA;
					final double scaleDelta = (newHeight - currentHeight) / currentHeight;

					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinY() + (SCALE_DELTA / 2) >= 0
							&& targetGroup.getBoundsInParent().getMaxY() + (SCALE_DELTA / 2) <= config.get()
									.getDisplayHeight())) {
						targetGroup.setScaleY(targetGroup.getScaleY() * (1.0 - scaleDelta));

						// Scale down proportionally if ctrl is down
						if (event.isControlDown()) {
							final double newWidth = currentWidth + (SCALE_DELTA * (currentWidth / currentHeight));
							final double widthDelta = (newWidth - currentWidth) / currentWidth;

							targetGroup.setScaleX(targetGroup.getScaleX() * (1.0 - widthDelta));
						}
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetResized(cameraName, this,
								targetGroup.getBoundsInParent().getWidth(),
								targetGroup.getBoundsInParent().getHeight());
					}
				} else {
					if (!keepInBounds || (targetGroup.getBoundsInParent().getMinY() + MOVEMENT_DELTA >= 0
							&& targetGroup.getBoundsInParent().getMaxY() + MOVEMENT_DELTA <= config.get()
									.getDisplayHeight())) {

						targetGroup.setLayoutY(targetGroup.getLayoutY() + MOVEMENT_DELTA);
					}

					if (config.isPresent() && config.get().getSessionRecorder().isPresent()) {
						config.get().getSessionRecorder().get().recordTargetMoved(cameraName, this,
								(int) targetGroup.getLayoutX(), (int) targetGroup.getLayoutY());
					}
				}
			}

				break;

			default:
				break;
			}
			event.consume();
		});
	}

	private boolean isTopZone(MouseEvent event) {
		return event.getY() < (targetGroup.getLayoutBounds().getMinY() + RESIZE_MARGIN);
	}

	private boolean isBottomZone(MouseEvent event) {
		return event.getY() > (targetGroup.getLayoutBounds().getMaxY() - RESIZE_MARGIN);
	}

	private boolean isLeftZone(MouseEvent event) {
		return event.getX() < (targetGroup.getLayoutBounds().getMinX() + RESIZE_MARGIN);
	}

	private boolean isRightZone(MouseEvent event) {
		return event.getX() > (targetGroup.getLayoutBounds().getMaxX() - RESIZE_MARGIN);
	}

	private boolean isInResizeZone(MouseEvent event) {
		return isTopZone(event) || isBottomZone(event) || isLeftZone(event) || isRightZone(event);
	}

	@Override
	public boolean tagExists(String name) {
		return targetTags.containsKey(name);
	}

	@Override
	public String getTag(String name) {
		return targetTags.get(name);
	}

	@Override
	public Map<String, String> getAllTags() {
		return targetTags;
	}
}