// This is a common include file for Digital Distortion EmuWeb.
// This is to be placed in sbbs/mods/load

"use strict";

require("http.js", "HTTPRequest");

var gDDEmuWebVersion = "0.92";
var gDDEmuWebVersionDate = "2025-09-20";

// Cached configuration file
var gCfgCacheFilename = system.ctrl_dir + "dd_emu_web_cfg.json";

// Read the dd_emu_web section from modopts.ini
var DDEmuCfgFromModopts = readDDEmuWebSettingsFromModopts();
var gUserIsMobile = userBrowserIsMobile(http_request.header["user-agent"]);

var gSystemImgHeight = 300;
var gWebRootDir = getConfiguredWebRootDir();
var gEmulationWebDirBase = "/emulation/";
var gEmulationROMWebDirBase = gEmulationWebDirBase + "ROMs/";
var gWebROMDir = gWebRootDir + gEmulationROMWebDirBase.substring(1);

// A dictionary that maps the ROM directory names to the No-Intro database filenames
var gROMDirDBMapping = {
	"Amiga": "",
	"Arcade": "Arcade - PC-based (Parent-Clone) (20241207-071923).dat",
	"Atari2600": "Atari - Atari 2600 (Parent-Clone) (20250725-233811).dat",
	"Atari5200": "Atari - Atari 5200 (Parent-Clone) (20250406-021055).dat",
	"Atari7800": "Atari - Atari 7800 (BIN) (Parent-Clone) (20250625-060807).dat",
	"AtariJaguar": "Atari - Atari Jaguar (J64) (Parent-Clone) (20250208-164242).dat",
	"AtariLynx": "Atari - Atari Lynx (LYX) (Parent-Clone) (20250731-011706).dat",
	"C64": "Commodore - Commodore 64 (Parent-Clone) (20250403-112320).dat",
	"C128": "Commodore - Commodore 64 (Parent-Clone) (20250403-112320).dat",
	"ColecoVision": "",
	"CommodorePET": "",
	"CommodorePlus4": "",
	"GB": "Nintendo - Game Boy (Parent-Clone) (20250730-134224).dat",
	"GBA": "Nintendo - Game Boy Advance (Parent-Clone) (20250730-122536).dat",
	"GBC": "Nintendo - Game Boy Color (Parent-Clone) (20250729-175945).dat",
	"N64": "Nintendo - Nintendo 64 (BigEndian) (Parent-Clone) (20250512-080149).dat",
	"NDS": "Nintendo - Nintendo DS (Decrypted) (Parent-Clone) (20250731-131831).dat",
	"NES": "Nintendo - Nintendo Entertainment System (Headered) (Parent-Clone) (20250731-115552).dat",
	"Sega32X": "Sega - 32X (Parent-Clone) (20250126-142831).dat",
	"SegaCD": "",
	"SegaGenesis": "Sega - Mega Drive - Genesis (Parent-Clone) (20250726-190245).dat",
	"SegaGG": "Sega - Game Gear (Parent-Clone) (20250729-221036).dat",
	"SegaMS": "Sega - Master System - Mark III (Parent-Clone) (20250606-123722).dat",
	"SegaSaturn": "",
	"SNES": "Nintendo - Super Nintendo Entertainment System (Parent-Clone) (20250723-095042).dat",
	"SonyPS1": "Sony - PlayStation (PS one Classics) (PSN) (Parent-Clone) (20250715-101144).dat",
	"SonyPSP": "",
	"TGX16": "NEC - PC Engine - TurboGrafx-16 (Parent-Clone) (20250429-114106).dat",
	"VIC20": "Commodore - VIC-20 (Parent-Clone) (20231226-072946).dat",
	"VirtualBoy": "Nintendo - Virtual Boy (Parent-Clone) (20240829-133848).dat"
};

// Stuff for IGDB (Internet Game Database)
var gIGDBAPIHostnameAndPath = "api.igdb.com/v4";
// Stuff for RAWG game database
var gRAWGAPIHostnameAndPath = "api.rawg.io/api";

//////////////////////////////////////////////////////////////
// Functions

// Reads the dd_emu_web section from modopts.ini and returns an object
// with key/value pairs with the settings
function readDDEmuWebSettingsFromModopts()
{
	var cfgObj = {
		open_item_in_new_tab: false,
		additional_desktop_css_filenames: [],
		additional_mobile_css_filenames: [],
		crc32_path: "/usr/bin/crc32",
		md5sum_path: "/usr/bin/md5sum",
		sha1sum_path: "/usr/bin/sha1sum",
		get_metadata_online: false,
		game_metadata_name_max_levenshtein_distance: 15,
		game_metadata_service: "",
		igdb_client_id: "",
		igdb_access_token: "",
		rawg_api_key: ""
	};

	var inFile = new File(system.ctrl_dir + "modopts.ini");
	if (inFile.open("r"))
	{
		var DDEmuWebSection = inFile.iniGetObject("dd_emu_web");
		inFile.close();

		if (typeof(DDEmuWebSection.open_item_in_new_tab) === "boolean")
			cfgObj.open_item_in_new_tab = DDEmuWebSection.open_item_in_new_tab;
		if (typeof(DDEmuWebSection.crc32_path) === "string")
			cfgObj.crc32_path = DDEmuWebSection.crc32_path;
		if (typeof(DDEmuWebSection.md5sum_path) === "string")
			cfgObj.md5sum_path = DDEmuWebSection.md5sum_path;
		if (typeof(DDEmuWebSection.md5sum_path) === "string")
			cfgObj.md5sum_path = DDEmuWebSection.md5sum_path;
		if (typeof(DDEmuWebSection.sha1sum_path) === "string")
			cfgObj.sha1sum_path = DDEmuWebSection.sha1sum_path;
		if (typeof(DDEmuWebSection.get_metadata_online) === "boolean")
			cfgObj.get_metadata_online = DDEmuWebSection.get_metadata_online;
		if (typeof(DDEmuWebSection.game_metadata_name_max_levenshtein_distance) === "number")
		{
			if (DDEmuWebSection.game_metadata_name_max_levenshtein_distance >= 0)
				cfgObj.game_metadata_name_max_levenshtein_distance = DDEmuWebSection.game_metadata_name_max_levenshtein_distance;
		}
		if (typeof(DDEmuWebSection.game_metadata_service) === "string")
			cfgObj.game_metadata_service = DDEmuWebSection.game_metadata_service.toLowerCase();
		if (typeof(DDEmuWebSection.igdb_client_id) === "string")
			cfgObj.igdb_client_id = DDEmuWebSection.igdb_client_id;
		if (typeof(DDEmuWebSection.igdb_access_token) === "string")
			cfgObj.igdb_access_token = DDEmuWebSection.igdb_access_token;
		if (typeof(DDEmuWebSection.rawg_api_key) === "string")
			cfgObj.rawg_api_key = DDEmuWebSection.rawg_api_key;

		var filenameArrPropNames = ["additional_desktop_css_filenames", "additional_mobile_css_filenames"];
		for (var i = 0; i < filenameArrPropNames.length; ++i)
		{
			var propName = filenameArrPropNames[i];
			if (typeof(DDEmuWebSection[propName]) === "string")
			{
				var filenames = DDEmuWebSection[propName].split(",");
				for (var j = 0; j < filenames.length; ++j)
				{
					if (filenames[j].length == 0) continue;
					cfgObj[propName].push(skipsp(truncsp(filenames[j])));
				}
			}
		}
	}

	// If the mobile CSS filenames array is empty, then use the
	// ones in the desktop filename array, if there are any.
	if (cfgObj.additional_mobile_css_filenames.length == 0)
		cfgObj.additional_mobile_css_filenames = cfgObj.additional_desktop_css_filenames;

	// Sanity checking
	if (!file_exists(cfgObj.crc32_path))
		cfgObj.crc32_path = "";
	if (!file_exists(cfgObj.md5sum_path))
		cfgObj.md5sum_path = "";
	if (!file_exists(cfgObj.sha1sum_path))
		cfgObj.sha1sum_path = "";

	return cfgObj;
}

// Returns the web root directory as configured
function getConfiguredWebRootDir()
{
	var webRoot = "";

	// Try reading RootDirectory from the [Web] section of sbbs.ini first
	var sbbsIniFile = new File(system.ctrl_dir + "sbbs.ini");
	if (sbbsIniFile.open("r"))
	{
		webRoot = sbbsIniFile.iniGetValue("Web", "RootDirectory", "");
		sbbsIniFile.close();
	}
	else
	{
		// Try modopts.ini
		var modoptsFile = new File(system.ctrl_dir + "modopts.ini");
		if (modoptsFile.open("r"))
		{
			// It seems older versions of ecweb may have had a web_root, but now it
			// uses web_directory (which doesn't have the 'root' at the end)
			webRoot = modoptsFile.iniGetValue("web", "web_root", "");
			if (webRoot.length == 0)
			{
				webRoot = modoptsFile.iniGetValue("web", "web_directory", "");
				if (webRoot.length > 0)
					webRoot = backslash(webRoot) + "root";
			}
			modoptsFile.close();
		}
	}

	return backslash(webRoot);
}

// Returns whether the user is using a mobile web browser, based on their user agent string.
function userBrowserIsMobile(pUserAgent) {
	return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(pUserAgent)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(pUserAgent.substr(0,4));
}

// Gets a game name from a fully-pathed filename.
// Separate words in the filename must start with an uppercase letter.
function gameNameFromFilename(pFilename)
{
    var gameName = file_getname(pFilename);
    var lastDotIdx = gameName.lastIndexOf(".");
    if (lastDotIdx > -1)
        gameName = gameName.substr(0, lastDotIdx);
    if (gameName.indexOf("_") > -1)
        gameName = gameName.replace(/_/g, " ");
    else
    {
		/*
        gameName = gameName.replace(/([A-Z])/g, ' $1');
        gameName = gameName.replace(/-/g, ' - ');
        //gameName = gameName.replace(/(\D)(\d+)/g, "$1 $2");
        gameName = gameName.replace(/([^0-9])([0-9]+)/g, "$1 $2");
		*/
    }

	// Remove any parenthesized/bracketed text at the end of the name
	var trimStartChars = ["(", "["];
	for (var i = 0; i < trimStartChars.length; ++i)
	{
		var charIdx = gameName.lastIndexOf(trimStartChars[i]);
		if (charIdx > -1)
			gameName = gameName.substring(0, charIdx);
	}

    gameName = gameName.trim();
    return gameName;
}

// Matches a ROM file (or files) with a No-Intro ROM database file (from
// https://no-intro.org).
//
// Parameters:
//  pCfgObj: An object containing configuration properties (crc32_path,
//           md5sum_path, and sha1sum_path, specifying where the crc32,
//           md5sum, and sha1sum executables are)
//  pFilenameOrArrayOfFilenames: A ROM filename (string) or array of filenames
//  pDatabaseFilename: The ame of the database file to use
//
// Return value: An object that maps the indexes of ROM filenames to the game names
//               found in the database.  If the ROM filename is a string specifying
//               a single filename, the index would be 0
function matchROMFilesInDatabase(pCfgObj, pFilenameOrArrayOfFilenames, pDatabaseFilename)
{
	var gameNameDictionary = {};

	var filenames = [];
	if (typeof(pFilenameOrArrayOfFilenames) === "string")
		filenames.push(pFilenameOrArrayOfFilenames);
	else if (Array.isArray(pFilenameOrArrayOfFilenames))
		filenames = filenames.concat(pFilenameOrArrayOfFilenames);

	if (filenames.length == 0)
		return gameNameDictionary;
	if (pCfgObj.crc32_path.length == 0 && pCfgObj.md5sum_path.length == 0 && pCfgObj.sha1sum_path == 0)
		return gameNameDictionary;

	var inFile = new File(pDatabaseFilename);
	if (inFile.open("r"))
	{
		//writeln(format("Opened %s<br/>", pDatabaseFilename)); // Temporary
		//https://docs.microfocus.com/SM/9.50/Hybrid/Content/programming/javascript/reference/javascript_object_xml.htm
		var fileContents = inFile.read(inFile.length);
		inFile.close();
		try
		{
			var xmlData = new XML(fileContents.replace(/<\?xml.*?\?>/, ''));
			if (typeof(xmlData) !== "xml")
				return gameNameDictionary;
			//xmlData = toLocal(xmlData);
			for (var filenameI = 0; filenameI < filenames.length; ++filenameI)
			{
				try
				{
					var ROMFilename = filenames[filenameI];
					//writeln(format("%s<br/>", ROMFilename)); // Temporary
					var fileCRC32 = "";
					var fileMD5 = "";
					var fileSHA1 = "";
					if (pCfgObj.crc32_path.length > 0)
						fileCRC32 = file_crc32(pCfgObj.crc32_path, ROMFilename);
					if (pCfgObj.md5sum_path.length > 0)
						fileMD5 = file_md5sum(pCfgObj.md5sum_path, ROMFilename);
					if (pCfgObj.sha1sum_path.length > 0)
						fileSHA1 = file_sha1sum(pCfgObj.sha1sum_path, ROMFilename);

					for (var prop in xmlData)
					{
						// Game element:
						if (xmlData[prop].hasOwnProperty("description") && xmlData[prop].hasOwnProperty("release") && xmlData[prop].hasOwnProperty("rom"))
						{
							if (fileCRC32 == xmlData[prop].rom..@crc.toString() || fileMD5 == xmlData[prop].rom..@md5.toString() || fileSHA1 == xmlData[prop].rom..@sha1.toString())
							{
								var name = xmlData[prop].description; // xmlData[prop]..@name is returning the ROM filename for some reason
								gameNameDictionary[filenameI] = name;
								break;
							}
						}
					}
				}
				catch (e)
				{
					log(LOG_ERROR, e);
				}
			}
		}
		catch (e)
		{
			log(LOG_ERROR, e);
		}
	}
	/*
	// Temporary
	else
		writeln(format("* Failed to open %s<br/>", pDatabaseFilename)); // Temporary
	// End Temporary
	*/

	return gameNameDictionary;
}

// Gets game information from RAWG. This can take some time to complete.
//
// Parameters:
//  pAPIKey: Your API key for the RAWG game database API
//  pPlatformID: The ID of the platform for the game to find. Can be numeric for a single
//               value or a comma-separated list of platform IDs (string)
//  pGameName: The name of the game to find
//  pExact: Whether to have RAWG do an exact match (boolean)
//  pPrecise: Whether to have RAWG do a precise match (boolean)
//  pMaxLevenshteinDist: The maximum Levenshtein distance to use for matching the name of the game
//  pSortOrder: A string specifying how to sort the results array ("name" or "date")
//
// Return value: An object containing the following properties:
//               results: An array of game results, where each object contains the following properties:
//                        name: The name of the game
//                        releaseDate: The release date of the game (string)
//                        description: The description of the game (including HTML format specifiers)
//                        website: The URL to the game's web site
//                        images: An array of image URLs for the game
function getRAWGGameInfo(pAPIKey, pPlatformID, pGameName, pExact, pPrecise, pMaxLevenshteinDist, pSortOrder)
{
	var retObj = {
		results: []
	};

	var pageSize = 999;
	var ordering = "game";
	var URL = format("https://%s/games?key=%s&ordering=%s&page_size=%d", gRAWGAPIHostnameAndPath, pAPIKey, ordering, pageSize);
	if (typeof(pGameName) === "string" && pGameName.length > 0)
		URL += format("&search=%s", pGameName.replace(/ /g, "%20"));
	if (typeof(pExact) === "boolean")
		URL += format("&search_exact=%s", pExact ? "true" : "false");
	if (typeof(pPrecise) === "boolean")
		URL += format("&search_precise=%s", pPrecise ? "true" : "false");
	if (typeof(pPlatformID) === "number")
		URL += format("&platforms=%d", pPlatformID);
	else if (typeof(pPlatformID) === "string")
		URL += format("&platforms=%s", pPlatformID);

	// Get an abbreviated game name, without any numbers at the end
	const nameRegex = new RegExp("^(.*) [0-9]");
	var abbreviatedGameNameLower = "";
	const matchArray = pGameName.match(nameRegex);
	if (matchArray != null && matchArray.length > 1)
		abbreviatedGameNameLower = matchArray[1].toLowerCase();
	else
		abbreviatedGameNameLower = pGameName.toLowerCase();
	/*
	var spaceIdx = pGameName.indexOf(" ");
	if (spaceIdx > -1)
		abbreviatedGameNameLower = pGameName.substring(0, spaceIdx).toLowerCase();
	else
		abbreviatedGameNameLower = pGameName.toLowerCase();
	*/

	var httpRequest = new HTTPRequest();
	var continueOn = true;
	while (continueOn && !js.terminated)
	{
		if (js.terminated)
		{
			continueOn = false;
			break;
		}

		var response = httpRequest.Get(URL, "", undefined, undefined, "raw");
		if (response != "")
		{
			var respObj = JSON.parse(response);
			if (typeof(respObj) === "object" && Object.keys(respObj).length > 0)
			{
				if (respObj.hasOwnProperty("results") && Array.isArray(respObj.results) && respObj.results.length > 0)
				{
					for (var i = 0; i < respObj.results.length; ++i)
					{
						var gameIsMatch = false;
						if (pMaxLevenshteinDist > 0)
						{
							var gameNameLower = respObj.results[i].name.toLowerCase();
							var levDist = levenshteinDistance(gameNameLower, abbreviatedGameNameLower);
							gameIsMatch = (levDist <= pMaxLevenshteinDist);
						}
						else
						{
							gameIsMatch = (respObj.results[i].name.toLowerCase().indexOf(abbreviatedGameNameLower) == 0);
							/*
							if (respObj.results[i].name.toLowerCase().indexOf(abbreviatedGameNameLower) != 0)
								continue;
							*/
						}
						if (!gameIsMatch)
							continue;

						var gameDetailsURL = format("https://%s/games/%d?key=%s", gRAWGAPIHostnameAndPath, respObj.results[i].id, pAPIKey);
						response = httpRequest.Get(gameDetailsURL, "", undefined, undefined, "raw");
						if (response != "")
						{
							var gameDetailsObj = JSON.parse(response);
							if (typeof(gameDetailsObj) === "object" && Object.keys(gameDetailsObj).length > 0)
							{
								if (gameDetailsObj.hasOwnProperty("description"))
								{
									var gameInfoObj = {
										name: respObj.results[i].name,
										releaseDate: respObj.results[i].released,
										description: gameDetailsObj.description,
										website: "",
										images: []
									};
									if (gameDetailsObj.hasOwnProperty("background_image"))
										gameInfoObj.images.push(gameDetailsObj.background_image);
									if (gameDetailsObj.hasOwnProperty("background_image_additional"))
										gameInfoObj.images.push(gameDetailsObj.background_image_additional);
									if (gameDetailsObj.hasOwnProperty("website"))
										gameInfoObj.website = gameDetailsObj.website;
									retObj.results.push(gameInfoObj);
								}
							}
						}
					}
				}

				// If there's a URL for next results, use it; otherwise, stop looping through results.
				if (respObj.hasOwnProperty("next") && typeof(respObj.next) === "string")
					URL = respObj.next;
				else
					continueOn = false;
			}
			else
				continueOn = false;
		}
		else
			continueOn = false;
	}

	// Sort the results array
	var sortOrder = "name";
	if (typeof(pSortOrder) === "string")
		sortOrder = pSortOrder.toLowerCase();
	if (sortOrder == "name")
	{
		retObj.results.sort(function(pA, pB) {
			if (pA.name < pB.name)
				return -1;
			else if (pA.name == pB.name)
				return 0;
			else // pA.name > pB.name
				return 1;
		});
	}
	else if (sortOrder == "date")
	{
		retObj.results.sort(function(pA, pB) {
			if (pA.releaseDate < pB.releaseDate)
				return -1;
			else if (pA.releaseDate == pB.releaseDate)
				return 0;
			else // pA.releaseDate > pB.releaseDate
				return 1;
		});
	}

	return retObj;
}

// Gets game information matching a game name or where clause from IMGB.
//
// Parameters:
//  pGameNameOrClause: The game name or clause to include as a game search
//  pIGDBSystemID: The ID of the platform/system for the game to find. Can
//                 be a number or an array of numbers.
//  pMaxNumGames: The maximum number of game results to return
//  pClientID: The string to send as the client ID in the request
//  pAccessToken: The access token for your account
//  pSort: The name of the field to request sorting by from IGDB
//
// Return value: An object containing the following properties:
//               results: An array of game results, where each object contains the following properties:
//                        name: The name of the game
//                        releaseDate: The release date of the game (string)
//                        description: The description of the game (including HTML format specifiers)
//                        website: The URL to the game's web site
//                        images: An array of image URLs for the game
function getIGDBGameInfo(pGameNameOrClause, pIGDBSystemID, pMaxNumGames, pClientID, pAccessToken, pSort)
{
	var retObj = {
		results: []
	};

	var URL = format("https://%s/games", gIGDBAPIHostnameAndPath);
	var httpRequest = new HTTPRequest();
	httpRequest.extra_headers = {
		"Client-ID": pClientID,
		"Authorization": "Bearer " + pAccessToken,
		"Accept": "application/json"
	};
	//release_dates
	var data = "fields id,category,cover,name,platforms,summary,release_dates,screenshots,game_type,url,first_release_date;";
	// Sort the game array by name if desired
	if (typeof(pSort) === "boolean" && pSort)
		data += "\r\nsort name;";
	if (pGameNameOrClause.indexOf("where") == 0)
		data += format("\r\n%s*;", pGameNameOrClause);
	else
		data += format("\r\nwhere name = \"%s\"*;", pGameNameOrClause);
	// Adding "where platforms = #;" doesn't seem to filter correctly
	/*
	var systemIDType = typeof(pIGDBSystemID);
	if (systemIDType === "number")
		data += format("\r\nwhere platforms = %d;", pIGDBSystemID);
	else if (systemIDType === "string")
		data += format("\r\nwhere platforms = {%s};", pIGDBSystemID);
	else if (Array.isArray(pIGDBSystemID))
	{
		if (pIGDBSystemID.length > 0)
			data += format("\r\nwhere platforms = {%s};", pIGDBSystemID.join(","));
	}
	*/
	if (typeof(pMaxNumGames) === "number" && pMaxNumGames > 0)
		data += format("\r\nlimit %d;", pMaxNumGames);
	var response =  httpRequest.Post(URL, data, undefined, undefined, "raw");
	//writeln(response + "<br/>"); // Temporary (for debugging)
	if (response != "")
	{
		var respObj = JSON.parse(response);
		if (Array.isArray(respObj) && respObj.length > 0)
		{
			//writeln("# games returned: " + respObj.length + "<br/>"); // Temporary (for debugging)

			// Get game covers
			var coverIDs = [];
			for (var i = 0; i < respObj.length; ++i)
			{
				if (respObj[i].hasOwnProperty("cover"))
					coverIDs.push(respObj[i].cover);
			}
			var gameCovers = getIGDBGameCovers(coverIDs, pClientID, pAccessToken);
			/*
			// Temporary
			writeln(format("- There are %d game covers:<br/>", Object.keys(gameCovers).length));
			for (var coverID in gameCovers)
			{
				writeln(coverID + ":<br/>");
				writeln("<ul>");
				for (var prop in gameCovers[coverID])
					writeln("<li>" + prop + ": " + gameCovers[coverID][prop] + "</li>");
				writeln("</ul>");
			}
			for (var i = 0; i < gameCovers.length; ++i)
				writeln(gameCovers[i].URL + "<br/>");
			// End Temporary
			*/

			var platformIDIsNumber = (typeof(pIGDBSystemID) === "number");
			for (var i = 0; i < respObj.length; ++i)
			{
				var platformIDIdx = -1; // Index of platform ID in the platforms array
				if (platformIDIsNumber && respObj[i].hasOwnProperty("platforms") && Array.isArray(respObj[i].platforms))
					platformIDIdx = respObj[i].platforms.indexOf(pIGDBSystemID);
				// If the platform wasn't found, then skip this game
				if (platformIDIdx < 0)
					continue;

				var gameInfoObj = {
					name: "",
					releaseDate: "",
					releaseDateUNIX: 0,
					description: "",
					website: "",
					images: []
				};
				if (respObj[i].hasOwnProperty("name"))
					gameInfoObj.name = respObj[i].name;
				if (respObj[i].hasOwnProperty("summary"))
					gameInfoObj.description = respObj[i].summary;
				if (respObj[i].hasOwnProperty("url"))
					gameInfoObj.website = respObj[i].url;
				if (respObj[i].hasOwnProperty("cover"))
				{
					var coverImgInfo = getIGDBGameCover(respObj[i].cover, pClientID, pAccessToken);
					if (coverImgInfo.URL.length > 0)
						gameInfoObj.images.push(coverImgInfo.URL);
				}
				if (respObj[i].hasOwnProperty("first_release_date") && typeof(respObj[i].first_release_date) === "number")
				{
					// This is a UNIX timestamp
					gameInfoObj.releaseDateUNIX = respObj[i].first_release_date;
					gameInfoObj.releaseDate = strftime("%Y-%m-%d", respObj[i].first_release_date);
				}
				if (respObj[i].hasOwnProperty("screenshots") && Array.isArray(respObj[i].screenshots) && respObj[i].screenshots > 0)
				{
					for (var j = 0; j < respObj[i].screenshots.length; ++j)
						gameInfoObj.images.push(respObj[i].screenshots[j]);
				}
				/*
				var gameInfoObj = {
					name: "",
					description: "",
					coverImg: {
						URL: "",
						width: 0,
						height: 0
					},
					screenshotURLs: []
				};
				if (respObj[i].hasOwnProperty("name"))
					gameInfoObj.name = respObj[i].name;
				if (respObj[i].hasOwnProperty("summary"))
					gameInfoObj.description = respObj[i].summary;
				if (respObj[i].hasOwnProperty("cover"))
					gameInfoObj.coverImg = getIGDBGameCover(respObj[i].cover, pClientID, pAccessToken);
				if (respObj[i].hasOwnProperty("screenshots") && Array.isArray(respObj[i].screenshots) && respObj[i].screenshots > 0)
				{
					// TODO: Screenshot URLs
					for (var j = 0; j < respObj[i].screenshots.length; ++j)
					{
						//
					}
				}
				*/
				retObj.results.push(gameInfoObj);
			}

			/*
			// Sort the game array by name if desired
			if (typeof(pSort) === "boolean" && pSort)
			{
				retObj.results.sort(function(pA, pB)
				{
					if (pA.name < pB.name)
						return -1;
					else if (pA.name == pB.name)
						return 0;
					else if (pA.name > pB.name)
						return 1;
				});
			}
			*/
		}
	}
	return retObj;
}

// Gets information about a game cover from IGDB
//
// Paramters:
//  pID: The ID of the game to request
//  pClientID: The string to send as the client ID in the request
//  pAccessToken: The access token for your account
//
// Return value: An object containing the following properties:
//               URL: The URL of the game cover
//               width: The width of the game cover image
//               height: The height of the game cover image
function getIGDBGameCover(pID, pClientID, pAccessToken)
{
	var retObj = {
		URL: "",
		width: 0,
		height: 0
	};

	var URL = format("https://%s/covers", gIGDBAPIHostnameAndPath);
	var httpRequest = new HTTPRequest();
	httpRequest.extra_headers = {
		"Client-ID": pClientID,
		"Authorization": "Bearer " + pAccessToken
	};
	var data = "fields game,image_id,url,width,height;";
	data += format("\r\nwhere id=%d;", pID);
	var response =  httpRequest.Post(URL, data, undefined, undefined, "raw");
	if (response != "")
	{
		var respObj = JSON.parse(response);
		if (Array.isArray(respObj) && respObj.length > 0)
		{
			if (respObj[0].hasOwnProperty("url"))
				retObj.URL = respObj[0].url;
			if (respObj[0].hasOwnProperty("width"))
				retObj.width = respObj[0].width;
			if (respObj[0].hasOwnProperty("height"))
				retObj.height = respObj[0].height;
		}
	}

	return retObj;
}

// Returns game covers as an object indexed by cover ID
function getIGDBGameCovers(pIDs, pClientID, pAccessToken)
{
	if (!Array.isArray(pIDs) || pIDs.length == 0)
		return {};

	var gameCovers = {};

	var URL = format("https://%s/covers", gIGDBAPIHostnameAndPath);
	var httpRequest = new HTTPRequest();
	httpRequest.extra_headers = {
		"Client-ID": pClientID,
		"Authorization": "Bearer " + pAccessToken
	};
	var whereClause = "";
	for (var i = 0; i < pIDs.length; ++i)
	{
		if (whereClause.length == 0)
			whereClause = format("where id=%d", pIDs[i]);
		else
			whereClause += format(" | id=%d", pIDs[i]);
	}
	var data = "fields id,game,image_id,url,width,height;";
	data += format("\r\n%s;limit %d;", whereClause, pIDs.length);
	var response =  httpRequest.Post(URL, data, undefined, undefined, "raw");
	if (response != "")
	{
		var respObj = JSON.parse(response);
		if (Array.isArray(respObj))
		{
			var gameCoverObj = {
				URL: "",
				width: 0,
				height: 0
			};
			for (var i = 0; i < respObj.length; ++i)
			{
				if (respObj[i].hasOwnProperty("id"))
				{
					if (respObj[i].hasOwnProperty("url"))
					{
						gameCoverObj.URL = respObj[0].url;
						if (gameCoverObj.URL.indexOf("//") == 0)
							gameCoverObj.URL = gameCoverObj.URL.substring(2);
					}
					if (respObj[i].hasOwnProperty("width"))
						gameCoverObj.width = respObj[0].width;
					if (respObj[i].hasOwnProperty("height"))
						gameCoverObj.height = respObj[0].height;
					gameCovers[respObj[i].id] = gameCoverObj;
				}
			}
		}
	}

	return gameCovers;
}


// Gets game information for a particular game & system and adds it to
// a system object (as created by EmuSoft.xjs). A 'games' property will
// be added to the system object with the game name as the key.
//
// Parameters:
//  pDDEmuWebCfg: The configuration object for DDEmuWeb
//  pSystemObj: An object for a particular system, as created/defined in
//              EmuSoft.xjs. A 'games' property will be added to the
//              system object with the game name as the key.
//  pGameName: The game name, or in the case of using IGDB, a
//             clause for matching the game
//  pMaxNumGames: The maximum number of game results to return
//
// Return value: An object containing the following properties:
//               results: An array of game results, where each object contains the following properties:
//                        name: The name of the game
//                        releaseDate: The release date of the game (string)
//                        description: The description of the game (including HTML format specifiers)
//                        website: The URL to the game's web site
//                        images: An array of image URLs for the game
function getGameInfo(pDDEmuWebCfg, pSystemObj, pGameName, pMaxNumGames)
{
	var retObj = {
		results: []
	};

	if (pSystemObj.rawg_system_id > -1 && pDDEmuWebCfg.rawg_api_key.length > 0)
	{
		var infoRetObj = getRAWGGameInfo(pDDEmuWebCfg.rawg_api_key, pSystemObj.igdb_system_id, pGameName, true, true,
		                                 pDDEmuWebCfg.game_metadata_name_max_levenshtein_distance, "name", false);
		retObj.results = infoRetObj.results;
	}
	if (retObj.results.length == 0 && pSystemObj.igdb_system_id > -1 && pDDEmuWebCfg.igdb_client_id.length > 0 && pDDEmuWebCfg.igdb_access_token.length > 0)
	{
		var infoRetObj = getIGDBGameInfo(pGameName, pSystemObj.igdb_system_id, 1, pDDEmuWebCfg.igdb_client_id, pDDEmuWebCfg.igdb_access_token, false);
		retObj.results = infoRetObj.results;
	}

	return retObj;
}

// Calculates the Levenshtein distance between 2 strings
//
// Parameters:
//  pStr1: The first string to compare
//  pStr2: The second string to compare
//
// Return value: The Levenshtein distance between the two strings (numeric)
function levenshteinDistance(pStr1, pStr2)
{
	if (typeof(pStr1) !== "string" || typeof(pStr2) !== "string")
		return 0;

	//https://www.tutorialspoint.com/levenshtein-distance-in-javascript
	var track = new Array(pStr2.length + 1);
	for (var i = 0; i < pStr2.length + 1; ++i)
	{
		track[i] = new Array(pStr1.length + 1);
		for (var j = 0; j < pStr1.length + 1; ++j)
			track[i][j] = null;
	}

	for (var i = 0; i <= pStr1.length; i += 1)
		track[0][i] = i;
	for (var j = 0; j <= pStr2.length; j += 1)
		track[j][0] = j;
	for (var j = 1; j <= pStr2.length; j += 1)
	{
		for (var i = 1; i <= pStr1.length; i += 1)
		{
			var indicator = pStr1[i - 1] === pStr2[j - 1] ? 0 : 1;
			track[j][i] = Math.min(
				track[j][i - 1] + 1, // deletion
				track[j - 1][i] + 1, // insertion
				track[j - 1][i - 1] + indicator // substitution
			);
		}
	}
	return track[pStr2.length][pStr1.length];
}

function file_crc32(pExecutablePath, pFilename)
{
	var crcVal = "";
	var cmdLine = format("\"%s\" \"%s\"", pExecutablePath, pFilename);
	if (system.platform.toLowerCase() == "win32")
	{
		var execObj = execCmdWithOutput(cmdLine);
		if (execObj.returnCode == 0)
		{
			if (execObj.cmdOutput.length > 0)
				crcVal = execObj.cmdOutput[0];
		}
	}
	else
	{
		var output = system.popen(cmdLine);
		if (output.length > 0)
		{
			crcVal = output[0];
			crcVal = crcVal.replace("\r", "").replace("\n", "");
		}
	}
	return crcVal;
}

function file_md5sum(pExecutablePath, pFilename)
{
	var md5Val = "";
	var cmdLine = format("\"%s\" \"%s\"", pExecutablePath, pFilename);
	if (system.platform.toLowerCase() == "win32")
	{
		var execObj = execCmdWithOutput(cmdLine);
		if (execObj.returnCode == 0)
		{
			if (execObj.cmdOutput.length > 0)
			{
				var splitOutput = execObj.cmdOutput[0].split(" ");
				if (splitOutput.length > 0)
					md5Val = splitOutput[0];
				else
					md5Val = execObj.cmdOutput[0];
			}
		}
	}
	else
	{
		var output = system.popen(cmdLine);
		if (output.length > 0)
		{
			var outputTokens = output[0].split(" ");
			if (outputTokens.length > 0)
				md5Val = outputTokens[0];
		}
	}
	return md5Val;
}

function file_sha1sum(pExecutablePath, pFilename)
{
	var sha1Val = "";
	var cmdLine = format("\"%s\" \"%s\"", pExecutablePath, pFilename);
	if (system.platform.toLowerCase() == "win32")
	{
		var execObj = execCmdWithOutput(cmdLine);
		if (execObj.returnCode == 0)
		{
			if (execObj.cmdOutput.length > 0)
			{
				var splitOutput = execObj.cmdOutput[0].split(" ");
				if (splitOutput.length > 0)
					sha1Val = splitOutput[0];
				else
					sha1Val = execObj.cmdOutput[0];
			}
		}
	}
	else
	{
		var output = system.popen(cmdLine);
		if (output.length > 0)
		{
			var outputTokens = output[0].split(" ");
			if (outputTokens.length > 0)
				sha1Val = outputTokens[0];
		}
	}
	return sha1Val;
}

// This function executes an OS command and returns its output as an
// array of strings.  The reason this function was written is that
// system.popen() is only functional in UNIX; this is useful for the
// same purpose in Windows.
//
// Parameters:
//  pCommand: The command to execute
//
// Return value: An object containing the following properties:
//               returnCode: The return code of the OS command
//               cmdOutput: An array of strings containing the program's output.
function execCmdWithOutput(pCommand)
{
	var retObj = {
		returnCode: 0,
		cmdOutput: []
	};

	if (typeof(pCommand) !== "string" || pCommand.length == 0)
		return retObj;

	// Execute the command and redirect the output to a file in the
	// node's directory.  system.exec() returns the return code that the
	// command returns; generally, 0 means success and non-zero means
	// failure (or an error of some sort).
	const tempFilename = system.node_dir + "cmdOutput_" + randomStr(10) + ".txt";
	retObj.returnCode = system.exec(pCommand + " >" + tempFilename + " 2>&1");
	// Read the temporary file and populate retObj.cmdOutput with its
	// contents.
	var tempFile = new File(tempFilename);
	if (tempFile.open("r"))
	{
		if (tempFile.length > 0)
		{
			var fileLine = null;
			while (!tempFile.eof)
			{
				fileLine = tempFile.readln(2048);

				// fileLine should be a string, but I've seen some cases
				// where it isn't, so check its type.
				if (typeof(fileLine) != "string")
				continue;

				retObj.cmdOutput.push(fileLine);
			}
		}

		tempFile.close();
	}

	// Remove the temporary file, if it exists.
	if (file_exists(tempFilename))
		file_remove(tempFilename);

	return retObj;
}