#!/usr/bin/env node

// CLI for Storage System
// Copyright (c) 2015 Joseph Huckaby
// Released under the MIT License

var path = require('path');
var cp = require('child_process');
var os = require('os');
var fs = require('fs');
var async = require('async');
var bcrypt = require('bcrypt-node');

var Args = require('pixl-args');
var Tools = require('pixl-tools');
var StandaloneStorage = require('pixl-server-storage/standalone');

// chdir to the proper server root dir
process.chdir( path.dirname( __dirname ) );

// load app's config file
var config = require('../conf/config.json');

// shift commands off beginning of arg array
var argv = JSON.parse( JSON.stringify(process.argv.slice(2)) );
var commands = [];
while (argv.length && !argv[0].match(/^\-/)) {
	commands.push( argv.shift() );
}

// now parse rest of cmdline args, if any
var args = new Args( argv, {
	debug: false,
	verbose: false,
	quiet: false
} );
args = args.get(); // simple hash

// copy debug flag into config (for standalone)
config.Storage.debug = args.debug;

var print = function(msg) {
	// print message to console
	if (!args.quiet) process.stdout.write(msg);
};
var verbose = function(msg) {
	// print only in verbose mode
	if (args.verbose) print(msg);
};
var warn = function(msg) {
	// print to stderr unless quiet
	if (!args.quiet) process.stderr.write(msg);
};
var verbose_warn = function(msg) {
	// verbose print to stderr unless quiet
	if (args.verbose && !args.quiet) process.stderr.write(msg);
};

if (config.uid && (process.getuid() != 0)) {
	print( "ERROR: Must be root to use this script.\n" );
	process.exit(1);
}

// determine server hostname
var hostname = (process.env['HOSTNAME'] || process.env['HOST'] || os.hostname()).toLowerCase();

// find the first external IPv4 address
var ip = '';
var ifaces = os.networkInterfaces();
var addrs = [];
for (var key in ifaces) {
	if (ifaces[key] && ifaces[key].length) {
		Array.from(ifaces[key]).forEach( function(item) { addrs.push(item); } );
	}
}
var addr = Tools.findObject( addrs, { family: 'IPv4', internal: false } );
if (addr && addr.address && addr.address.match(/^\d+\.\d+\.\d+\.\d+$/)) {
	ip = addr.address;
}
else {
	print( "ERROR: Could not determine server's IP address.\n" );
	process.exit(1);
}

// util.isArray is DEPRECATED??? Nooooooooode!
var isArray = Array.isArray || util.isArray;

// prevent logging transactions to STDOUT
config.Storage.log_event_types = {};

// allow APPNAME_key env vars to override config
var env_regex = new RegExp( "^CRONICLE_(.+)$" );
for (var env_key in process.env) {
	if (env_key.match(env_regex)) {
		var env_path = RegExp.$1.trim().replace(/^_+/, '').replace(/_+$/, '').replace(/__/g, '/');
		var env_value = process.env[env_key].toString();
		
		// massage value into various types
		if (env_value === 'true') env_value = true;
		else if (env_value === 'false') env_value = false;
		else if (env_value.match(/^\-?\d+$/)) env_value = parseInt(env_value);
		else if (env_value.match(/^\-?\d+\.\d+$/)) env_value = parseFloat(env_value);
		
		Tools.setPath(config, env_path, env_value);
	}
}

// construct standalone storage server
var storage = new StandaloneStorage(config.Storage, function(err) {
	if (err) throw err;
	// storage system is ready to go
	
	// become correct user
	if (config.uid && (process.getuid() == 0)) {
		verbose( "Switching to user: " + config.uid + "\n" );
		process.setuid( config.uid );
	}
	
	// custom job data expire handler
	storage.addRecordType( 'cronicle_job', {
		'delete': function(key, value, callback) {
			storage.delete( key, function(err) {
				storage.delete( key + '/log.txt.gz', function(err) {
					callback();
				} ); // delete
			} ); // delete
		}
	} );
	
	// process command
	var cmd = commands.shift();
	verbose("\n");
	
	switch (cmd) {
		case 'setup':
		case 'install':
			// setup new master server
			var setup = require('../conf/setup.json');
			
			// make sure this is only run once
			storage.get( 'global/users', function(err) {
				if (!err) {
					print( "Storage has already been set up.  There is no need to run this command again.\n\n" );
					process.exit(1);
				}
				
				async.eachSeries( setup.storage,
					function(params, callback) {
						verbose( "Executing: " + JSON.stringify(params) + "\n" );
						// [ "listCreate", "global/users", { "page_size": 100 } ]
						var func = params.shift();
						params.push( callback );
						
						// massage a few params
						if (typeof(params[1]) == 'object') {
							var obj = params[1];
							if (obj.created) obj.created = Tools.timeNow(true);
							if (obj.modified) obj.modified = Tools.timeNow(true);
							if (obj.regexp && (obj.regexp == '_HOSTNAME_')) obj.regexp = '^(' + Tools.escapeRegExp( hostname ) + ')$';
							if (obj.hostname && (obj.hostname == '_HOSTNAME_')) obj.hostname = hostname;
							if (obj.ip && (obj.ip == '_IP_')) obj.ip = ip;
						}
						
						// call storage directly
						storage[func].apply( storage, params );
					},
					function(err) {
						if (err) throw err;
						
						print("\n");
						print( "Setup completed successfully!\n" );
						print( "This server ("+hostname+") has been added as the single primary master server.\n" );
						print( "An administrator account has been created with username 'admin' and password 'admin'.\n" );
						print( "You should now be able to start the service by typing: '/opt/cronicle/bin/control.sh start'\n" );
						print( "Then, the web interface should be available at: http://"+hostname+":"+config.WebServer.http_port+"/\n" );
						print( "Please allow for up to 60 seconds for the server to become master.\n\n" );
						
						storage.shutdown( function() { process.exit(0); } );
					}
				);
			} );
		break;
		
		case 'admin':
			// create or replace admin account
			// Usage: ./storage-cli.js admin USERNAME PASSWORD [EMAIL]
			var username = commands.shift();
			var password = commands.shift();
			var email = commands.shift() || 'admin@localhost';
			if (!username || !password) {
				print( "\nUsage: bin/storage-cli.js admin USERNAME PASSWORD [EMAIL]\n\n" );
				process.exit(1);
			}
			if (!username.match(/^[\w\-\.]+$/)) {
				print( "\nERROR: Username must contain only alphanumerics, dash and period.\n\n" );
				process.exit(1);
			}
			username = username.toLowerCase();
			
			var user = {
				username: username,
				password: password,
				full_name: "Administrator",
				email: email
			};
			
			user.active = 1;
			user.created = user.modified = Tools.timeNow(true);
			user.salt = Tools.generateUniqueID( 64, user.username );
			user.password = bcrypt.hashSync( user.password + user.salt );
			user.privileges = { admin: 1 };
			
			storage.put( 'users/' + username, user, function(err) {
				if (err) throw err;
				print( "\nAdministrator '"+username+"' created successfully.\n" );
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'get':
		case 'fetch':
		case 'view':
		case 'cat':
			// get storage key
			// Usage: ./storage-cli.js get users/jhuckaby
			var key = commands.shift();
			storage.get( key, function(err, data) {
				if (err) throw err;
				if (storage.isBinaryKey(key)) print( data.toString() + "\n" );
				else print( ((typeof(data) == 'object') ? JSON.stringify(data, null, "\t") : data) + "\n" );
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'put':
		case 'save':
		case 'store':
			// put storage key (read data from STDIN)
			// Usage: cat USER.json | ./storage-cli.js put users/jhuckaby
			var key = commands.shift();
			var json_raw = '';
			var rl = require('readline').createInterface({ input: process.stdin });
			rl.on('line', function(line) { json_raw += line; });
			rl.on('close', function() {
				print( "Writing record from STDIN: " + key + "\n" );
				
				var data = null;
				try { data = JSON.parse(json_raw); }
				catch (err) {
					warn( "Failed to parse JSON for key: " + key + ": " + err + "\n" );
					process.exit(1);
				}
				
				storage.put( key, data, function(err) {
					if (err) {
						warn( "Failed to store record: " + key + ": " + err + "\n" );
						process.exit(1);
					}
					print("Record successfully saved: "+key+"\n");
					
					storage.shutdown( function() { process.exit(0); } );
				} );
			});
		break;
		
		case 'edit':
		case 'vi':
			var key = commands.shift();
			
			if ((cmd == 'edit') && !process.env.EDITOR) {
				warn( "No EDITOR environment variable is set.\n" );
				process.exit(1);
			}
			
			storage.get( key, function(err, data) {
				if (err) data = {};
				print("Spawning editor to edit record: " + key + "\n");
				
				// save to local temp file
				var temp_file = path.join( os.tmpdir(), 'cli-temp-' + process.pid + '.json' );
				fs.writeFileSync( temp_file, JSON.stringify(data, null, "\t") + "\n" );
				var stats = fs.statSync( temp_file );
				var old_mod = Math.floor( stats.mtime.getTime() / 1000 );
				
				// spawn vi but inherit terminal
				var child = cp.spawn( (cmd == 'vi') ? 'vi' : process.env.EDITOR, [temp_file], {
					stdio: 'inherit'
				} );
				child.on('exit', function (e, code) {
					var stats = fs.statSync( temp_file );
					var new_mod = Math.floor( stats.mtime.getTime() / 1000 );
					if (new_mod != old_mod) {
						print("Saving new data back into record: "+key+"\n");
						
						var json_raw = fs.readFileSync( temp_file, { encoding: 'utf8' } );
						fs.unlinkSync( temp_file );
						
						var data = JSON.parse( json_raw );
						
						storage.put( key, data, function(err, data) {
							if (err) throw err;
							print("Record successfully saved with your changes: "+key+"\n");
							
							storage.shutdown( function() { process.exit(0); } );
						} );
					}
					else {
						fs.unlinkSync( temp_file );
						print("File has not been changed, record was not touched: "+key+"\n");
						
						storage.shutdown( function() { process.exit(0); } );
					}
				} );
				
			} ); // got data
		break;
		
		case 'delete':
			// delete storage key
			// Usage: ./storage-cli.js delete users/jhuckaby
			var key = commands.shift();
			storage.delete( key, function(err, data) {
				if (err) throw err;
				print("Record '"+key+"' deleted successfully.\n");
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'list_create':
			// create new list
			// Usage: ./storage-cli.js list_create key
			var key = commands.shift();
			storage.listCreate( key, null, function(err) {
				if (err) throw err;
				print("List created successfully: " + key + "\n");
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'list_pop':
			// pop item off end of list
			// Usage: ./storage-cli.js list_pop key
			var key = commands.shift();
			storage.listPop( key, function(err, item) {
				if (err) throw err;
				print("Item popped off list: " + key + ": " + JSON.stringify(item, null, "\t") + "\n");
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'list_get':
			// fetch items from list
			// Usage: ./storage-cli.js list_get key idx len
			var key = commands.shift();
			var idx = parseInt( commands.shift() || 0 );
			var len = parseInt( commands.shift() || 0 );
			storage.listGet( key, idx, len, function(err, items) {
				if (err) throw err;
				print("Got " + items.length + " items.\n");
				print("Items from list: " + key + ": " + JSON.stringify(items, null, "\t") + "\n");
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'list_info':
			// fetch info about list
			// Usage: ./storage-cli.js list_info key
			var key = commands.shift();
			
			storage.listGetInfo( key, function(err, list) {
				if (err) throw err;
				print("List Header: " + key + ": " + JSON.stringify(list, null, "\t") + "\n\n");
				var page_idx = list.first_page;
				var item_idx = 0;
				async.whilst(
					function() { return page_idx <= list.last_page; },
					function(callback) {
						// load each page
						storage._listLoadPage(key, page_idx++, false, function(err, page) {
							if (err) return callback(err);
							print("Page " + Math.floor(page_idx - 1) + ": " + page.items.length + " items\n");
							callback();
						} ); // page loaded
					},
					function(err) {
						// all pages iterated
						if (err) throw err;
						print("\n");
						
						storage.shutdown( function() { process.exit(0); } );
					} // pages complete
				); // whilst
			} );
		break;
		
		case 'list_delete':
			// delete list
			// Usage: ./storage-cli.js list_delete key
			var key = commands.shift();
			storage.listDelete( key, null, function(err) {
				if (err) throw err;
				print("List deleted successfully: " + key + "\n");
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'maint':
		case 'maintenance':
			// perform daily maintenance, specify date or defaults to current day
			// Usage: ./storage-cli.js maint 2015-05-31
			storage.runMaintenance( commands.shift(), function() {
				print( "Daily maintenance completed successfully.\n" );
				print("\n");
				
				storage.shutdown( function() { process.exit(0); } );
			} );
		break;
		
		case 'export':
			// export all storage data (except completed jobs, sessions)
			var file = commands.shift();
			export_data(file);
		break;
		
		case 'import':
			// import storage data from file
			var file = commands.shift();
			import_data(file);
		break;
		
		default:
			print("Unknown command: " + cmd + "\n");
			storage.shutdown( function() { process.exit(0); } );
		break;
		
	} // switch
});

function export_data(file) {
	// export data to file or stdout (except for completed jobs, logs, and sessions)
	// one record per line: KEY - JSON
	var stream = file ? fs.createWriteStream(file) : process.stdout;
	
	// file header (for humans)
	var file_header = "# Cronicle Data Export v1.0\n" + 
		"# Hostname: " + hostname + "\n" + 
		"# Date/Time: " + (new Date()).toString() + "\n" + 
		"# Format: KEY - JSON\n\n";
	
	stream.write( file_header );
	verbose_warn( file_header );
	
	if (file) verbose_warn("Exporting to file: " + file + "\n\n");
	
	// need to handle users separately, as they're stored as a list + individual records
	storage.listEach( 'global/users',
		function(item, idx, callback) {
			var username = item.username;
			var key = 'users/' + username.toString().toLowerCase().replace(/\W+/g, '');
			verbose_warn( "Exporting user: " + username + "\n" );
			
			storage.get( key, function(err, user) {
				if (err) {
					// user deleted?
					warn( "\nFailed to fetch user: " + key + ": " + err + "\n\n" );
					return callback();
				}
				
				stream.write( key + ' - ' + JSON.stringify(user) + "\n", 'utf8', callback );
			} ); // get
		},
		function(err) {
			// ignoring errors here
			// proceed to the rest of the lists
			async.eachSeries(
				[
					'global/users',
					'global/plugins',
					'global/categories',
					'global/server_groups',
					'global/schedule',
					'global/servers',
					'global/api_keys'
				],
				function(list_key, callback) {
					// first get the list header
					verbose_warn( "Exporting list: " + list_key + "\n" );
					
					storage.get( list_key, function(err, list) {
						if (err) return callback( new Error("Failed to fetch list: " + list_key + ": " + err) );
						
						stream.write( list_key + ' - ' + JSON.stringify(list) + "\n" );
						
						// now iterate over all the list pages
						var page_idx = list.first_page;
						
						async.whilst(
							function() { return page_idx <= list.last_page; },
							function(callback) {
								// load each page
								var page_key = list_key + '/' + page_idx;
								page_idx++;
								
								verbose_warn( "Exporting list page: " + page_key + "\n");
								
								storage.get(page_key, function(err, page) {
									if (err) return callback( new Error("Failed to fetch list page: " + page_key + ": " + err) );
									
									// write page data
									stream.write( page_key + ' - ' + JSON.stringify(page) + "\n", 'utf8', callback );
								} ); // page get
							}, // iterator
							callback
						); // whilst
						
					} ); // get
				}, // iterator
				function(err) {
					if (err) {
						warn( "\nEXPORT ERROR: " + err + "\n" );
						process.exit(1);
					}
					
					verbose_warn( "\nExport completed at " + (new Date()).toString() + ".\nExiting.\n\n" );
					
					if (file) stream.end();
					
					storage.shutdown( function() { process.exit(0); } );
				} // done done
			); // list eachSeries
		} // done with users
	); // users listEach
};

function import_data(file) {
	// import storage data from specified file or stdin
	// one record per line: KEY - JSON
	print( "\nCronicle Data Importer v1.0\n" );
	if (file) print( "Importing from file: " + file + "\n" );
	else print( "Importing from STDIN\n" );
	print( "\n" );
	
	var count = 0;
	var queue = async.queue( function(line, callback) {
		// process each line
		if (line.match(/^(\w[\w\-\.\/]*)\s+\-\s+(\{.+\})\s*$/)) {
			var key = RegExp.$1;
			var json_raw = RegExp.$2;
			print( "Importing record: " + key + "\n" );
			
			var data = null;
			try { data = JSON.parse(json_raw); }
			catch (err) {
				warn( "Failed to parse JSON for key: " + key + ": " + err + "\n" );
				return callback();
			}
			
			storage.put( key, data, function(err) {
				if (err) {
					warn( "Failed to store record: " + key + ": " + err + "\n" );
					return callback();
				}
				count++;
				callback();
			} );
		}
		else callback();
	}, 1 );
	
	// setup readline to line-read from file or stdin
	var readline = require('readline');
	var rl = readline.createInterface({
		input: file ? fs.createReadStream(file) : process.stdin
	});
	
	rl.on('line', function(line) {
		// enqueue each line
		queue.push( line );
	});
	
	rl.on('close', function() {
		// end of input stream
		var complete = function() {
			// finally, delete state so cronicle recreates it
			storage.delete( 'global/state', function(err) {
				// ignore error here, as state may not exist yet
				print( "\nImport complete. " + count + " records imported.\nExiting.\n\n" );
				storage.shutdown( function() { process.exit(0); } );
			});
		};
		
		// fire complete on queue drain
		if (queue.idle()) complete();
		else queue.drain = complete;
	}); // rl close
};
