/*
 * opsu! - an open-source osu! client
 * Copyright (C) 2014-2017 Jeffrey Han
 *
 * opsu! 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.
 *
 * opsu! 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 opsu!.  If not, see <http://www.gnu.org/licenses/>.
 */

package itdelatrisu.opsu.objects;

import itdelatrisu.opsu.GameData;
import itdelatrisu.opsu.GameData.HitObjectType;
import itdelatrisu.opsu.GameImage;
import itdelatrisu.opsu.GameMod;
import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
import itdelatrisu.opsu.beatmap.HitObject;
import itdelatrisu.opsu.objects.curves.Vec2f;
import itdelatrisu.opsu.options.Options;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.ui.Colors;

import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Graphics;
import org.newdawn.slick.Image;

/**
 * Data type representing a spinner object.
 */
public class Spinner implements GameObject {
	/** Container dimensions. */
	private static int width, height;

	/** The map's overall difficulty value. */
	private static float overallDifficulty = 5f;

	/** The number of rotation velocities to store. */
	private int maxStoredDeltaAngles;

	/** The amount of time, in milliseconds, before another velocity is stored. */
	private static final float DELTA_UPDATE_TIME = 1000 / 60f;

	/** Angle mod multipliers: "auto" (477rpm), "spun out" (287rpm) */
	private static final float
		AUTO_MULTIPLIER = 1 / 20f,         // angle = 477/60f * delta/1000f * TWO_PI;
		SPUN_OUT_MULTIPLIER = 1 / 33.25f;  // angle = 287/60f * delta/1000f * TWO_PI;

	/** Maximum angle difference. */
	private static final float MAX_ANG_DIFF = DELTA_UPDATE_TIME * AUTO_MULTIPLIER; // ~95.3

	/** PI constants. */
	private static final float
		TWO_PI  = (float) (Math.PI * 2),
		HALF_PI = (float) (Math.PI / 2);

	/** The associated HitObject. */
	private HitObject hitObject;

	/** The associated Game object. */
	private Game game;

	/** The associated GameData object. */
	private GameData data;

	/** The last rotation angle. */
	private float lastAngle = 0f;

	/** The current number of rotations. */
	private float rotations = 0f;

	/** The current rotation to draw. */
	private float drawRotation = 0f;

	/** The total number of rotations needed to clear the spinner. */
	private float rotationsNeeded;

	/** The remaining amount of time that was not used. */
	private float deltaOverflow;

	/** The sum of all the velocities in storedVelocities. */
	private float sumDeltaAngle = 0f;

	/** Array holding the most recent rotation velocities. */
	private float[] storedDeltaAngle;

	/** True if the mouse cursor is pressed. */
	private boolean isSpinning;

	/** Current index of the stored velocities in rotations/second. */
	private int deltaAngleIndex = 0;

	/** The remaining amount of the angle that was not used. */
	private float deltaAngleOverflow = 0;

	/** The RPM that is drawn to the screen. */
	private int drawnRPM = 0;

	/**
	 * Initializes the Spinner data type with images and dimensions.
	 * @param container the game container
	 * @param difficulty the map's overall difficulty value
	 */
	public static void init(GameContainer container, float difficulty) {
		width  = container.getWidth();
		height = container.getHeight();
		overallDifficulty = difficulty;
	}

	/**
	 * Constructor.
	 * @param hitObject the associated HitObject
	 * @param game the associated Game object
	 * @param data the associated GameData object
	 */
	public Spinner(HitObject hitObject, Game game, GameData data) {
		this.hitObject = hitObject;
		this.game = game;
		this.data = data;

/*
		1 beat = 731.707317073171ms
			RPM at frame X with spinner Y beats long
				10	20	30	40	50	60 <frame#
		1.00	306	418	457	470
		1.25	323	424	459	471	475
		1.5		305	417	456	470	475	477
		1.75	322	417	456	471	475
		2.00	304	410	454	469	474	476
		2.25	303	410	451	467	474	476
		2.50	303	417	456	470	475	476
		2.75	302	416	456	470	475	476
		3.00	301	416	456	470	475		<-- ~2sec
		4.00	274	414	453	470	475
		5.00	281	409	454	469	475
		6.00	232	392	451	467	472	476
		6.25	193	378	443	465
		6.50	133	344	431	461
		6.75	85	228	378	435	463	472	<-- ~5sec
		7.00	53	154	272	391	447
		8.00	53	154	272	391	447
		9.00	53	154	272	400	450
		10.00	53	154	272	400	450
		15.00	53	154	272	391	444	466
		20.00	61	154	272	400	447
		25.00	53	154	272	391	447	466
		^beats
*/
		// TODO not correct at all, but close enough?
		// <2sec ~ 12 ~ 200ms
		// >5sec ~ 48 ~ 800ms

		final int minVel = 12;
		final int maxVel = 48;
		final int minTime = 2000;
		final int maxTime = 5000;
		maxStoredDeltaAngles = Utils.clamp((hitObject.getEndTime() - hitObject.getTime() - minTime)
				* (maxVel - minVel) / (maxTime - minTime) + minVel, minVel, maxVel);
		storedDeltaAngle = new float[maxStoredDeltaAngles];

		// calculate rotations needed
		float spinsPerMinute = 100 + (overallDifficulty * 15);
		rotationsNeeded = spinsPerMinute * (hitObject.getEndTime() - hitObject.getTime()) / 60000f;
	}

	@Override
	public void draw(Graphics g, int trackPosition) {
		// only draw spinners shortly before start time
		int timeDiff = hitObject.getTime() - trackPosition;
		final int fadeInTime = game.getFadeInTime();
		if (timeDiff - fadeInTime > 0)
			return;

		boolean spinnerComplete = (rotations >= rotationsNeeded);
		float alpha = Utils.clamp(1 - (float) timeDiff / fadeInTime, 0f, 1f);

		// darken screen
		if (Options.getSkin().isSpinnerFadePlayfield()) {
			float oldAlpha = Colors.BLACK_ALPHA.a;
			if (timeDiff > 0)
				Colors.BLACK_ALPHA.a *= alpha;
			g.setColor(Colors.BLACK_ALPHA);
			g.fillRect(0, 0, width, height);
			Colors.BLACK_ALPHA.a = oldAlpha;
		}

		// rpm
		Image rpmImg = GameImage.SPINNER_RPM.getImage();
		rpmImg.setAlpha(alpha);
		rpmImg.drawCentered(width / 2f, height - rpmImg.getHeight() / 2f);
		if (timeDiff < 0)
			data.drawSymbolString(Integer.toString(drawnRPM), (width + rpmImg.getWidth() * 0.95f) / 2f,
					height - data.getScoreSymbolImage('0').getHeight() * 1.025f, 1f, 1f, true);

		// spinner meter (subimage)
		Image spinnerMetre = GameImage.SPINNER_METRE.getImage();
		int spinnerMetreY = (spinnerComplete) ? 0 : (int) (spinnerMetre.getHeight() * (1 - (rotations / rotationsNeeded)));
		Image spinnerMetreSub = spinnerMetre.getSubImage(
				0, spinnerMetreY,
				spinnerMetre.getWidth(), spinnerMetre.getHeight() - spinnerMetreY
		);
		spinnerMetreSub.setAlpha(alpha);
		spinnerMetreSub.draw(0, height - spinnerMetreSub.getHeight());

		// main spinner elements
		GameImage.SPINNER_CIRCLE.getImage().setAlpha(alpha);
		GameImage.SPINNER_CIRCLE.getImage().setRotation(drawRotation * 360f);
		GameImage.SPINNER_CIRCLE.getImage().drawCentered(width / 2, height / 2);
		if (!GameMod.HIDDEN.isActive()) {
			float approachScale = 1 - Utils.clamp(((float) timeDiff / (hitObject.getTime() - hitObject.getEndTime())), 0f, 1f);
			Image approachCircleScaled = GameImage.SPINNER_APPROACHCIRCLE.getImage().getScaledCopy(approachScale);
			approachCircleScaled.setAlpha(alpha);
			approachCircleScaled.drawCentered(width / 2, height / 2);
		}
		GameImage.SPINNER_SPIN.getImage().setAlpha(alpha);
		GameImage.SPINNER_SPIN.getImage().drawCentered(width / 2, height * 3 / 4);

		if (spinnerComplete) {
			GameImage.SPINNER_CLEAR.getImage().drawCentered(width / 2, height / 4);
			int extraRotations = (int) (rotations - rotationsNeeded);
			if (extraRotations > 0)
				data.drawSymbolNumber(extraRotations * 1000, width / 2, height * 2 / 3, 1f, 1f);
		}
	}

	/**
	 * Calculates and sends the spinner hit result.
	 * @return the hit result (GameData.HIT_* constants)
	 */
	private int hitResult() {
		// TODO: verify ratios
		int result;
		float ratio = rotations / rotationsNeeded;
		if (ratio >= 1.0f || GameMod.AUTO.isActive() || GameMod.AUTOPILOT.isActive() || GameMod.SPUN_OUT.isActive()) {
			result = GameData.HIT_300;
			if (!Options.isGameplaySoundDisabled())
				SoundController.playSound(SoundEffect.SPINNEROSU);
		} else if (ratio >= 0.9f)
			result = GameData.HIT_100;
		else if (ratio >= 0.75f)
			result = GameData.HIT_50;
		else
			result = GameData.HIT_MISS;

		data.sendHitResult(hitObject.getEndTime(), result, width / 2, height / 2,
				Color.transparent, true, hitObject, HitObjectType.SPINNER, true, 0, null, false);
		return result;
	}

	@Override
	public boolean mousePressed(int x, int y, int trackPosition) {
		lastAngle = (float) Math.atan2(x - (height / 2), y - (width / 2));
		return false;
	}

	@Override
	public boolean update(int delta, int mouseX, int mouseY, boolean keyPressed, int trackPosition) {
		// end of spinner
		if (trackPosition > hitObject.getEndTime()) {
			hitResult();
			return true;
		}

		// game button is released
		if (isSpinning && !(keyPressed || GameMod.RELAX.isActive()))
			isSpinning = false;

		// spin automatically
		// http://osu.ppy.sh/wiki/FAQ#Spinners

		deltaOverflow += delta;

		float angleDiff = 0;
		if (GameMod.AUTO.isActive()) {
			angleDiff = delta * AUTO_MULTIPLIER;
			isSpinning = true;
		} else if (GameMod.SPUN_OUT.isActive() || GameMod.AUTOPILOT.isActive()) {
			angleDiff = delta * SPUN_OUT_MULTIPLIER;
			isSpinning = true;
		} else {
			float angle = (float) Math.atan2(mouseY - (height / 2), mouseX - (width / 2));

			// set initial angle to current mouse position to skip first click
			if (!isSpinning && (keyPressed || GameMod.RELAX.isActive())) {
				lastAngle = angle;
				isSpinning = true;
				return false;
			}

			angleDiff = angle - lastAngle;
			if (Math.abs(angleDiff) > 0.01f)
				lastAngle = angle;
			else
				angleDiff = 0;
		}

		// make angleDiff the smallest angle change possible
		// (i.e. 1/4 rotation instead of 3/4 rotation)
		if (angleDiff < -Math.PI)
			angleDiff += TWO_PI;
		else if (angleDiff > Math.PI)
			angleDiff -= TWO_PI;

		// may be a problem at higher frame rate due to floating point round off
		if (isSpinning)
			deltaAngleOverflow += angleDiff;

		while (deltaOverflow >= DELTA_UPDATE_TIME) {
			// spin caused by the cursor
			float deltaAngle = 0;
			if (isSpinning) {
				deltaAngle = deltaAngleOverflow * DELTA_UPDATE_TIME / deltaOverflow;
				deltaAngleOverflow -= deltaAngle;
				deltaAngle = Utils.clamp(deltaAngle, -MAX_ANG_DIFF, MAX_ANG_DIFF);
			}
			sumDeltaAngle -= storedDeltaAngle[deltaAngleIndex];
			sumDeltaAngle += deltaAngle;
			storedDeltaAngle[deltaAngleIndex++] = deltaAngle;
			deltaAngleIndex %= storedDeltaAngle.length;
			deltaOverflow -= DELTA_UPDATE_TIME;

			float rotationAngle = sumDeltaAngle / maxStoredDeltaAngles;
			rotationAngle = Utils.clamp(rotationAngle, -MAX_ANG_DIFF, MAX_ANG_DIFF);
			float rotationPerSec = rotationAngle * (1000 / DELTA_UPDATE_TIME) / TWO_PI;

			drawnRPM = (int) (Math.abs(rotationPerSec * 60));

			rotate(rotationAngle);
		}

		//TODO may need to update 1 more time when the spinner ends?
		return false;
	}

	@Override
	public void updatePosition() {}

	@Override
	public Vec2f getPointAt(int trackPosition) {
		// get spinner time
		int timeDiff;
		float x = hitObject.getScaledX(), y = hitObject.getScaledY();
		if (trackPosition <= hitObject.getTime())
			timeDiff = 0;
		else if (trackPosition >= hitObject.getEndTime())
			timeDiff = hitObject.getEndTime() - hitObject.getTime();
		else
			timeDiff = trackPosition - hitObject.getTime();

		// calculate point
		float multiplier = (GameMod.AUTO.isActive()) ? AUTO_MULTIPLIER : SPUN_OUT_MULTIPLIER;
		float angle = (timeDiff * multiplier) - HALF_PI;
		final float r = height / 10f;
		return new Vec2f((float) (x + r * Math.cos(angle)), (float) (y + r * Math.sin(angle)));
	}

	@Override
	public int getEndTime() { return hitObject.getEndTime(); }

	/**
	 * Rotates the spinner by an angle.
	 * @param angle the angle to rotate (in radians)
	 */
	private void rotate(float angle) {
		drawRotation += angle / TWO_PI;
		angle = Math.abs(angle);
		float newRotations = rotations + (angle / TWO_PI);

		// added one whole rotation...
		if (Math.floor(newRotations) > rotations) {
			if (newRotations > rotationsNeeded)  // extra rotations
				data.sendSpinnerSpinResult(GameData.HIT_SPINNERBONUS);
			else
				data.sendSpinnerSpinResult(GameData.HIT_SPINNERSPIN);
		}

		rotations = newRotations;
	}

	@Override
	public void reset() {
		deltaAngleIndex = 0;
		sumDeltaAngle = 0;
		for (int i = 0; i < storedDeltaAngle.length; i++)
			storedDeltaAngle[i] = 0;
		drawRotation = 0;
		rotations = 0;
		deltaOverflow = 0;
		isSpinning = false;
	}
}