/*
 * Kangaroo Punch MultiPlayer Game Server Mark II
 * Copyright (C) 2020-2021  Scott Duensing
 *
 * This program 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.
 *
 * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */


#include <mysql.h>
#include <pthread.h>
#include <crypt.h>

#include "thirdparty/SHA256/sha256.h"

#include "util.h"
#include "array.h"
#include "console.h"
#include "settings.h"

#include "database.h"


// ***TODO*** Left/Right trim whitespace from returned strings?


#define STATEMENT_MAX  2048


static MYSQL           *_sql   = NULL;
static pthread_mutex_t  _mutex = PTHREAD_MUTEX_INITIALIZER;


static void dbFileInfoSet(DbFileInfoT *info);
static void dbFileSha256Create(char *file, char *buf);


uint8_t dbConnect(char *host, uint16_t port, char *database, char *user, char *password) {
	uint8_t reconnect = 1;

	if (_sql == NULL) {
		_sql = mysql_init(NULL);
		mysql_options(_sql, MYSQL_OPT_RECONNECT, (const void *)&reconnect);
		if (mysql_real_connect(_sql, host, user, password, database, port, NULL, 0) == NULL) {
			logWrite("dbConnect:  %s\n", mysql_error(_sql));
			return FAIL;
		}
		if (pthread_mutex_init(&_mutex, NULL)) {
			logWrite("dbConnect:  SQL mutex creation failed.\n");
			return FAIL;
		}
		return SUCCESS;
	}
	return FAIL;
}


void dbClientConfigRelease(DbClientConfigT **config) {
	DbClientConfigT *c = *config;

	if (c) {
		DEL(c->name);
		DEL(c->data);
		DEL(c);
	}
}


DbClientConfigT **dbClientConfigGet(void) {
	char             statement[STATEMENT_MAX];
	char            *p       = statement;
	MYSQL_RES       *result  = NULL;
	MYSQL_ROW        row     = NULL;
	int              count   = 0;
	DbClientConfigT  *record = NULL;
	DbClientConfigT **config = NULL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	p += sprintf(p, "SELECT name, data FROM client");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbClientConfigGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	result = mysql_store_result(_sql);
	for (count = 0; count < (int)mysql_num_rows(result); count++) {
		if ((row = mysql_fetch_row(result)) == NULL) {
			logWrite("dbClientConfigGet:  %s\n", mysql_error(_sql));
			pthread_mutex_unlock(&_mutex);
			while (arrlen(config) > 0) {
				record = config[0];
				dbClientConfigRelease(&record);
				arrdel(config, 0);
			}
			arrfree(config);
			pthread_mutex_unlock(&_mutex);
			return NULL;
		}

		NEW(DbClientConfigT, record);
		record->name = strdup(row[0]);
		record->data = strdup(row[1]);
		arrput(config, record);
	}

	mysql_free_result(result);

	pthread_mutex_unlock(&_mutex);

	return config;
}


uint8_t dbDisconnect(void) {
	if (_sql) {
		 mysql_close(_sql);
		 _sql = NULL;
		 pthread_mutex_destroy(&_mutex);
		 return SUCCESS;
	}

	return FAIL;
}


DbFileInfoT *dbFileInfoGet(char *vpath) {
	char         statement[STATEMENT_MAX];
	char        *p      = statement;
	MYSQL_RES   *result = NULL;
	MYSQL_ROW    row    = NULL;
	int32_t      count  = 0;
	DbFileInfoT *info   = NULL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	// If they passed in a colon-separated path, flip it to slashes.
	while (vpath[count] != 0) {
		if (vpath[count] == ':') vpath[count] = '/';
		count++;
	}

	p += sprintf(p, "SELECT id, path, length, sha256, modified, authenticated, touched FROM files WHERE path='");
	p += mysql_real_escape_string(_sql, p, vpath, strlen(vpath));
	p += sprintf(p, "'");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbFileInfoGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);
	if (count != 1) {
		mysql_free_result(result);
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	if ((row = mysql_fetch_row(result)) == NULL) {
		logWrite("dbFileInfoGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	NEW(DbFileInfoT, info);
	if (!info) {
		logWrite("dbFileInfoGet:  Unable to allocate DbFileInfoT.\n");
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}
	info->id            = atol(row[0]);
	info->path          = strdup(row[1]);
	info->length        = atol(row[2]);
	info->sha           = strdup(row[3]);
	info->modified      = strdup(row[4]);
	info->authenticated = atoi(row[5]);
	info->touched       = atoi(row[6]);

	mysql_free_result(result);

	pthread_mutex_unlock(&_mutex);

	return info;
}


void dbFileInfoRelease(DbFileInfoT **info) {
	DbFileInfoT *i = *info;

	if (i) {
		DEL(i->path);
		DEL(i->modified);
		DEL(i->sha);
		DEL(i);
	}
}


static void dbFileInfoSet(DbFileInfoT *info) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return;
	}

	// If id is 0, it's a new file to insert.  Otherwise update.
	if (info->id == 0) {
		// New record.
		p += sprintf(p, "INSERT INTO files (path, length, sha256, modified, touched) VALUES ('");
		p += mysql_real_escape_string(_sql, p, info->path, strlen(info->path));
		p += sprintf(p, "', %ld, '", info->length);
		p += mysql_real_escape_string(_sql, p, info->sha, strlen(info->sha));
		p += sprintf(p, "', '");
		p += mysql_real_escape_string(_sql, p, info->modified, strlen(info->modified));
		p += sprintf(p, "', %d)", info->touched);
	} else {
		// Update record.
		p += sprintf(p, "UPDATE files SET length=%ld, sha256='", info->length);
		p += mysql_real_escape_string(_sql, p, info->sha, strlen(info->sha));
		p += sprintf(p, "', modified='");
		p += mysql_real_escape_string(_sql, p, info->modified, strlen(info->modified));
		p += sprintf(p, "', touched=%d WHERE id=%ld", info->touched, info->id);
	}
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbFileInfoSet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return;
	}

	pthread_mutex_unlock(&_mutex);
}


static void dbFileSha256Create(char *file, char *buf) {
	FILE           *f            = NULL;
	char            buffer[1024] = { 0 };
	char            hex[3]       = { 0 };
	size_t          bytes        = 0;
	sha256_context  hash         = { 0 };

	// buf has to be at least 65 bytes.
	buf[0] = 0;

	f = fopen(file, "rb");
	if (!f) return;

	sha256_init(&hash);
	while (bytes = fread(buffer, 1, sizeof(buffer), f), bytes > 0) {
		sha256_hash(&hash, (uint8_t *)buffer, bytes);
	}
	fclose(f);
	sha256_done(&hash, (uint8_t *)buffer);

	for (bytes = 0; bytes < 32; bytes++) {
		sprintf(hex, "%02x", (uint8_t)buffer[bytes]);
		strcat(buf, hex);
	}
	buf[64] = 0;
}


void dbGameRelease(GameT **game) {
	GameT *g = *game;

	if (g) {
		DEL(g->title);
		DEL(g->publisher);
		DEL(g->developer);
		DEL(g->description);
		DEL(g->releaseDate);
		DEL(g->rating);
		DEL(g->series);
		DEL(g->origin);
		DEL(g->shortName);
		DEL(g->worksWith);
		DEL(g);
	}
}


GameT **dbGamesGet(void) {
	char        statement[STATEMENT_MAX];
	char       *p        = statement;
	MYSQL_RES  *result   = NULL;
	MYSQL_ROW   row      = NULL;
	int32_t     count    = 0;
	int32_t     i        = 0;
	GameT     **gameList = NULL;
	GameT      *game     = NULL;
	char        filename[MAX_PATH];
	int32_t     x        = 0;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	p += sprintf(p,
		"SELECT title, publisher, developer, description, releaseDate, rating, series, "
		"origin, shortName, worksWith, type, maxPlayers, joinable "
		"FROM games WHERE active=1");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbGamesGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return NULL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);

	if (count > 0) {
		for (i=0; i<count; i++) {
			if ((row = mysql_fetch_row(result)) == NULL) {
				logWrite("dbGamesGet:  %s\n", mysql_error(_sql));
				mysql_free_result(result);
				while (arrlen(gameList) > 0) {
					game = gameList[0];
					dbGameRelease(&game);
					arrdel(gameList, 0);
				}
				arrfree(gameList);
				pthread_mutex_unlock(&_mutex);
				return NULL;
			}
			NEW(GameT, game);
			game->title       = strdup(row[0]);
			game->publisher   = strdup(row[1]);
			game->developer   = strdup(row[2]);
			game->description = strdup(row[3]);
			game->releaseDate = strdup(row[4]);
			game->rating      = strdup(row[5]);
			game->series      = strdup(row[6]);
			game->origin      = strdup(row[7]);
			game->shortName   = strdup(row[8]);
			game->worksWith   = strdup(row[9]);
			game->type        = GAME_TYPE_UNKNOWN;
			if (!strcasecmp(row[10], "DOOR"))    game->type = GAME_TYPE_DOOR;
			if (!strcasecmp(row[10], "SERIAL"))  game->type = GAME_TYPE_SERIAL;
			if (!strcasecmp(row[10], "IPX"))     game->type = GAME_TYPE_IPX;
			if (!strcasecmp(row[10], "FICTION")) game->type = GAME_TYPE_FICTION;
			game->maxPlayers  = atoi(row[11]);
			game->joinable    = atoi(row[12]);

			// Find screenshot and box image count from filesystem.
			x = 0;
			while (1) {
				snprintf(filename, MAX_PATH, "%s/files/games/%c/%s/screen%d.png", __settingsFile, game->shortName[0], game->shortName, x + 1);
				utilStringToLower(filename);
				if (!utilFileExists(filename)) break;
				x++;
			}
			game->screens = x;
			x = 0;
			while (1) {
				snprintf(filename, MAX_PATH, "%s/files/games/%c/%s/box%d.png", __settingsFile, game->shortName[0], game->shortName, x + 1);
				utilStringToLower(filename);
				if (!utilFileExists(filename)) break;
				x++;
			}
			game->boxes = x;

			arrput(gameList, game);
		}
	}

	mysql_free_result(result);
	pthread_mutex_unlock(&_mutex);

	return gameList;
}


uint8_t dbSettingsStringGet(char *host, char *key, char *value, uint32_t max) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;
	MYSQL_RES *result = NULL;
	MYSQL_ROW  row;
	int        count;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	p += sprintf(p, "SELECT data FROM config WHERE name='");
	p += mysql_real_escape_string(_sql, p, key, strlen(key));
	p += sprintf(p, "' AND host='");
	p += mysql_real_escape_string(_sql, p, host, strlen(host));
	p += sprintf(p, "'");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbConfigValueGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);
	if (count != 1) {
		logWrite("dbConfigValueGet:  Too many rows returned:  %d.\n", count);
		mysql_free_result(result);
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	if ((row = mysql_fetch_row(result)) == NULL) {
		logWrite("dbConfigValueGet:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	strncpy(value, row[0], max);

	mysql_free_result(result);

	pthread_mutex_unlock(&_mutex);

	return SUCCESS;
}


uint8_t dbSettingsValueGet(char *host, char *key, int32_t *value) {
	char temp[DB_CONFIG_ITEM_SIZE] = { 0 };

	if (dbSettingsStringGet(host, key, temp, DB_CONFIG_ITEM_SIZE) == FAIL) return FAIL;

	*value = atoi(temp);

	return SUCCESS;
}


uint8_t dbUpdateFileData(char *file, uint64_t len, char *time) {
	DbFileInfoT   *info         = NULL;
	char           prefix[1064] = { 0 };
	char          *p            = NULL;
	unsigned char *buf[65]      = { 0 };
	FILE          *f            = NULL;

	// This is the prefix we need to remove to match names in the database.
	snprintf(prefix, 1064, "%s/files/", __settingsFile);
	p = strstr(file, prefix);
	if (!p || p != file) return FAIL;
	p += strlen(prefix);

	// If the date or size change, re-calculate the SHA and update the record.
	info = dbFileInfoGet(p);
	if (info) {
		if (info->length != len || strcasecmp(info->modified, time)) {
			// File changed.  Update & fix SHA.
			dbFileSha256Create(file, (char *)buf);
			DEL(info->sha);
			info->sha = strdup((char *)buf);
			DEL(info->modified);
			info->modified = strdup(time);
			info->length   = 0;  // Needs updated.
		}
	} else {
		// File not in database, add it.
		NEW(DbFileInfoT, info);
		if (!info) return FAIL;
		info->id       = 0;  // Indicates a new record.
		info->modified = strdup(time);
		info->path     = strdup(p);
		dbFileSha256Create(file, (char *)buf);
		info->sha      = strdup((char *)buf);
		info->length   = 0;  // Needs updated.
	}

	// Do we need to update the length?
	if (info->length == 0) {
		f = fopen(file, "rb");
		if (f) {
			fseek(f, 0, SEEK_END);
			info->length = ftell(f);
			fclose(f);
		}
	}

	// Every time we check a file, we set "touched" to 1.
	info->touched = 1;

	dbFileInfoSet(info);

	dbFileInfoRelease(&info);

	return SUCCESS;
}


uint8_t dbUpdateFilesFinish(void) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;
	MYSQL_RES *result = NULL;
	MYSQL_ROW  row;
	int32_t    count  = 0;
	uint8_t    status = FAIL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	// We double-check to be sure not everything is zero - that would be bad.
	p += sprintf(p, "SELECT COUNT(id) AS total FROM files WHERE touched=1");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUpdateFilesFinish:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);

	if (count == 1) {
		if ((row = mysql_fetch_row(result)) != NULL) {
			// Once the scan is complete, anything with "touched" set to 0 no longer exists.  Delete the records.
			p = statement;
			p += sprintf(p, "DELETE FROM files WHERE touched=0");
			if (mysql_real_query(_sql, statement, p - statement) != 0) {
				logWrite("dbUpdateFilesFinish:  %s\n", mysql_error(_sql));
				pthread_mutex_unlock(&_mutex);
				return FAIL;
			}
		}
	}

	mysql_free_result(result);

	pthread_mutex_unlock(&_mutex);

	return status;
}


uint8_t dbUpdateFilesStart(void) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	// We start by ensuring the "touched" column is zeroed.
	p += sprintf(p, "UPDATE files SET touched=0");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUpdateFilesStart:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	pthread_mutex_unlock(&_mutex);

	return SUCCESS;
}


uint8_t dbUserCreate(char *first, char *last, char *user, char *pass, char *email) {
	char  statement[STATEMENT_MAX];
	char *p    = statement;
	char *hash = NULL;
	char *salt = NULL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	// Not thread safe.  Protected by SQL _mutex.
	salt = crypt_gensalt(0, 0, 0, 0);
	hash = crypt(pass, salt);

	// We don't pass 'enabled' into the database and instead rely on the default value.
	p += sprintf(p, "INSERT INTO users (first, last, user, pass, email) VALUES ('");
	p += mysql_real_escape_string(_sql, p, first, strlen(first));
	p += sprintf(p, "', '");
	p += mysql_real_escape_string(_sql, p, last, strlen(last));
	p += sprintf(p, "', '");
	p += mysql_real_escape_string(_sql, p, user, strlen(user));
	p += sprintf(p, "', '");
	p += mysql_real_escape_string(_sql, p, hash, strlen(hash));
	p += sprintf(p, "', '");
	p += mysql_real_escape_string(_sql, p, email, strlen(email));
	p += sprintf(p, "')");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUserCreate:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	pthread_mutex_unlock(&_mutex);

	return SUCCESS;
}


uint8_t dbUserEmailExists(char *email) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;
	MYSQL_RES *result = NULL;
	int32_t    count  = 0;
	uint8_t    status = FAIL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	p += sprintf(p, "SELECT id FROM users WHERE email='");
	p += mysql_real_escape_string(_sql, p, email, strlen(email));
	p += sprintf(p, "'");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUserEmailExists:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);

	if (count == 1) status = SUCCESS;
	if ((count != 1) && (count != 0)) logWrite("dbUserEmailExists:  Invalid number of rows returned:  %d.\n", count);

	mysql_free_result(result);
	pthread_mutex_unlock(&_mutex);

	return status;
}


uint8_t dbUserLogin(char *user, char *password) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;
	MYSQL_RES *result = NULL;
	MYSQL_ROW  row;
	int        count;
	uint8_t    status = FAIL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	p += sprintf(p, "SELECT pass FROM users WHERE user='");
	p += mysql_real_escape_string(_sql, p, user, strlen(user));
	p += sprintf(p, "' AND enabled=1");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUserLogin:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);
	if (count != 1) {
		//logWrite("dbUserLogin:  Too many rows returned:  %d.\n", count);
		mysql_free_result(result);
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	if ((row = mysql_fetch_row(result)) == NULL) {
		logWrite("dbUserLogin:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	// Not thread safe.  Protected by SQL _mutex.
	p = crypt(password, row[0]);
	if (strcmp(p, row[0]) == 0) status = SUCCESS;

	mysql_free_result(result);

	pthread_mutex_unlock(&_mutex);

	return status;
}


uint8_t dbUserNameExists(char *user) {
	char       statement[STATEMENT_MAX];
	char      *p      = statement;
	MYSQL_RES *result = NULL;
	int32_t    count  = 0;
	uint8_t    status = FAIL;

	pthread_mutex_lock(&_mutex);

	if (!_sql) {
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	p += sprintf(p, "SELECT id FROM users WHERE user='");
	p += mysql_real_escape_string(_sql, p, user, strlen(user));
	p += sprintf(p, "'");
	if (mysql_real_query(_sql, statement, p - statement) != 0) {
		logWrite("dbUserNameExists:  %s\n", mysql_error(_sql));
		pthread_mutex_unlock(&_mutex);
		return FAIL;
	}

	result = mysql_store_result(_sql);
	count = mysql_num_rows(result);

	if (count == 1) status = SUCCESS;
	if ((count != 1) && (count != 0)) logWrite("dbUserNameExists:  Invalid number of rows returned:  %d.\n", count);

	mysql_free_result(result);
	pthread_mutex_unlock(&_mutex);

	return status;
}



