package fr.insa.clavardator.client.db;

import fr.insa.clavardator.client.users.CurrentUser;
import fr.insa.clavardator.client.users.PeerUser;
import fr.insa.clavardator.lib.message.FileMessage;
import fr.insa.clavardator.lib.message.Message;
import fr.insa.clavardator.lib.users.User;
import fr.insa.clavardator.lib.users.UserInformation;
import fr.insa.clavardator.lib.util.ErrorCallback;
import fr.insa.clavardator.lib.util.Log;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.sql.*;
import java.util.ArrayList;
import java.util.Date;

public class DatabaseController {
	private Connection connection;

	public DatabaseController() {
		connect();
	}

	public DatabaseController(boolean test) {
		if (test) {
			connectToTestDb();
		} else {
			connect();
		}
	}

	/**
	 * Connects to the main database
	 */
	private void connect() {
		connectToDatabase("clavardator");
	}

	/**
	 * Connects to the test database.
	 *
	 * @implNote DO NOT USE OUTSIDE OF TESTS
	 */
	private void connectToTestDb() {
		connectToDatabase("clavardator_test");
	}

	/**
	 * Connects to the database of the given name
	 *
	 * @param dbName The database to connect to
	 */
	private void connectToDatabase(String dbName) {
		try {
			Class.forName("org.sqlite.JDBC");
			connection = DriverManager.getConnection("jdbc:sqlite:" + dbName + ".db");
			Log.v(getClass().getSimpleName(), "Opened database '" + dbName + "' successfully");
		} catch (ClassNotFoundException | SQLException e) {
			e.printStackTrace();
		}
	}

	/**
	 * Closes the connection to the current database
	 */
	public void close() {
		try {
			connection.close();
		} catch (SQLException e) {
			e.printStackTrace();
		}
	}

	/**
	 * Creates the table used to store messages
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void createMessageTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "CREATE TABLE IF NOT EXISTS message " +
				"(id            INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
				" timestamp     DATETIME                          NOT NULL, " +
				" sender        TEXT                 						 	NOT NULL, " +
				" recipient     TEXT                  						NOT NULL, " +
				" text          TEXT, " +
				" file_path     TEXT)";

		Log.v(getClass().getSimpleName(), "Creating table message...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	/**
	 * Creates the table used to store users
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void createUserTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "CREATE TABLE IF NOT EXISTS user " +
				"(id            TEXT 				PRIMARY KEY NOT NULL," +
				" username      TINYTEXT                NULLABLE )";

		Log.v(getClass().getSimpleName(), "Creating table user...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	/**
	 * Creates the table used to store users
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void createCurrentUserTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "CREATE TABLE IF NOT EXISTS current_user " +
				"(id            TEXT 				PRIMARY KEY NOT NULL," +
				" username      TINYTEXT                NULLABLE )";

		Log.v(getClass().getSimpleName(), "Creating table current user...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	/**
	 * Creates all needed tables if non-existent
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	public void initTables(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		createMessageTable(() -> createCurrentUserTable(() -> createUserTable(callback, errorCallback), errorCallback), errorCallback);
	}

	/**
	 * Destroys the message table
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void dropMessageTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "DROP TABLE IF EXISTS message";
		Log.v(getClass().getSimpleName(), "Dropping table message...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	/**
	 * Destroys the user table
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void dropUserTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "DROP TABLE IF EXISTS user";
		Log.v(getClass().getSimpleName(), "Dropping table user...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	private void dropCurrentUserTable(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "DROP TABLE IF EXISTS current_user";
		Log.v(getClass().getSimpleName(), "Dropping table current_user...");
		UpdateExecutor executor = new UpdateExecutor(sql, callback, errorCallback);
		executor.start();
	}

	/**
	 * Destroys all tables
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	private void dropTables(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		dropMessageTable(() -> dropUserTable(() -> dropCurrentUserTable(callback, errorCallback), errorCallback), errorCallback);
	}

	/**
	 * Destroys and recreates all tables
	 *
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	public void resetTables(@Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		dropTables(() -> initTables(callback, errorCallback), errorCallback);
	}

	/**
	 * Fetches the list of users for which we already have a chat history
	 *
	 * @param callback      Function called when the request is done
	 * @param errorCallback The function to call on error
	 */
	public void getAllUsers(UsersCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "SELECT * FROM user";

		Log.v(getClass().getSimpleName(), "Fetching users from db... ");

		QueryExecutor executor = new QueryExecutor(sql, res -> {
			ArrayList<User> userList = new ArrayList<>();
			while (res.next()) {
				String id = res.getString("id");
				String username = res.getString("username");
				userList.add(new PeerUser(id, username));
			}
			Log.v(getClass().getSimpleName(), userList.size() + " users fetched");

			callback.onUsersFetched(userList);
		}, errorCallback);
		executor.start();
	}

	public void getCurrentUser(CurrentUserCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "SELECT * FROM current_user ";

		Log.v(getClass().getSimpleName(), "Fetching current_user from db... ");

		QueryExecutor executor = new QueryExecutor(sql, res -> {
			UserInformation user = null;
			int nbRows = 0;
			if (res.next()) {
				String id = res.getString("id");
				String username = res.getString("username");
				user = new UserInformation(id, username);
				nbRows = 1;
			}
			Log.v(getClass().getSimpleName(), nbRows + " users fetched");
			callback.onFetched(user);
		}, errorCallback);
		executor.start();
	}

	/**
	 * Gets the chat history for a given time frame
	 *
	 * @param user1         the user for which to retrieve the history
	 * @param user2         the user for which to retrieve the history
	 * @param from          the starting date
	 * @param to            the ending date
	 * @param callback      Function called when the request is done
	 * @param errorCallback The function to call on error
	 */
	public void getChatHistory(UserInformation user1, UserInformation user2, Date from, Date to, HistoryCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql =
				"SELECT timestamp, sender, recipient, text, file_path " +
						"FROM message WHERE (sender = ? OR recipient = ?)" +
						"		AND timestamp > ? AND timestamp < ? " +
						"ORDER BY timestamp";

		Log.v(getClass().getSimpleName(), "Fetching chat history from db... ");
		executeGetChatHistory(user1, from, to, sql, res -> {
			ArrayList<Message> chatHistory = new ArrayList<>();
			while (res.next()) {
				Date date = new Date(res.getTimestamp("timestamp").getTime());
				String text = res.getString("text");
				String filePath = res.getString("file_path");
				String sId = res.getString("sender");

				final UserInformation sender;
				final UserInformation recipient;
				if (user1.id.equals(sId)) {
					sender = user1;
					recipient = user2;
				} else {
					sender = user2;
					recipient = user1;
				}

				if (filePath == null) {
					chatHistory.add(new Message(sender, recipient, date, text));
				} else {
					try {
						chatHistory.add(new FileMessage(sender, recipient, date, text, filePath));
					} catch (IOException e) {
						Log.e(getClass().getSimpleName(), "Error while opening the file", e);
					}
				}
			}
			Log.v(getClass().getSimpleName(), chatHistory.size() + " messages fetched");
			callback.onHistoryFetched(chatHistory);
		}, errorCallback);
	}

	private void executeGetChatHistory(UserInformation user, Date from, Date to, @Language("SQL") String sql, QueryCallback callback, ErrorCallback errorCallback) {
		try {
			PreparedStatement preparedStatement = connection.prepareStatement(sql);
			preparedStatement.setString(1, user.id);
			preparedStatement.setString(2, user.id);
			preparedStatement.setLong(3, from.getTime());
			preparedStatement.setLong(4, to.getTime());
			QueryExecutor executor = new QueryExecutor(
					preparedStatement,
					callback,
					errorCallback);
			executor.start();
		} catch (SQLException e) {
			Log.e(this.getClass().getSimpleName(), "Could not prepare statement: ", e);
			errorCallback.onError(e);
		}
	}


	/**
	 * Adds a message to the database for this user.
	 * If the user does not exist, we create it.
	 *
	 * @param message       The message to add to the database
	 * @param callback      Function called when the request is done
	 * @param errorCallback The function to call on error
	 */
	public void addMessage(Message message, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		// Insert the correspondent if not already in the database
		Log.v(getClass().getSimpleName(), "Inserting correspondent into db... ");
		UserInformation correspondent;
		if (CurrentUser.getInstance().getId() != null && CurrentUser.getInstance().getId().equals(message.getSender().id)) {
			correspondent = message.getRecipient();
		} else {
			correspondent = message.getSender();
		}
		addUser(correspondent, () -> {
			// Handle messages containing a file
			String filePath = null;
			if (message instanceof FileMessage) {
				filePath = ((FileMessage) message).getPath();
			}
			@Language("SQL") String sql = "INSERT INTO message " +
					"(timestamp, sender, recipient, text, file_path) VALUES (?, ?, ?, ?, ?)";
			Log.v(getClass().getSimpleName(), "Inserting message into db... ");
			executeAddMessage(message, filePath, sql, callback, errorCallback);
		}, errorCallback);
	}

	private void executeAddMessage(Message message, String filePath, @Language("SQL") String sql, @Nullable UpdateCallback callback, ErrorCallback errorCallback) {
		try {
			PreparedStatement preparedStatement = connection.prepareStatement(sql);
			preparedStatement.setLong(1, message.getDate().getTime());
			preparedStatement.setString(2, message.getSender().id);
			preparedStatement.setString(3, message.getRecipient().id);
			preparedStatement.setString(4, message.getText());
			preparedStatement.setString(5, filePath);
			UpdateExecutor executor = new UpdateExecutor(
					preparedStatement,
					callback,
					errorCallback);
			executor.start();
		} catch (SQLException e) {
			Log.e(this.getClass().getSimpleName(), "Could not prepare statement: ", e);
			errorCallback.onError(e);
		}
	}

	/**
	 * Inserts the given user if not existing
	 *
	 * @param user          The user information to store
	 * @param callback      The function to call on success
	 * @param errorCallback The function to call on error
	 */
	public void addUser(UserInformation user, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql;
		if (user.getUsername() != null) {
			sql = "INSERT OR IGNORE INTO user (id, username) VALUES (?, ?)";
		} else {
			sql = "INSERT OR IGNORE INTO user (id) VALUES (?)";
		}
		Log.v(getClass().getSimpleName(), "Adding user to db: " + user.id + " / " + user.getUsername());
		executeAddUser(user, sql, callback, errorCallback);
	}

	public void addCurrentUser(UserInformation user, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql;
		if (user.getUsername() != null) {
			sql = "INSERT OR IGNORE INTO current_user (id, username) VALUES (?, ?)";
		} else {
			sql = "INSERT OR IGNORE INTO current_user (id) VALUES (?)";
		}
		Log.v(getClass().getSimpleName(), "Adding current_user to db: " + user.id + " / " + user.getUsername());
		executeAddUser(user, sql, callback, errorCallback);
	}

	private void executeAddUser(UserInformation user, @Language("SQL") String sql, @Nullable UpdateCallback callback, ErrorCallback errorCallback) {
		try {
			PreparedStatement preparedStatement = connection.prepareStatement(sql);
			preparedStatement.setString(1, user.id);
			if (user.getUsername() != null) {
				preparedStatement.setString(2, user.getUsername());
			}
			UpdateExecutor executor = new UpdateExecutor(
					preparedStatement,
					callback,
					errorCallback);
			executor.start();
		} catch (SQLException e) {
			Log.e(this.getClass().getSimpleName(), "Could not prepare statement: ", e);
			errorCallback.onError(e);
		}
	}

	public void updateUsername(UserInformation user, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "UPDATE user SET username = ? where id = ?";
		executeUsernameUpdate(user, sql, callback, errorCallback);
	}

	public void updateCurrentUsername(UserInformation user, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
		@Language("SQL") String sql = "UPDATE current_user SET username = ? where id = ?";
		executeUsernameUpdate(user, sql, callback, errorCallback);
	}

	private void executeUsernameUpdate(UserInformation user, @Language("SQL") String sql, @Nullable UpdateCallback callback, ErrorCallback errorCallback) {
		try {
			PreparedStatement preparedStatement = connection.prepareStatement(sql);
			preparedStatement.setString(1, user.getUsername());
			preparedStatement.setString(2, user.id);
			UpdateExecutor executor = new UpdateExecutor(
					preparedStatement,
					callback,
					errorCallback);
			executor.start();
		} catch (SQLException e) {
			Log.e(this.getClass().getSimpleName(), "Could not prepare statement: ", e);
			errorCallback.onError(e);
		}
	}

	public interface UsersCallback {
		void onUsersFetched(ArrayList<User> users);
	}

	public interface CurrentUserCallback {
		void onFetched(UserInformation user);
	}

	public interface HistoryCallback {
		void onHistoryFetched(ArrayList<Message> history);
	}

	public interface UpdateCallback {
		void onUpdateExecuted();
	}

	private interface QueryCallback {
		void onQueryExecuted(ResultSet resultSet) throws SQLException;
	}

	private class UpdateExecutor extends Thread {
		private final String sqlQuery;
		private final PreparedStatement preparedStatement;
		private final UpdateCallback callback;
		private final ErrorCallback errorCallback;

		/**
		 * Constructs a thread that executes an update on the database
		 *
		 * @param sqlQuery      The query to execute
		 * @param callback      The function to call on success
		 * @param errorCallback The function to call on error
		 */
		public UpdateExecutor(@Language("SQL") String sqlQuery, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
			this.preparedStatement = null;
			this.sqlQuery = sqlQuery;
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		public UpdateExecutor(PreparedStatement preparedStatement, @Nullable DatabaseController.UpdateCallback callback, ErrorCallback errorCallback) {
			this.preparedStatement = preparedStatement;
			this.sqlQuery = null;
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			try {
				int rowsModified;
				if (preparedStatement == null) {
					Statement statement = connection.createStatement();
					rowsModified = statement.executeUpdate(sqlQuery);
					statement.close();
				} else {
					rowsModified = preparedStatement.executeUpdate();
					preparedStatement.close();
				}
				Log.v(getClass().getSimpleName(), rowsModified + " rows modified");

				if (callback != null) {
					callback.onUpdateExecuted();
				}
			} catch (SQLException e) {
				Log.e(this.getClass().getSimpleName(), "Error executing update: ", e);
				errorCallback.onError(e);
			}
		}
	}

	private class QueryExecutor extends Thread {
		private final String sqlQuery;
		private final PreparedStatement preparedStatement;
		private final QueryCallback callback;
		private final ErrorCallback errorCallback;

		/**
		 * Constructs a thread that executes an update on the database
		 *
		 * @param sqlQuery      The query to execute
		 * @param callback      The function to call on success
		 * @param errorCallback The function to call on error
		 */
		public QueryExecutor(@Language("SQL") String sqlQuery, @Nullable QueryCallback callback, ErrorCallback errorCallback) {
			this.preparedStatement = null;
			this.sqlQuery = sqlQuery;
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		public QueryExecutor(PreparedStatement preparedStatement, @Nullable QueryCallback callback, ErrorCallback errorCallback) {
			this.preparedStatement = preparedStatement;
			this.sqlQuery = null;
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			try {
				Statement statement = null;
				final ResultSet resultSet;
				if (preparedStatement == null) {
					statement = connection.createStatement();
					resultSet = statement.executeQuery(sqlQuery);
				} else {
					resultSet = preparedStatement.executeQuery();
				}
				if (callback != null) {
					callback.onQueryExecuted(resultSet);
				}
				resultSet.close();
				if (statement != null) {
					statement.close();
				}
				if (preparedStatement != null) {
					preparedStatement.close();
				}
			} catch (SQLException e) {
				Log.e(this.getClass().getSimpleName(), "Error executing update: ", e);
				errorCallback.onError(e);
			}
		}
	}
}
