/*
 * 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.replay;

import itdelatrisu.opsu.ErrorHandler;
import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.io.OsuReader;
import itdelatrisu.opsu.io.OsuWriter;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.CharBuffer;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import lzma.streams.LzmaOutputStream;

import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream;
import org.newdawn.slick.util.Log;

/**
 * Captures osu! replay data.
 * https://osu.ppy.sh/wiki/Osr_%28file_format%29
 *
 * @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/)
 */
public class Replay {
	/** The associated file. */
	private File file;

	/** Whether or not the replay data has been loaded from the file. */
	public boolean loaded = false;

	/** The game mode. */
	public byte mode;

	/** Game version when the replay was created. */
	public int version;

	/** Beatmap MD5 hash. */
	public String beatmapHash;

	/** The player's name. */
	public String playerName;

	/** Replay MD5 hash. */
	public String replayHash;

	/** Hit result counts. */
	public short hit300, hit100, hit50, geki, katu, miss;

	/** The score. */
	public int score;

	/** The max combo. */
	public short combo;

	/** Whether or not a full combo was achieved. */
	public boolean perfect;

	/** Game mod bitmask. */
	public int mods;

	/** Life frames. */
	public LifeFrame[] lifeFrames;

	/** The time when the replay was created. */
	public Date timestamp;

	/** Length of the replay data. */
	public int replayLength;

	/** Replay frames. */
	public ReplayFrame[] frames;

	/** Seed. (?) */
	public int seed;

	/** Seed string. */
	private static final String SEED_STRING = "-12345";

	/**
	 * Empty constructor.
	 */
	public Replay() {}

	/**
	 * Constructor.
	 * @param file the file to load from
	 */
	public Replay(File file) {
		this.file = file;
	}

	/**
	 * Loads the replay data.
	 * @throws IOException failure to load the data
	 */
	public void load() throws IOException {
		if (loaded)
			return;

		OsuReader reader = new OsuReader(file);
		loadHeader(reader);
		loadData(reader);
		reader.close();
		loaded = true;
	}

	/**
	 * Loads the replay header data.
	 * @param reader the associated reader
	 * @throws IOException
	 */
	private void loadHeader(OsuReader reader) throws IOException {
		this.mode = reader.readByte();
		this.version = reader.readInt();
		this.beatmapHash = reader.readString();
		this.playerName = reader.readString();
		this.replayHash = reader.readString();
		this.hit300 = reader.readShort();
		this.hit100 = reader.readShort();
		this.hit50 = reader.readShort();
		this.geki = reader.readShort();
		this.katu = reader.readShort();
		this.miss = reader.readShort();
		this.score = reader.readInt();
		this.combo = reader.readShort();
		this.perfect = reader.readBoolean();
		this.mods = reader.readInt();
	}

	/**
	 * Loads the replay data.
	 * @param reader the associated reader
	 * @throws IOException
	 */
	private void loadData(OsuReader reader) throws IOException {
		// life data
		String[] lifeData = reader.readString().split(",");
		List<LifeFrame> lifeFrameList = new ArrayList<LifeFrame>(lifeData.length);
		for (String frame : lifeData) {
			String[] tokens = frame.split("\\|");
			if (tokens.length < 2)
				continue;
			try {
				int time = Integer.parseInt(tokens[0]);
				float percentage = Float.parseFloat(tokens[1]);
				lifeFrameList.add(new LifeFrame(time, percentage));
			} catch (NumberFormatException e) {
				Log.warn(String.format("Failed to load life frame: '%s'", frame), e);
			}
		}
		this.lifeFrames = lifeFrameList.toArray(new LifeFrame[lifeFrameList.size()]);

		// timestamp
		this.timestamp = reader.readDate();

		// LZMA-encoded replay data
		this.replayLength = reader.readInt();
		if (replayLength > 0) {
			LZMACompressorInputStream lzma = new LZMACompressorInputStream(reader.getInputStream());
			String[] replayFrames = Utils.convertStreamToString(lzma).split(",");
			lzma.close();
			List<ReplayFrame> replayFrameList = new ArrayList<ReplayFrame>(replayFrames.length);
			int lastTime = 0;
			for (String frame : replayFrames) {
				if (frame.isEmpty())
					continue;
				String[] tokens = frame.split("\\|");
				if (tokens.length < 4)
					continue;
				try {
					if (tokens[0].equals(SEED_STRING)) {
						seed = Integer.parseInt(tokens[3]);
						continue;
					}
					int timeDiff = Integer.parseInt(tokens[0]);
					int time = timeDiff + lastTime;
					float x = Float.parseFloat(tokens[1]);
					float y = Float.parseFloat(tokens[2]);
					int keys = Integer.parseInt(tokens[3]);
					replayFrameList.add(new ReplayFrame(timeDiff, time, x, y, keys));
					lastTime = time;
				} catch (NumberFormatException e) {
					Log.warn(String.format("Failed to parse frame: '%s'", frame), e);
				}
			}
			this.frames = replayFrameList.toArray(new ReplayFrame[replayFrameList.size()]);
		}
	}

	/**
	 * Saves the replay data to a file in the replays directory.
	 */
	public void save() {
		// create replay directory
		File dir = Options.getReplayDir();
		if (!dir.isDirectory()) {
			if (!dir.mkdir()) {
				ErrorHandler.error("Failed to create replay directory.", null, false);
				return;
			}
		}

		// write file in new thread
		final File file = new File(dir, String.format("%s.osr", getReplayFilename()));
		new Thread() {
			@Override
			public void run() {
				try (FileOutputStream out = new FileOutputStream(file)) {
					OsuWriter writer = new OsuWriter(out);

					// header
					writer.write(mode);
					writer.write(version);
					writer.write(beatmapHash);
					writer.write(playerName);
					writer.write(replayHash);
					writer.write(hit300);
					writer.write(hit100);
					writer.write(hit50);
					writer.write(geki);
					writer.write(katu);
					writer.write(miss);
					writer.write(score);
					writer.write(combo);
					writer.write(perfect);
					writer.write(mods);

					// life data
					StringBuilder sb = new StringBuilder();
					if (lifeFrames != null) {
						NumberFormat nf = new DecimalFormat("##.##");
						for (int i = 0; i < lifeFrames.length; i++) {
							LifeFrame frame = lifeFrames[i];
							sb.append(String.format("%d|%s,",
									frame.getTime(), nf.format(frame.getPercentage())));
						}
					}
					writer.write(sb.toString());

					// timestamp
					writer.write(timestamp);

					// LZMA-encoded replay data
					if (frames != null && frames.length > 0) {
						// build full frame string
						NumberFormat nf = new DecimalFormat("###.#####");
						sb = new StringBuilder();
						for (int i = 0; i < frames.length; i++) {
							ReplayFrame frame = frames[i];
							sb.append(String.format("%d|%s|%s|%d,",
									frame.getTimeDiff(), nf.format(frame.getX()),
									nf.format(frame.getY()), frame.getKeys()));
						}
						sb.append(String.format("%s|0|0|%d", SEED_STRING, seed));

						// get bytes from string
						CharsetEncoder encoder = StandardCharsets.US_ASCII.newEncoder();
						CharBuffer buffer = CharBuffer.wrap(sb);
						byte[] bytes = encoder.encode(buffer).array();

						// compress data
						ByteArrayOutputStream bout = new ByteArrayOutputStream();
						LzmaOutputStream compressedOut = new LzmaOutputStream.Builder(bout).useMediumDictionarySize().build();
						try {
							compressedOut.write(bytes);
						} catch (IOException e) {
							// possible OOM: https://github.com/jponge/lzma-java/issues/9
							ErrorHandler.error("LZMA compression failed (possible out-of-memory error).", e, true);
						}
						compressedOut.close();
						bout.close();

						// write to file
						byte[] compressed = bout.toByteArray();
						writer.write(compressed.length);
						writer.write(compressed);
					} else
						writer.write(0);

					writer.close();
				} catch (IOException e) {
					ErrorHandler.error("Could not save replay data.", e, true);
				}
			}
		}.start();
	}

	/**
	 * Returns the file name of where the replay should be saved and loaded,
	 * or null if the required fields are not set.
	 */
	public String getReplayFilename() {
		if (replayHash == null)
			return null;

		return String.format("%s-%d%d%d%d%d%d",
				replayHash, hit300, hit100, hit50, geki, katu, miss);
	}

	@Override
	public String toString() {
		final int LINE_SPLIT = 5;
		final int MAX_LINES = LINE_SPLIT * 10;

		StringBuilder sb = new StringBuilder();
		sb.append("File: "); sb.append(file.getName()); sb.append('\n');
		sb.append("Mode: "); sb.append(mode); sb.append('\n');
		sb.append("Version: "); sb.append(version); sb.append('\n');
		sb.append("Beatmap hash: "); sb.append(beatmapHash); sb.append('\n');
		sb.append("Player name: "); sb.append(playerName); sb.append('\n');
		sb.append("Replay hash: "); sb.append(replayHash); sb.append('\n');
		sb.append("Hits: ");
		sb.append(hit300); sb.append(' ');
		sb.append(hit100); sb.append(' ');
		sb.append(hit50); sb.append(' ');
		sb.append(geki); sb.append(' ');
		sb.append(katu); sb.append(' ');
		sb.append(miss); sb.append('\n');
		sb.append("Score: "); sb.append(score); sb.append('\n');
		sb.append("Max combo: "); sb.append(combo); sb.append('\n');
		sb.append("Perfect: "); sb.append(perfect); sb.append('\n');
		sb.append("Mods: "); sb.append(mods); sb.append('\n');
		sb.append("Life data ("); sb.append(lifeFrames.length); sb.append(" total):\n");
		for (int i = 0; i < lifeFrames.length && i < MAX_LINES; i++) {
			if (i % LINE_SPLIT == 0)
				sb.append('\t');
			sb.append(lifeFrames[i]);
			sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
		}
		sb.append('\n');
		sb.append("Timestamp: "); sb.append(timestamp); sb.append('\n');
		sb.append("Replay length: "); sb.append(replayLength); sb.append('\n');
		if (frames != null) {
			sb.append("Frames ("); sb.append(frames.length); sb.append(" total):\n");
			for (int i = 0; i < frames.length && i < MAX_LINES; i++) {
				if (i % LINE_SPLIT == 0)
					sb.append('\t');
				sb.append(frames[i]);
				sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
			}
			sb.append('\n');
		}
		sb.append("Seed: "); sb.append(seed); sb.append('\n');
		return sb.toString();
	}
}
