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

import itdelatrisu.opsu.ErrorHandler;
import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.ScoreData;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * Handles connections and queries with the scores database.
 */
public class ScoreDB {
	/**
	 * Current database version.
	 * This value should be changed whenever the database format changes.
	 * Add any update queries to the {@link #getUpdateQueries(int)} method.
	 */
	private static final int DATABASE_VERSION = 20140311;

	/**
	 * Returns a list of SQL queries to apply, in order, to update from
	 * the given database version to the latest version.
	 * @param version the current version
	 * @return a list of SQL queries
	 */
	private static List<String> getUpdateQueries(int version) {
		List<String> list = new LinkedList<String>();
		if (version < 20140311)
			list.add("ALTER TABLE scores ADD COLUMN replay TEXT");

		/* add future updates here */

		return list;
	}

	/** Database connection. */
	private static Connection connection;

	/** Score insertion statement. */
	private static PreparedStatement insertStmt;

	/** Score select statement. */
	private static PreparedStatement selectMapStmt, selectMapSetStmt;

	/** Score deletion statement. */
	private static PreparedStatement deleteSongStmt, deleteScoreStmt;

	// This class should not be instantiated.
	private ScoreDB() {}

	/**
	 * Initializes the database connection.
	 */
	public static void init() {
		// create a database connection
		connection = DBController.createConnection(Options.SCORE_DB.getPath());
		if (connection == null)
			return;

		// run any database updates
		updateDatabase();

		// create the database
		createDatabase();

		// prepare sql statements
		try {
			insertStmt = connection.prepareStatement(
				"INSERT INTO scores VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
			);
			selectMapStmt = connection.prepareStatement(
				"SELECT * FROM scores WHERE " +
				"MID = ? AND title = ? AND artist = ? AND creator = ? AND version = ?"
			);
			selectMapSetStmt = connection.prepareStatement(
				"SELECT * FROM scores WHERE " +
				"MSID = ? AND title = ? AND artist = ? AND creator = ? ORDER BY version DESC"
			);
			deleteSongStmt = connection.prepareStatement(
				"DELETE FROM scores WHERE " +
				"MID = ? AND title = ? AND artist = ? AND creator = ? AND version = ?"
			);
			deleteScoreStmt = connection.prepareStatement(
				"DELETE FROM scores WHERE " +
				"timestamp = ? AND MID = ? AND MSID = ? AND title = ? AND artist = ? AND " +
				"creator = ? AND version = ? AND hit300 = ? AND hit100 = ? AND hit50 = ? AND " +
				"geki = ? AND katu = ? AND miss = ? AND score = ? AND combo = ? AND perfect = ? AND mods = ?"
			);
		} catch (SQLException e) {
			ErrorHandler.error("Failed to prepare score statements.", e, true);
		}
	}

	/**
	 * Creates the database, if it does not exist.
	 */
	private static void createDatabase() {
		try (Statement stmt = connection.createStatement()) {
			String sql =
				"CREATE TABLE IF NOT EXISTS scores (" +
					"timestamp INTEGER PRIMARY KEY, " +
					"MID INTEGER, MSID INTEGER, " +
					"title TEXT, artist TEXT, creator TEXT, version TEXT, " +
					"hit300 INTEGER, hit100 INTEGER, hit50 INTEGER, " +
					"geki INTEGER, katu INTEGER, miss INTEGER, " +
					"score INTEGER, " +
					"combo INTEGER, " +
					"perfect BOOLEAN, " +
					"mods INTEGER, " +
					"replay TEXT" +
				");" +
				"CREATE TABLE IF NOT EXISTS info (" +
					"key TEXT NOT NULL UNIQUE, value TEXT" +
				"); " +
				"CREATE INDEX IF NOT EXISTS idx ON scores (MID, MSID, title, artist, creator, version);";
			stmt.executeUpdate(sql);

			// set the version key, if empty
			sql = String.format("INSERT OR IGNORE INTO info(key, value) VALUES('version', %d)", DATABASE_VERSION);
			stmt.executeUpdate(sql);
		} catch (SQLException e) {
			ErrorHandler.error("Could not create score database.", e, true);
		}
	}

	/**
	 * Applies any database updates by comparing the current version to the
	 * stored version.  Does nothing if tables have not been created.
	 */
	private static void updateDatabase() {
		try (Statement stmt = connection.createStatement()) {
			int version = 0;

			// if 'info' table does not exist, assume version 0 and apply all updates
			String sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'info'";
			ResultSet rs = stmt.executeQuery(sql);
			boolean infoExists = rs.isBeforeFirst();
			rs.close();
			if (!infoExists) {
				// if 'scores' table also does not exist, databases not yet created
				sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'scores'";
				ResultSet scoresRS = stmt.executeQuery(sql);
				boolean scoresExists = scoresRS.isBeforeFirst();
				scoresRS.close();
				if (!scoresExists)
					return;
			} else {
				// try to retrieve stored version
				sql = "SELECT value FROM info WHERE key = 'version'";
				ResultSet versionRS = stmt.executeQuery(sql);
				String versionString = (versionRS.next()) ? versionRS.getString(1) : "0";
				versionRS.close();
				try {
					version = Integer.parseInt(versionString);
				} catch (NumberFormatException e) {}
			}

			// database versions match
			if (version >= DATABASE_VERSION)
				return;

			// apply updates
			for (String query : getUpdateQueries(version))
				stmt.executeUpdate(query);

			// update version
			if (infoExists) {
				PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)");
				ps.setString(1, Integer.toString(DATABASE_VERSION));
				ps.executeUpdate();
				ps.close();
			}
		} catch (SQLException e) {
			ErrorHandler.error("Failed to update score database.", e, true);
		}
	}

	/**
	 * Adds the game score to the database.
	 * @param data the GameData object
	 */
	public static void addScore(ScoreData data) {
		if (connection == null)
			return;

		try {
			setStatementFields(insertStmt, data);
			insertStmt.setString(18, data.replayString);
			insertStmt.executeUpdate();
		} catch (SQLException e) {
			ErrorHandler.error("Failed to save score to database.", e, true);
		}
	}

	/**
	 * Deletes the given score from the database.
	 * @param data the score to delete
	 */
	public static void deleteScore(ScoreData data) {
		if (connection == null)
			return;

		try {
			setStatementFields(deleteScoreStmt, data);
			deleteScoreStmt.executeUpdate();
		} catch (SQLException e) {
			ErrorHandler.error("Failed to delete score from database.", e, true);
		}
	}

	/**
	 * Deletes all the scores for the given beatmap from the database.
	 * @param osu the OsuFile object
	 */
	public static void deleteScore(OsuFile osu) {
		if (connection == null)
			return;

		try {
			deleteSongStmt.setInt(1, osu.beatmapID);
			deleteSongStmt.setString(2, osu.title);
			deleteSongStmt.setString(3, osu.artist);
			deleteSongStmt.setString(4, osu.creator);
			deleteSongStmt.setString(5, osu.version);
			deleteSongStmt.executeUpdate();
		} catch (SQLException e) {
			ErrorHandler.error("Failed to delete scores from database.", e, true);
		}
	}

	/**
	 * Sets all statement fields using a given ScoreData object.
	 * @param stmt the statement to set fields for
	 * @param data the score data
	 * @throws SQLException
	 */
	private static void setStatementFields(PreparedStatement stmt, ScoreData data)
			throws SQLException {
		stmt.setLong(1, data.timestamp);
		stmt.setInt(2, data.MID);
		stmt.setInt(3, data.MSID);
		stmt.setString(4, data.title);
		stmt.setString(5, data.artist);
		stmt.setString(6, data.creator);
		stmt.setString(7, data.version);
		stmt.setInt(8, data.hit300);
		stmt.setInt(9, data.hit100);
		stmt.setInt(10, data.hit50);
		stmt.setInt(11, data.geki);
		stmt.setInt(12, data.katu);
		stmt.setInt(13, data.miss);
		stmt.setLong(14, data.score);
		stmt.setInt(15, data.combo);
		stmt.setBoolean(16, data.perfect);
		stmt.setInt(17, data.mods);
	}

	/**
	 * Retrieves the game scores for an OsuFile map.
	 * @param osu the OsuFile
	 * @return all scores for the beatmap, or null if any error occurred
	 */
	public static ScoreData[] getMapScores(OsuFile osu) {
		if (connection == null)
			return null;

		List<ScoreData> list = new ArrayList<ScoreData>();
		try {
			selectMapStmt.setInt(1, osu.beatmapID);
			selectMapStmt.setString(2, osu.title);
			selectMapStmt.setString(3, osu.artist);
			selectMapStmt.setString(4, osu.creator);
			selectMapStmt.setString(5, osu.version);
			ResultSet rs = selectMapStmt.executeQuery();
			while (rs.next()) {
				ScoreData s = new ScoreData(rs);
				list.add(s);
			}
			rs.close();
		} catch (SQLException e) {
			ErrorHandler.error("Failed to read scores from database.", e, true);
			return null;
		}
		return getSortedArray(list);
	}

	/**
	 * Retrieves the game scores for an OsuFile map set.
	 * @param osu the OsuFile
	 * @return all scores for the beatmap set (Version, ScoreData[]),
	 *         or null if any error occurred
	 */
	public static Map<String, ScoreData[]> getMapSetScores(OsuFile osu) {
		if (connection == null)
			return null;

		Map<String, ScoreData[]> map = new HashMap<String, ScoreData[]>();
		try {
			selectMapSetStmt.setInt(1, osu.beatmapSetID);
			selectMapSetStmt.setString(2, osu.title);
			selectMapSetStmt.setString(3, osu.artist);
			selectMapSetStmt.setString(4, osu.creator);
			ResultSet rs = selectMapSetStmt.executeQuery();

			List<ScoreData> list = null;
			String version = "";  // sorted by version, so pass through and check for differences
			while (rs.next()) {
				ScoreData s = new ScoreData(rs);
				if (!s.version.equals(version)) {
					if (list != null)
						map.put(version, getSortedArray(list));
					version = s.version;
					list = new ArrayList<ScoreData>();
				}
				list.add(s);
			}
			if (list != null)
				map.put(version, getSortedArray(list));
			rs.close();
		} catch (SQLException e) {
			ErrorHandler.error("Failed to read scores from database.", e, true);
			return null;
		}
		return map;
	}

	/**
	 * Returns a sorted ScoreData array (in reverse order) from a List.
	 */
	private static ScoreData[] getSortedArray(List<ScoreData> list) {
		ScoreData[] scores = list.toArray(new ScoreData[list.size()]);
		Arrays.sort(scores, Collections.reverseOrder());
		return scores;
	}

	/**
	 * Closes the connection to the database.
	 */
	public static void closeConnection() {
		if (connection == null)
			return;

		try {
			insertStmt.close();
			selectMapStmt.close();
			selectMapSetStmt.close();
			connection.close();
			connection = null;
		} catch (SQLException e) {
			ErrorHandler.error("Failed to close score database.", e, true);
		}
	}
}
