// Cronicle Server Component
// Copyright (c) 2015 Joseph Huckaby
// Released under the MIT License

var assert = require("assert");
var fs = require("fs");
var mkdirp = require('mkdirp');
var async = require('async');
var glob = require('glob');
var jstz = require('jstimezonedetect');

var Class = require("pixl-class");
var Component = require("pixl-server/component");
var Tools = require("pixl-tools");
var Request = require("pixl-request");

module.exports = Class.create({
	
	__name: 'Cronicle',
	__parent: Component,
	__mixins: [ 
		require('./api.js'),       // API Layer Mixin
		require('./comm.js'),      // Communication Layer Mixin
		require('./scheduler.js'), // Scheduler Mixin
		require('./job.js'),       // Job Management Layer Mixin
		require('./queue.js'),     // Queue Layer Mixin
		require('./discovery.js')  // Discovery Layer Mixin
	],
	
	activeJobs: null,
	deadJobs: null,
	eventQueue: null,
	kids: null,
	state: null,
	
	defaultWebHookTextTemplates: {
		"job_start": "Job started on [hostname]: [event_title] [job_details_url]",
		"job_complete": "Job completed successfully on [hostname]: [event_title] [job_details_url]",
		"job_failure": "Job failed on [hostname]: [event_title]: Error [code]: [description] [job_details_url]",
		"job_launch_failure": "Failed to launch scheduled event: [event_title]: [description] [edit_event_url]"
	},
	
	startup: function(callback) {
		// start cronicle service
		var self = this;
		this.logDebug(3, "Cronicle engine starting up" );
		
		// create a few extra dirs we'll need
		try { mkdirp.sync( this.server.config.get('log_dir') + '/jobs' ); }
		catch (e) {
			throw new Error("FATAL ERROR: Log directory could not be created: " + this.server.config.get('log_dir') + "/jobs: " + e);
		}
		try { mkdirp.sync( this.server.config.get('queue_dir') ); }
		catch (e) {
			throw new Error("FATAL ERROR: Queue directory could not be created: " + this.server.config.get('queue_dir') + ": " + e);
		}
		
		// dirs should be writable by all users
		fs.chmodSync( this.server.config.get('log_dir') + '/jobs', "777" );
		fs.chmodSync( this.server.config.get('queue_dir'), "777" );
		
		// keep track of jobs
		this.activeJobs = {};
		this.deadJobs = {};
		this.eventQueue = {};
		this.kids = {};
		this.state = { enabled: true, cursors: {}, stats: {}, flagged_jobs: {} };
		
		// we'll need these components frequently
		this.storage = this.server.Storage;
		this.web = this.server.WebServer;
		this.api = this.server.API;
		this.usermgr = this.server.User;
		
		// register custom storage type for dual-metadata-log delete
		this.storage.addRecordType( 'cronicle_job', {
			'delete': this.deleteExpiredJobData.bind(this)
		} );
		
		// multi-server cluster / failover system
		this.multi = {
			cluster: false,
			master: false,
			slave: false,
			masterHostname: '',
			eligible: false,
			lastPingReceived: 0,
			lastPingSent: 0,
			data: {}
		};
		
		// construct http client for web hooks and uploading logs
		this.request = new Request( "Cronicle " + this.server.__version );
		
		// optionally bypass all ssl cert validation
		if (this.server.config.get('ssl_cert_bypass') || this.server.config.get('web_hook_ssl_cert_bypass')) {
			process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
		}
		
		// register our class as an API namespace
		this.api.addNamespace( "app", "api_", this );
		
		// intercept API requests to inject server groups
		this.web.addURIFilter( /^\/api\/app\/\w+/, "API Filter", function(args, callback) {
			// load server groups for all API requests (these are cached in RAM)
			self.storage.listGet( 'global/server_groups', 0, 0, function(err, items) {
				args.server_groups = items;
				callback(false); // passthru
			} );
		} );
		
		// register a handler for HTTP OPTIONS (for CORS AJAX preflight)
		this.web.addMethodHandler( "OPTIONS", "CORS Preflight", this.corsPreflight.bind(this) );
		
		// start socket.io server, attach to http/https
		this.startSocketListener();
		
		// start auto-discovery listener (UDP)
		this.setupDiscovery();
		
		// add uncaught exception handler
		require('uncatch').on('uncaughtException', this.emergencyShutdown.bind(this));
		
		// listen for ticks so we can broadcast status
		this.server.on('tick', this.tick.bind(this));
		
		// register hooks for when users are created / deleted
		this.usermgr.registerHook( 'after_create', this.afterUserChange.bind(this, 'user_create') );
		this.usermgr.registerHook( 'after_update', this.afterUserChange.bind(this, 'user_update') );
		this.usermgr.registerHook( 'after_delete', this.afterUserChange.bind(this, 'user_delete') );
		this.usermgr.registerHook( 'after_login', this.afterUserLogin.bind(this) );
		
		// intercept user login and session resume, to merge in extra data
		this.usermgr.registerHook( 'before_login', this.beforeUserLogin.bind(this) );
		this.usermgr.registerHook( 'before_resume_session', this.beforeUserLogin.bind(this) );
		
		// monitor active jobs (for timeouts, etc.)
		this.server.on('minute', function() {
			// force gc 10s after the minute
			// (only if global.gc is exposed by node CLI arg)
			if (global.gc) {
				self.gcTimer = setTimeout( function() {
					delete self.gcTimer;
					self.logDebug(10, "Forcing garbage collection now", process.memoryUsage());
					global.gc();
					self.logDebug(10, "Garbage collection complete", process.memoryUsage());
				}, 10 * 1000 );
			}
		} );
		
		// archive logs daily at midnight
		this.server.on('day', function() {
			self.archiveLogs();
		} );
		
		// determine master server eligibility
		this.checkMasterEligibility( function() {
			// master mode (CLI option) -- force us to become master right away
			if (self.server.config.get('master') && self.multi.eligible) self.goMaster();
			
			// reset the failover counter
			self.multi.lastPingReceived = Tools.timeNow(true);
			
			// startup complete
			callback();
		} );
	},
	
	checkMasterEligibility: function(callback) {
		// determine master server eligibility
		var self = this;
		
		this.storage.listGet( 'global/servers', 0, 0, function(err, servers) {
			if (err) {
				// this may happen on slave servers that have no access to storage -- silently fail
				servers = [];
			}
			
			if (!Tools.findObject(servers, { hostname: self.server.hostname }) && !self.multi.cluster) {
				// we were not found in server list
				self.multi.eligible = false;
				if (!self.multi.slave) {
					self.logDebug(4, "Server not found in cluster -- waiting for a master server to contact us");
				}
				if (callback) callback();
				return;
			}
			
			// found server in cluster, good
			self.multi.cluster = true;
			
			// now check server groups
			self.storage.listGet( 'global/server_groups', 0, 0, function(err, groups) {
				if (err) {
					// this may happen on slave servers that have no access to storage -- silently fail
					groups = [];
				}
				
				// scan all master groups for our hostname
				var eligible = false;
				var group_title = '';
				
				for (var idx = 0, len = groups.length; idx < len; idx++) {
					var group = groups[idx];
					
					var regexp = null;
					try { regexp = new RegExp( group.regexp ); }
					catch (e) {
						self.logError('master', "Invalid group regular expression: " + group.regexp + ": " + e);
						regexp = null;
					}
					
					if (group.master && regexp && self.server.hostname.match(regexp)) {
						eligible = true;
						group_title = group.title;
						idx = len;
					}
				}
				
				if (eligible) {
					self.logDebug(4, "Server is eligible to become master (" + group_title + ")");
					self.multi.eligible = true;
				}
				else {
					self.logDebug(4, "Server is not eligible for master -- it will be a slave only");
					self.multi.eligible = false;
				}
				
				if (callback) callback();
				
			} ); // global/server_groups
		} ); // global/servers
	},
	
	tick: function() {
		// called every second
		var self = this;
		this.lastTick = Tools.timeNow();
		var now = Math.floor(this.lastTick);
		
		if (this.numSocketClients) {
			var status = {
				epoch: Tools.timeNow(),
				master: this.multi.master,
				master_hostname: this.multi.masterHostname
			};
			if (this.multi.master) {
				// web client connection to master
				// send additional information only needed by UI
				status.active_jobs = this.getAllActiveJobs(true);
				status.servers = this.getAllServers();
			}
			else {
				// we are a slave, so just send our own jobs and misc server health stats
				status.active_jobs = this.activeJobs;
				status.queue = this.internalQueue || {};
				status.data = this.multi.data;
				status.uptime = now - (this.server.started || now);
			}
			
			// this.io.emit( 'status', status );
			this.authSocketEmit( 'status', status );
		}
		
		// monitor master health
		if (!this.multi.master) {
			var delta = now - this.multi.lastPingReceived;
			if (delta >= this.server.config.get('master_ping_timeout')) {
				if (this.multi.eligible) this.masterFailover();
				else if (this.multi.slave) this.slaveFailover();
			}
		}
		
		// as master, broadcast pings every N seconds
		if (this.multi.master) {
			var delta = now - this.multi.lastPingSent;
			if (delta >= this.server.config.get('master_ping_freq')) {
				this.sendMasterPings();
				this.multi.lastPingSent = now;
			}
		}
		
		// monitor server resources every N seconds (or 1 min if no local jobs)
		var msr_freq = Tools.numKeys(this.activeJobs) ? (this.server.config.get('monitor_res_freq') || 10) : 60;
		if (!this.lastMSR || (now - this.lastMSR >= msr_freq)) {
			this.lastMSR = now;
			
			this.monitorServerResources( function(err) {
				// nicer to do this after gathering server resources
				self.monitorAllActiveJobs();
			} );
		}
		
		// auto-discovery broadcast pings
		this.discoveryTick();
	},
	
	authSocketEmit: function(key, data) {
		// Only emit to authenticated clients
		for (var id in this.sockets) {
			var socket = this.sockets[id];
			if (socket._pixl_auth) socket.emit( key, data );
		}
	},
	
	masterSocketEmit: function() {
		// Only emit to master server -- and make sure this succeeds
		// Internally queue upon failure
		var count = 0;
		var key = '';
		var data = null;
		
		if (arguments.length == 2) {
			key = arguments[0];
			data = arguments[1];
		}
		else if (arguments.length == 1) {
			key = arguments[0].key;
			data = arguments[0].data;
		}
		
		for (var id in this.sockets) {
			var socket = this.sockets[id];
			if (socket._pixl_master) {
				socket.emit( key, data );
				count++;
			}
		}
		
		if (!count) {
			// enqueue this for retry
			this.logDebug(8, "No master server socket connection available, will retry");
			this.enqueueInternal({
				action: 'masterSocketEmit',
				key: key,
				data: data,
				when: Tools.timeNow(true) + 10
			});
		}
	},
	
	updateClientData: function(name) {
		// broadcast global list update to all clients
		// name should be one of: plugins, categories, server_groups, schedule
		assert( name != 'users' );
		
		var self = this;
		this.storage.listGet( 'global/' + name, 0, 0, function(err, items) {
			if (err) {
				self.logError('storage', "Failed to fetch list: global/" + name + ": " + err);
				return;
			}
			var data = {};
			data[name] = items;
			// self.io.emit( 'update', data );
			self.authSocketEmit( 'update', data );
		} );
	},
	
	beforeUserLogin: function(args, callback) {
		// infuse data into user login client response
		var self = this;
		
		args.resp = {
			epoch: Tools.timeNow(),
			servers: this.getAllServers(),
			nearby: this.nearbyServers,
			activeJobs: this.getAllActiveJobs(true),
			eventQueue: this.eventQueue,
			state: this.state
		};
		
		// load essential data lists in parallel (these are, or will be, cached in RAM)
		async.each( ['schedule', 'categories', 'plugins', 'server_groups'], 
			function(name, callback) {
				self.storage.listGet( 'global/' + name, 0, 0, function(err, items) {
					args.resp[ name ] = items || [];
					callback();
				} );
			},
			callback
		); // each
	},
	
	afterUserLogin: function(args) {
		// log the login
		this.logActivity('user_login', { 
			user: Tools.copyHashRemoveKeys( args.user, { password: 1, salt: 1 } )
		}, args );
	},
	
	afterUserChange: function(action, args) {
		// user data has changed, notify all connected clients
		// Note: user data is not actually sent here -- this just triggers a client redraw if on the user list page
		// this.io.emit( 'update', { users: {} } );
		this.authSocketEmit( 'update', { users: {} } );
		
		// add to activity log in the background
		this.logActivity(action, { 
			user: Tools.copyHashRemoveKeys( args.user, { password: 1, salt: 1 } )
		}, args );
	},
	
	logActivity: function(action, orig_data, args) {
		// add event to activity logs async
		var self = this;
		if (!args) args = {};
		
		assert( Tools.isaHash(orig_data), "Must pass a data object to logActivity" );
		var data = Tools.copyHash( orig_data, true );
		
		// sanity check: make sure we are still master
		if (!this.multi.master) return;
		
		data.action = action;
		data.epoch = Tools.timeNow(true);
		
		if (args.ip) data.ip = args.ip;
		if (args.request) data.headers = args.request.headers;
		
		if (args.admin_user) data.username = args.admin_user.username;
		else if (args.user) {
			if (args.user.key) {
				// API Key
				data.api_key = args.user.key;
				data.api_title = args.user.title;
			}
			else {
				data.username = args.user.username;
			}
		}
		
		this.storage.enqueue( function(task, callback) {
			self.storage.listUnshift( 'logs/activity', data, callback );
		});
	},
	
	goMaster: function() {
		// we just became the master server
		var self = this;
		this.logDebug(3, "We are becoming the master server");
		
		this.multi.master = true;
		this.multi.slave = false;
		this.multi.cluster = true;
		this.multi.masterHostname = this.server.hostname;
		this.multi.lastPingSent = Tools.timeNow(true);
		
		// we need to know the server timezone at this point
		this.tz = jstz.determine().name();
		
		// only the master server should enable storage maintenance
		this.server.on(this.server.config.get('maintenance'), function() {
			self.storage.runMaintenance( new Date(), self.runMaintenance.bind(self) );
		});
		
		// only the master server should enable storage ram caching
		this.storage.config.set( 'cache_key_match', '^global/' );
		this.storage.prepConfig();
		
		// start server cluster management
		this.setupCluster();
		
		// start scheduler
		this.setupScheduler();
		
		// start queue monitor
		this.setupQueue();
		
		// clear daemon stats every day at midnight
		this.server.on('day', function() {
			self.state.stats = {};
		} );
		
		// easter egg, let's see if anyone notices
		this.server.on('year', function() {
			self.logDebug(1, "Happy New Year!");
		} );
		
		// log this event
		if (!this.server.debug) {
			this.logActivity( 'server_master', { hostname: this.server.hostname } );
		}
		
		// recover any leftover logs
		this.recoverJobLogs();
	},
	
	goSlave: function() {
		// we just became a slave server
		// recover any leftover logs
		this.logDebug(3, "We are becoming a slave server");
		
		this.multi.master = false;
		this.multi.slave = true;
		this.multi.cluster = true;
		
		// start queue monitor
		this.setupQueue();
		
		// recover any leftover logs
		this.recoverJobLogs();
	},
	
	masterFailover: function() {
		// No master ping recieved in N seconds, so we need to choose a new master
		var self = this;
		var servers = [];
		var groups = [];
		
		this.logDebug(3, "No master ping received within " + this.server.config.get('master_ping_timeout') + " seconds, choosing new master");
		
		// make sure tick() doesn't keep calling us
		this.multi.lastPingReceived = Tools.timeNow(true);
		
		async.series([
			function(callback) {
				self.storage.listGet( 'global/servers', 0, 0, function(err, items) {
					servers = items || [];
					callback(err);
				} );
			},
			function(callback) {
				self.storage.listGet( 'global/server_groups', 0, 0, function(err, items) {
					groups = items || [];
					callback(err);
				} );
			}
		], 
		function(err) {
			// all resources loaded
			if (err || !servers.length || !groups.length) {
				// should never happen
				self.logDebug(4, "No servers found, going into idle mode");
				self.multi.masterHostname = '';
				self.multi.master = self.multi.slave = self.multi.cluster = false;
				return;
			}
			
			// compile list of master server candidates
			var candidates = {};
			
			for (var idx = 0, len = groups.length; idx < len; idx++) {
				var group = groups[idx];
				if (group.master) {
					
					var regexp = null;
					try { regexp = new RegExp( group.regexp ); }
					catch (e) {
						self.logError('master', "Invalid group regular expression: " + group.regexp + ": " + e);
						regexp = null;
					}
					
					if (regexp) {
						for (var idy = 0, ley = servers.length; idy < ley; idy++) {
							var server = servers[idy];
							if (server.hostname.match(regexp)) {
								candidates[ server.hostname ] = server;
							}
						} // foreach server
					}
				} // master group
			} // foreach group
			
			// sanity check: we better be in the list
			if (!candidates[ self.server.hostname ]) {
				self.logDebug(4, "We are no longer eligible for master, going into idle mode");
				self.multi.masterHostname = '';
				self.multi.master = self.multi.slave = self.multi.cluster = false;
				return;
			}
			
			// sort hostnames alphabetically to determine rank
			var hostnames = Object.keys(candidates).sort();
			
			if (!hostnames.length) {
				// should never happen
				self.logDebug(4, "No eligible servers found, going into idle mode");
				self.multi.masterHostname = '';
				self.multi.master = self.multi.slave = self.multi.cluster = false;
				return;
			}
			
			// see if any servers are 'above' us in rank
			var rank = hostnames.indexOf( self.server.hostname );
			if (rank == 0) {
				// we are the top candidate, so become master immediately
				self.logDebug(5, "We are the top candidate for master");
				self.goMaster();
				return;
			}
			
			// ping all servers higher than us to see if any of them are alive
			var superiors = hostnames.splice( 0, rank );
			var alive = [];
			self.logDebug(6, "We are rank #" + rank + " for master, pinging superiors", superiors);
			
			async.each( superiors, 
				function(hostname, callback) {
					var server = candidates[hostname];
					
					var api_url = self.getServerBaseAPIURL( hostname, server.ip ) + '/app/status';
					self.logDebug(10, "Sending API request to remote server: " + hostname + ": " + api_url);
					
					// send request
					self.request.json( api_url, {}, { timeout: 5 * 1000 }, function(err, resp, data) {
						if (err) {
							self.logDebug(10, "Failed to contact server: " + hostname + ": " + err );
							return callback();
						}
						if (resp.statusCode != 200) {
							self.logDebug(10, "Failed to contact server: " + hostname + ": HTTP " + resp.statusCode + " " + resp.statusMessage);
							return callback();
						}
						if (data.code != 0) {
							self.logDebug(10, "Failed to ping server: " + hostname + ": " + data.description);
							return callback();
						}
						if (data.tick_age > 60) {
							self.logDebug(1, "WARNING: Failed to ping server: " + hostname + ": Tick age is " + Math.floor(data.tick_age) + "s", data);
							return callback();
						}
						
						// success, at least one superior ponged our ping
						// relinquish command to them
						self.logDebug(10, "Successfully pinged server: " + hostname);
						alive.push( hostname );
						callback();
					} );
				},
				function() {
					if (alive.length) {
						self.logDebug(6, alive.length + " servers ranked above us are alive, so we will become a slave", alive);
					}
					else {
						self.logDebug(5, "No other servers ranked above us are alive, so we are the top candidate for master");
						self.goMaster();
					}
				} // pings complete
			); // async.each
		} ); // got servers and groups
	},
	
	slaveFailover: function() {
		// remove ourselves from slave duty, and go into idle mode
		if (this.multi.slave) {
			this.logDebug(3, "No master ping received within " + this.server.config.get('master_ping_timeout') + " seconds, going idle");
			this.multi.masterHostname = '';
			this.multi.master = this.multi.slave = this.multi.cluster = false;
			this.lastDiscoveryBroadcast = 0;
		}
	},
	
	masterConflict: function(slave) {
		// should never happen: a slave has become master
		// we must shut down right away if this happens
		var err_msg = "MASTER CONFLICT: The server '"+slave.hostname+"' is also a master server. Shutting down immediately.";
		this.logDebug(1, err_msg);
		this.logError('multi', err_msg);
		this.server.shutdown();
	},
	
	isProcessRunning: function(pid) {
		// check if process is running or not
		var ping = false;
		try { ping = process.kill( pid, 0 ); }
		catch (e) {;}
		return ping;
	},
	
	recoverJobLogs: function() {
		// upload any leftover job logs (after unclean shutdown)
		var self = this;
		
		// don't run this if shutting down
		if (this.server.shut) return;
		
		// make sure this ONLY runs once
		if (this.recoveredJobLogs) return;
		this.recoveredJobLogs = true;
		
		// look for any leftover job JSON files (master server shutdown)
		var job_spec = this.server.config.get('log_dir') + '/jobs/*.json';
		this.logDebug(4, "Looking for leftover job JSON files: " + job_spec);
		
		glob(job_spec, {}, function (err, files) {
			// got job json files
			if (!files) files = [];
			async.eachSeries( files, function(file, callback) {
				// foreach job file
				if (file.match(/\/(\w+)(\-detached)?\.json$/)) {
					var job_id = RegExp.$1;
					
					fs.readFile( file, { encoding: 'utf8' }, function(err, data) {
						var job = null;
						try { job = JSON.parse(data); } catch (e) {;}
						if (job) {
							self.logDebug(5, "Recovering job data: " + job_id + ": " + file, job);
							
							if (job.detached && job.pid && self.isProcessRunning(job.pid)) {
								// detached job is still running, resume it!
								self.logDebug(5, "Detached PID " + job.pid + " is still alive, resuming job as if nothing happened");
								self.activeJobs[ job_id ] = job;
								self.kids[ job.pid ] = { pid: job.pid };
								return callback();
							}
							else if (job.detached && fs.existsSync(self.server.config.get('queue_dir') + '/' + job_id + '-complete.json')) {
								// detached job completed while service was stopped
								// Note: Bit of a possible race condition here, as setupQueue() runs in parallel, and 'minute' event may fire
								self.logDebug(5, "Detached job " + job_id + " completed on its own, skipping recovery (queue will pick it up)");
								
								// disable job timeout, to prevent race condition with monitorAllActiveJobs()
								job.timeout = 0;
								
								self.activeJobs[ job_id ] = job;
								self.kids[ job.pid ] = { pid: job.pid };
								return callback();
							}
							else {
								// job died when server went down
								job.complete = 1;
								job.code = 1;
								job.description = "Aborted Job: Server '" + self.server.hostname + "' shut down unexpectedly.";
								self.logDebug(6, job.description);
								
								if (self.multi.master) {
									// we're master, finish the job locally
									self.finishJob(job);
								} // master
								else {
									// we're a slave, signal master to finish job via websockets
									self.io.emit('finish_job', job);
								} // slave
							} // standard job
						}
						
						fs.unlink( file, function(err) {
							// ignore error, file may not exist
							callback();
						} );
					} ); // fs.readFile
				} // found id
				else callback();
			}, 
			function(err) {
				// done with glob eachSeries
				self.logDebug(9, "Job recovery complete");
				
				var log_spec = self.server.config.get('log_dir') + '/jobs/*.log';
				self.logDebug(4, "Looking for leftover job logs: " + log_spec);
				
				// look for leftover log files
				glob(log_spec, {}, function (err, files) {
					// got log files
					if (!files) files = [];
					async.eachSeries( files, function(file, callback) {
						// foreach log file
						if (file.match(/\/(\w+)(\-detached)?\.log$/)) {
							var job_id = RegExp.$1;
							
							// only recover logs for dead jobs
							if (!self.activeJobs[job_id]) {
								self.logDebug(5, "Recovering job log: " + job_id + ": " + file);
								self.uploadJobLog( { id: job_id, log_file: file, hostname: self.server.hostname }, callback );
							}
							else callback();
						} // found id
						else callback();
					}, 
					function(err) {
						// done with glob eachSeries
						self.logDebug(9, "Log recovery complete");
						
						// cleanup old log .tmp files, which may have failed during old archive/rotation
						glob(self.server.config.get('log_dir') + '/jobs/*.tmp', {}, function (err, files) {
							if (!files || !files.length) return;
							async.eachSeries( files, function(file, callback) { fs.unlink(file, callback); } );
						}); // glob
					} );
				} ); // glob
			} ); // eachSeries
		} ); // glob
	},
	
	runMaintenance: function(callback) {
		// run routine daily tasks, called after storage maint completes.
		// make sure our activity logs haven't grown beyond the max limit
		var self = this;
		var max_rows = this.server.config.get('list_row_max') || 0;
		if (!max_rows) return;
		
		// sanity check: make sure we are still master
		if (!this.multi.master) return;
		
		// don't run this if shutting down
		if (this.server.shut) return;
		
		var list_paths = ['logs/activity', 'logs/completed'];
		
		this.storage.listGet( 'global/schedule', 0, 0, function(err, items) {
			if (err) {
				self.logError('maint', "Failed to fetch list: global/schedule: " + err);
				return;
			}
			
			for (var idx = 0, len = items.length; idx < len; idx++) {
				list_paths.push( 'logs/events/' + items[idx].id );
			}
			
			async.eachSeries( list_paths, 
				function(list_path, callback) {
					// iterator function, work on single list
					self.storage.listGetInfo( list_path, function(err, info) {
						// list may not exist, skip if so
						if (err) return callback();
						
						// check list length
						if (info.length > max_rows) {
							// list has grown too long, needs a trim
							self.logDebug(3, "List " + list_path + " has grown too long, trimming to max: " + max_rows, info);
							// self.storage.listSplice( list_path, max_rows, info.length - max_rows, null, callback );
							self.listRoughChop( list_path, max_rows, callback );
						}
						else {
							// no trim needed, proceed to next list
							callback();
						}
					} ); // get list info
				}, // iterator
				function(err) {
					if (err) {
						self.logError('maint', "Failed to trim lists: " + err);
					}
					
					// done with maint
					if (callback) callback();
					
				} // complete
			); // eachSeries
		} ); // schedule loaded
		
		// sanity state cleanup: flagged jobs
		if (this.state.flagged_jobs && Tools.numKeys(this.state.flagged_jobs)) {
			var all_jobs = this.getAllActiveJobs();
			for (var id in this.state.flagged_jobs) {
				if (!all_jobs[id]) delete this.state.flagged_jobs[id];
			}
		}
	},
	
	listRoughChop: function(list_path, max_rows, callback) {
		// Roughly chop the end of a list to get the size down to under specified ceiling.
		// This is not exact, and will likely be off by up to one 'page_size' of items.
		// The idea here is to chop without using tons of memory like listSplice does.
		var self = this;
		this.logDebug(4, "Roughly chopping list: " + list_path + " down to max: " + max_rows);
		
		self.storage._listLock( list_path, true, function() {
			// list is now locked, we can work on it safely
			
			self.storage.listGetInfo( list_path, function(err, list) {
				// check for error
				if (err) {
					self.logError('maint', "Failed to load list header: " + list_path + ": " + err);
					self.storage._listUnlock( list_path );
					return callback();
				}
				
				// begin chop loop
				async.whilst(
					function() {
						// keep chopping while list is too long
						return( list.length > max_rows ); 
					},
					function(callback) {
						// load last page
						self.storage._listLoadPage( list_path, list.last_page, false, function(err, page) {
							if (err) {
								// this should never happen, and is bad
								// (list is probably corrupted, and should be deleted and recreated)
								return callback( new Error("Failed to load list page: " + list_path + "/" + list.last_page + ": " + err) );
							}
							if (!page) page = { items: [] };
							if (!page.items) page.items = [];
							
							list.length -= page.items.length;
							
							self.logDebug(5, "Deleting list page: " + list_path + "/" + list.last_page + " (" + page.items.length + " items)");
							
							self.storage.delete( list_path + "/" + list.last_page, function(err) {
								if (err) {
									// log error but don't fail entire op
									self.logError('maint', "Failed to delete list page: " + list_path + "/" + list.last_page + ": " + err);
								}
								
								list.last_page--;
								callback();
								
							} ); // delete page
						} ); // _listLoadPage
					},
					function(err) {
						// all done
						if (err) {
							self.logError('maint', err.toString() );
							self.storage._listUnlock( list_path );
							return callback();
						}
						
						// update list header with new length and last_page
						self.storage.put( list_path, list, function(err) {
							if (err) {
								self.logError('maint', "Failed to update list header: " + list_path + ": " + err);
							}
							
							// unlock list
							self.storage._listUnlock( list_path );
							
							self.logDebug(4, "List chop complete: " + list_path + ", new size: " + list.length, list);
							
							// and we're done
							callback();
							
						} ); // put
					} // done
				); // whilst
			}); // listGetInfo
		} ); // listLock
	},
	
	archiveLogs: function() {
		// archive all logs (called once daily)
		var self = this;
		var src_spec = this.server.config.get('log_dir') + '/*.log';
		var dest_path = this.server.config.get('log_archive_path');
		
		if (dest_path) {
			this.logDebug(4, "Archiving logs: " + src_spec + " to: " + dest_path);
			// generate time label from previous day, so just subtracting 30 minutes to be safe
			var epoch = Tools.timeNow(true) - 1800;
			
			this.logger.archive(src_spec, dest_path, epoch, function(err) {
				if (err) self.logError('maint', "Failed to archive logs: " + err);
				else self.logDebug(4, "Log archival complete");
			});
		}
	},
	
	deleteExpiredJobData: function(key, data, callback) {
		// delete both job data and job log
		// called from storage maintenance system for 'cronicle_job' record types
		var self = this;
		var log_key = key + '/log.txt.gz';
		
		this.logDebug(6, "Deleting expired job data: " + key);
		this.storage.delete( key, function(err) {
			if (err) self.logError('maint', "Failed to delete expired job data: " + key + ": " + err);
			
			self.logDebug(6, "Deleting expired job log: " + log_key);
			self.storage.delete( log_key, function(err) {
				if (err) self.logError('maint', "Failed to delete expired job log: " + log_key + ": " + err);
				
				callback();
			} ); // delete
		} ); // delete
	},
	
	_uniqueIDCounter: 0,
	getUniqueID: function(prefix) {
		// generate unique id using high-res server time, and a static counter,
		// both converted to alphanumeric lower-case (base-36), ends up being ~10 chars.
		// allows for *up to* 1,296 unique ids per millisecond (sort of).
		this._uniqueIDCounter++;
		if (this._uniqueIDCounter >= Math.pow(36, 2)) this._uniqueIDCounter = 0;
		
		return [
			prefix,
			Tools.zeroPad( (new Date()).getTime().toString(36), 8 ),
			Tools.zeroPad( this._uniqueIDCounter.toString(36), 2 )
		].join('');		
	},
	
	corsPreflight: function(args, callback) {
		// handler for HTTP OPTIONS calls (CORS AJAX preflight)
		callback( "200 OK", 
			{
				'Access-Control-Allow-Origin': args.request.headers['origin'] || "*",
				'Access-Control-Allow-Methods': "POST, GET, HEAD, OPTIONS",
				'Access-Control-Allow-Headers': args.request.headers['access-control-request-headers'] || "*",
				'Access-Control-Max-Age': "1728000",
				'Content-Length': "0"
			},
			null
		);
	},
	
	logError: function(code, msg, data) {
		// proxy request to system logger with correct component
		this.logger.set( 'component', 'Error' );
		this.logger.error( code, msg, data );
	},
	
	logTransaction: function(code, msg, data) {
		// proxy request to system logger with correct component
		this.logger.set( 'component', 'Transaction' );
		this.logger.transaction( code, msg, data );
	},
	
	shutdown: function(callback) {
		// shutdown api service
		var self = this;
		
		this.logDebug(2, "Shutting down Cronicle");
		this.abortAllLocalJobs();
		this.shutdownCluster();
		this.shutdownScheduler( function() {
			
			self.shutdownQueue();
			self.shutdownDiscovery();
			
			if (self.gcTimer) {
				clearTimeout( self.gcTimer );
				delete self.gcTimer;
			}
			
			// wait for all non-detached local jobs to complete before continuing shutdown
			var count = 0;
			var ids = [];
			var first = true;
			
			async.whilst(
				function () {
					count = 0;
					ids = [];
					for (var id in self.activeJobs) {
						var job = self.activeJobs[id];
						if (!job.detached) { count++; ids.push(id); }
					}
					return (count > 0);
				},
				function (callback) {
					if (first) {
						self.logDebug(3, "Waiting for " + count + " active jobs to complete", ids);
						first = false;
					}
					setTimeout( function() { callback(); }, 250 );
				},
				function() {
					// all non-detached local jobs complete
					callback();
				}
			); // whilst
			
		} ); // shutdownScheduler
	},
	
	emergencyShutdown: function(err) {
		// perform emergency shutdown due to uncaught exception
		this.logger.set('sync', true);
		this.logError('crash', "Emergency Shutdown: " + err);
		this.logDebug(1, "Emergency Shutdown: " + err);
		this.abortAllLocalJobs();
	}
	
});
