/*
 * opsu! - an open-source osu! client
 * Copyright (C) 2014, 2015 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;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;

import org.newdawn.slick.Color;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.util.Log;

/**
 * Data type storing parsed data from OSU files.
 */
public class OsuFile implements Comparable<OsuFile> {
	/** Game modes. */
	public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3;

	/** Map of all loaded background images. */
	private static HashMap<OsuFile, Image> bgImageMap = new HashMap<OsuFile, Image>();

	/** Maximum number of cached images before all get erased. */
	private static final int MAX_CACHE_SIZE = 10;

	/** The OSU File object associated with this OsuFile. */
	private File file;

	/**
	 * [General]
	 */

	/** Audio file object. */
	public File audioFilename;

	/** Delay time before music starts (in ms). */
	public int audioLeadIn = 0;

	/** Audio hash (deprecated). */
//	public String audioHash = "";

	/** Start position of music preview (in ms). */
	public int previewTime = -1;

	/** Countdown type (0:disabled, 1:normal, 2:half, 3:double). */
	public byte countdown = 0;

	/** Sound samples ("None", "Normal", "Soft"). */
	public String sampleSet = "";

	/** How often closely placed hit objects will be stacked together. */
	public float stackLeniency = 0.7f;

	/** Game mode (MODE_* constants). */
	public byte mode = MODE_OSU;

	/** Whether the letterbox (top/bottom black bars) appears during breaks. */
	public boolean letterboxInBreaks = false;

	/** Whether the storyboard should be widescreen. */
	public boolean widescreenStoryboard = false;

	/** Whether to show an epilepsy warning. */
	public boolean epilepsyWarning = false;

	/**
	 * [Editor]
	 */

	/** List of editor bookmarks (in ms). */
//	public int[] bookmarks;

	/** Multiplier for "Distance Snap". */
//	public float distanceSpacing = 0f;

	/** Beat division. */
//	public byte beatDivisor = 0;

	/** Size of grid for "Grid Snap". */
//	public int gridSize = 0;

	/** Zoom in the editor timeline. */
//	public int timelineZoom = 0;

	/**
	 * [Metadata]
	 */

	/** Song title. */
	public String title = "", titleUnicode = "";

	/** Song artist. */
	public String artist = "", artistUnicode = "";

	/** Beatmap creator. */
	public String creator = "";

	/** Beatmap difficulty. */
	public String version = "";

	/** Song source. */
	public String source = "";

	/** Song tags (for searching). */
	public String tags = "";

	/** Beatmap ID. */
	public int beatmapID = 0;

	/** Beatmap set ID. */
	public int beatmapSetID = 0;

	/**
	 * [Difficulty]
	 */

	/** HP: Health drain rate (0:easy ~ 10:hard) */
	public float HPDrainRate = 5f;

	/** CS: Size of circles and sliders (0:large ~ 10:small). */
	public float circleSize = 4f;

	/** OD: Affects timing window, spinners, and approach speed (0:easy ~ 10:hard). */
	public float overallDifficulty = 5f;

	/** AR: How long circles stay on the screen (0:long ~ 10:short). */
	public float approachRate = -1f;

	/** Slider movement speed multiplier. */
	public float sliderMultiplier = 1f;

	/** Rate at which slider ticks are placed (x per beat). */
	public float sliderTickRate = 1f;

	/**
	 * [Events]
	 */

	/** Background image file name. */
	public String bg;

	/** Background video file name. */
//	public String video;

	/** All break periods (start time, end time, ...). */
	public ArrayList<Integer> breaks;

	/**
	 * [TimingPoints]
	 */

	/** All timing points. */
	public ArrayList<OsuTimingPoint> timingPoints;

	/** Song BPM range. */
	public int bpmMin = 0, bpmMax = 0;

	/**
	 * [Colours]
	 */

	/** Combo colors (max 8). */
	public Color[] combo;

	/**
	 * [HitObjects]
	 */

	/** All hit objects. */
	public OsuHitObject[] objects;

	/** Number of individual objects. */
	public int
		hitObjectCircle = 0,
		hitObjectSlider = 0,
		hitObjectSpinner = 0;

	/** Last object end time (in ms). */
	public int endTime = -1;

	/**
	 * Destroys all cached background images and resets the cache.
	 */
	public static void clearImageCache() {
		for (Image img : bgImageMap.values()) {
			if (img != null && !img.isDestroyed()) {
				try {
					img.destroy();
				} catch (SlickException e) {
					Log.warn(String.format("Failed to destroy image '%s'.", img.getResourceReference()), e);
				}
			}
		}
		resetImageCache();
	}

	/**
	 * Resets the image cache.
	 * This does NOT destroy images, so be careful of memory leaks!
	 */
	public static void resetImageCache() {
		bgImageMap = new HashMap<OsuFile, Image>();
	}

	/**
	 * Constructor.
	 * @param file the file associated with this OsuFile
	 */
	public OsuFile(File file) {
		this.file = file;
	}

	/**
	 * Returns the associated file object.
	 * @return the File object
	 */
	public File getFile() { return file; }

	/**
	 * Returns the song title.
	 * If configured, the Unicode string will be returned instead.
	 * @return the song title
	 */
	public String getTitle() {
		return (Options.useUnicodeMetadata() && !titleUnicode.isEmpty()) ? titleUnicode : title;
	}

	/**
	 * Returns the song artist.
	 * If configured, the Unicode string will be returned instead.
	 * @return the song artist
	 */
	public String getArtist() {
		return (Options.useUnicodeMetadata() && !artistUnicode.isEmpty()) ? artistUnicode : artist;
	}

	/**
	 * Draws the background associated with the OsuFile.
	 * @param width the container width
	 * @param height the container height
	 * @param alpha the alpha value
	 * @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio
	 * @return true if successful, false if any errors were produced
	 */
	public boolean drawBG(int width, int height, float alpha, boolean stretch) {
		if (bg == null)
			return false;
		try {
			Image bgImage = bgImageMap.get(this);
			if (bgImage == null) {
				if (bgImageMap.size() > MAX_CACHE_SIZE)
					clearImageCache();
				bgImage = new Image(new File(file.getParentFile(), bg).getAbsolutePath());
				bgImageMap.put(this, bgImage);
			}

			int swidth = width;
			int sheight = height;
			if (!stretch) {
				// fit image to screen
				if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height)  // x > y
					sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
				else
					swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
			} else {
				// fill screen while maintaining aspect ratio
				if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height)  // x > y
					swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
				else
					sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
			}
			bgImage = bgImage.getScaledCopy(swidth, sheight);

			bgImage.setAlpha(alpha);
			bgImage.drawCentered(width / 2, height / 2);
		} catch (Exception e) {
			Log.warn(String.format("Failed to get background image '%s'.", bg), e);
			bg = null;  // don't try to load the file again until a restart
			return false;
		}
		return true;
	}

	/**
	 * Compares two OsuFile objects first by overall difficulty, then by total objects.
	 */
	@Override
	public int compareTo(OsuFile that) {
		int cmp = Float.compare(this.overallDifficulty, that.overallDifficulty);
		if (cmp == 0)
			cmp = Integer.compare(
					this.hitObjectCircle + this.hitObjectSlider + this.hitObjectSpinner,
					that.hitObjectCircle + that.hitObjectSlider + that.hitObjectSpinner
			);
		return cmp;
	}

	/**
	 * Returns a formatted string: "Artist - Title [Version]"
	 * @see java.lang.Object#toString()
	 */
	@Override
	public String toString() {
		return String.format("%s - %s [%s]", getArtist(), getTitle(), version);
	}

	/**
	 * Returns the {@link #breaks} field formatted as a string,
	 * or null if the field is null.
	 */
	public String breaksToString() {
		if (breaks == null)
			return null;

		StringBuilder sb = new StringBuilder();
		for (int i : breaks) {
			sb.append(i);
			sb.append(',');
		}
		if (sb.length() > 0)
			sb.setLength(sb.length() - 1);
		return sb.toString();
	}

	/**
	 * Sets the {@link #breaks} field from a string.
	 * @param s the string
	 */
	public void breaksFromString(String s) {
		if (s == null)
			return;

		this.breaks = new ArrayList<Integer>();
		String[] tokens = s.split(",");
		for (int i = 0; i < tokens.length; i++)
			breaks.add(Integer.parseInt(tokens[i]));
	}

	/**
	 * Returns the {@link #timingPoints} field formatted as a string,
	 * or null if the field is null.
	 */
	public String timingPointsToString() {
		if (timingPoints == null)
			return null;

		StringBuilder sb = new StringBuilder();
		for (OsuTimingPoint p : timingPoints) {
			sb.append(p.toString());
			sb.append('|');
		}
		if (sb.length() > 0)
			sb.setLength(sb.length() - 1);
		return sb.toString();
	}

	/**
	 * Sets the {@link #timingPoints} field from a string.
	 * @param s the string
	 */
	public void timingPointsFromString(String s) {
		this.timingPoints = new ArrayList<OsuTimingPoint>();
		if (s == null)
			return;

		String[] tokens = s.split("\\|");
		for (int i = 0; i < tokens.length; i++) {
			try {
				timingPoints.add(new OsuTimingPoint(tokens[i]));
			} catch (Exception e) {
				Log.warn(String.format("Failed to read timing point '%s'.", tokens[i]), e);
			}
		}
		timingPoints.trimToSize();
	}

	/**
	 * Returns the {@link #combo} field formatted as a string,
	 * or null if the field is null or the default combo.
	 */
	public String comboToString() {
		if (combo == null || combo == Utils.DEFAULT_COMBO)
			return null;

		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < combo.length; i++) {
			Color c = combo[i];
			sb.append(c.getRed());
			sb.append(',');
			sb.append(c.getGreen());
			sb.append(',');
			sb.append(c.getBlue());
			sb.append('|');
		}
		if (sb.length() > 0)
			sb.setLength(sb.length() - 1);
		return sb.toString();
	}

	/**
	 * Sets the {@link #combo} field from a string.
	 * @param s the string
	 */
	public void comboFromString(String s) {
		this.combo = Utils.DEFAULT_COMBO;
		if (s == null)
			return;

		LinkedList<Color> colors = new LinkedList<Color>();
		String[] tokens = s.split("\\|");
		for (int i = 0; i < tokens.length; i++) {
			String[] rgb = tokens[i].split(",");
			colors.add(new Color(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2])));
		}
		if (!colors.isEmpty())
			this.combo = colors.toArray(new Color[colors.size()]);
	}
}