var opts = require('./opts');
if (require.main == module) opts.parse_args();
opts.load_defaults();

const khash = require("kana-hash");

var _ = require('../lib/underscore'),
    amusement = require('./amusement'),
    async = require('async'),
    auth = require('./auth'),
    caps = require('./caps'),
    check = require('./msgcheck').check,
    common = require('../common'),
    config = require('../config'),
    crypto = require('crypto'),
    db = require('../db'),
    fs = require('fs'),
    hooks = require('../hooks'),
    imager = require('../imager'),
    etc = require('../etc'),
    Muggle = etc.Muggle,
    okyaku = require('./okyaku'),
    render = require('./render'),
    request = require('request'),
    STATE = require('./state'),
    tripcode = require('../tripcode').make(),//{hash:function(a,b){return new khash.Kana(0, this.salt).once(a);}, setSalt:function(a){if(a) this.salt = new khash.Salt(a); else this.salt = khash.Salt.Default; return this.salt;}}, //require('./../tripcode/tripcode'),
    urlParse = require('url').parse,
    web = require('./web'),
    winston = require('winston');

require('../admin');
if (!imager.is_standalone())
	require('../imager/daemon'); // preload and confirm it works
if (config.CURFEW_BOARDS)
	require('../curfew/server');
try {
	var reportConfig = require('../report/config');
	if (reportConfig.RECAPTCHA_SITE_KEY)
		require('../report/server');
} catch (e) {}

var RES = STATE.resources;

var dispatcher = okyaku.dispatcher;

/* I always use encodeURI anyway */
var escape = common.escape_html;
var safe = common.safe;

dispatcher[common.PING] = function (msg, client) {
	if (msg.length)
		return false;
	client.send([0, common.PING]);
	return true;
};

dispatcher[common.SYNCHRONIZE] = function (msg, client) {
	function checked(err, ident) {
		if (!err)
			_.extend(client.ident, ident);
		if (!synchronize(msg, client))
			client.kotowaru(Muggle("Bad protocol."));
	}
	var chunks = web.parse_cookie(msg.pop());
	var cookie = auth.extract_login_cookie(chunks);
	if (cookie) {
		auth.check_cookie(cookie, checked);
		return true;
	}
	else
		return synchronize(msg, client);
};

function synchronize(msg, client) {
	if (!check(['id', 'string', 'id=>nat', 'boolean'], msg))
		return false;
	var id = msg[0], board = msg[1], syncs = msg[2], live = msg[3];
	if (id in STATE.clients) {
		winston.error("Duplicate client id " + id);
		return false;
	}
	client.id = id;
	STATE.clients[id] = client;

	if (!caps.can_access_board(client.ident, board))
		return false;
	var dead_threads = [], count = 0, op;
	for (var k in syncs) {
		k = parseInt(k, 10);
		if (db.OPs[k] != k || !db.OP_has_tag(board, k)) {
			delete syncs[k];
			dead_threads.push(k);
		}
		op = k;
		if (++count > config.THREADS_PER_PAGE) {
			/* Sync logic isn't great yet; allow this for now */
			// return false;
		}
	}
	client.watching = syncs;
	if (live) {
		/* XXX: This will break if a thread disappears during sync
		 *      (won't be reported)
		 * Or if any of the threads they see on the first page
		 * don't show up in the 'live' pub for whatever reason.
		 * Really we should get them synced first and *then* switch
		 * to the live pub.
		 */
		client.watching = {live: true};
		count = 1;
	}
	client.board = board;

	if (client.db)
		client.db.disconnect();
	client.db = new db.Yakusoku(board, client.ident);
	/* Race between subscribe and backlog fetch; client must de-dup */
	client.db.kiku(client.watching, client.on_update.bind(client),
			client.on_thread_sink.bind(client), listening);
	function listening(errs) {
		if (errs && errs.length >= count)
			return client.kotowaru(Muggle(
					"Couldn't sync to board."));
		else if (errs) {
			dead_threads.push.apply(dead_threads, errs);
			errs.forEach(function (thread) {
				delete client.watching[thread];
			});
		}
		client.db.fetch_backlogs(client.watching, got_backlogs);
	}
	function got_backlogs(errs, logs) {
		if (errs) {
			dead_threads.push.apply(dead_threads, errs);
			errs.forEach(function (thread) {
				delete client.watching[thread];
			});
		}

		if (client.ident.readOnly) {
			logs.push('0,' + common.MODEL_SET + ',["hot"],{"readOnly":true}');
		}

		var sync = '0,' + common.SYNCHRONIZE;
		if (dead_threads.length)
			sync += ',' + JSON.stringify(dead_threads);
		logs.push(sync);
		client.socket.write('[[' + logs.join('],[') + ']]');
		client.synced = true;

		var info = {client: client, live: live};
		if (!live && count == 1)
			info.op = op;
		else
			info.board = board;
		hooks.trigger('clientSynced', info, function (err) {
			if (err)
				winston.error(err);
		});
	}
	return true;
}

function setup_imager_relay(cb) {
	var onegai = new imager.Onegai;
	onegai.relay_client_messages();
	onegai.once('relaying', function () {
		onegai.on('message', image_status);
		cb(null);
	});
}

function image_status(client_id, status) {
	if (!check('id', client_id))
		return;
	var client = STATE.clients[client_id];
	if (client) {
		try {
			client.send([0, common.IMAGE_STATUS, status]);
		}
		catch (e) {
			// Swallow EINTR
			// anta baka?
		}
	}
}

function page_nav(thread_count, cur_page, ascending) {
	var page_count = Math.ceil(thread_count / config.THREADS_PER_PAGE);
	page_count = Math.max(page_count, 1);
	var info = {pages: page_count, threads: thread_count,
		cur_page: cur_page, ascending: ascending};

	var step = ascending ? -1 : 1;
	var next = Math.max(cur_page, 0) + step;
	if (next >= 0 && next < page_count)
		info.next_page = 'page' + next;
	var prev = cur_page - step;
	if (prev >= 0 && prev < page_count)
		info.prev_page = 'page' + prev;
	return info;
}

function write_gzip_head(req, resp, headers) {
	var encoding = config.GZIP && req.headers['accept-encoding'];
	if (req.ident.slow || !encoding || encoding.indexOf('gzip') < 0) {
		resp.writeHead(200, headers);
		return resp;
	}
	resp.writeHead(200, _.extend({}, headers, {
		'Content-Encoding': 'gzip',
		Vary: 'Accept-Encoding',
	}));

	var gz = require('zlib').createGzip();
	gz.pipe(resp);
	return gz;
}

function redirect_thread(cb, num, op, tag) {
	if (!tag)
		cb(null, 'redirect', op + '#' + num);
	else
		/* Use a JS redirect to preserve the hash */
		cb(null, 'redirect_js', '../' + tag + '/' + op + '#' + num);
}

// unless imager.config.DAEMON, we deal with image uploads in-process.
if (!imager.is_standalone()) {
	web.route_post(/^\/upload\/$/, require('../imager/daemon').new_upload);
}

web.resource(/^\/$/, function (req, cb) {
	cb(null, 'redirect', config.DEFAULT_BOARD + '/');
});

if (config.DEBUG) {
	/* Shortcuts for convenience */
	winston.warn("Running in (insecure) debug mode.");
	winston.warn("Do not use on the public internet.");
	web.route_get(/^\/login$/, function (req, resp) {
		auth.set_cookie(req, resp, {auth: 'Admin'});
	});
	web.route_get(/^\/mod$/, function (req, resp) {
		auth.set_cookie(req, resp, {auth: 'Moderator'});
	});
}
else {
	/* Production login endpoint */
	web.route_get(/^\/login$/, auth.login);

	if (config.SERVE_STATIC_FILES)
		winston.warn("Recommended: nginx-like webserver instead of SERVE_STATIC_FILES.");
	if (config.SERVE_IMAGES)
		winston.warn("Recommended: nginx-like webserver instead of SERVE_IMAGES.");
}
web.route_get(/^\/logout$/, auth.logout);
web.route_post(/^\/logout$/, auth.logout);

function write_mod_js(resp, ident) {
	if (!RES.modJs) {
		resp.writeHead(500);
		resp.end('Mod js not built?!');
		return;
	}

	var noCacheJs = _.clone(web.noCacheHeaders);
	noCacheJs['Content-Type'] = 'text/javascript; charset=UTF-8';
	resp.writeHead(200, noCacheJs);
	resp.write('(function (IDENT) {');
	resp.write(RES.modJs);
	resp.end('})(' + JSON.stringify(ident) + ');');
}

web.resource_auth(/^\/admin\.js$/, function (req, cb) {
	if (!caps.can_administrate(req.ident))
		cb(404);
	else
		cb(null, 'ok');
},
function (req, resp) {
	write_mod_js(resp, {
		auth: req.ident.auth,
		csrf: req.ident.csrf,
		user: req.ident.user,
	});
});

web.resource_auth(/^\/mod\.js$/, function (req, cb) {
	if (!caps.can_moderate(req.ident))
		cb(404);
	else
		cb(null, 'ok');
},
function (req, resp) {
	write_mod_js(resp, {
		auth: req.ident.auth,
		csrf: req.ident.csrf,
		user: req.ident.user,
	});
});

web.resource(/^\/(\w+)$/, function (req, params, cb) {
	var board = params[1];
	/* If arbitrary boards were allowed, need to escape this: */
	var dest = board + '/';
	if (req.ident.suspension)
		return cb(null, 'redirect', dest); /* TEMP */
	if (!caps.can_ever_access_board(req.ident, board))
		return cb(404);
	cb(null, 'redirect', dest);
});

web.resource(/^\/(\w+)\/live$/, function (req, params, cb) {
	if (req.ident.suspension)
		return cb(null, 'redirect', '.'); /* TEMP */
	if (!caps.can_ever_access_board(req.ident, params[1]))
		return cb(404);
	cb(null, 'redirect', '.');
});

web.resource(/^\/(\w+)\/$/, function (req, params, cb) {
	var board = params[1];
	if (req.ident.suspension)
		return cb(null, 'ok'); /* TEMP */
	if (!caps.can_ever_access_board(req.ident, board))
		return cb(404);

	cb(null, 'ok', {board: board});
},
function (req, resp) {
	/* TEMP */
	if (req.ident.suspension)
		return render_suspension(req, resp);

	var board = this.board;
	var info = {board: board, ident: req.ident, resp: resp};
	hooks.trigger_sync('boardDiversion', info);
	if (info.diverted)
		return;

	var yaku = new db.Yakusoku(board, req.ident);
	yaku.get_tag(-1);
	var paginationHtml;
	yaku.once('begin', function (thread_count) {
		var nav = page_nav(thread_count, -1, board == 'archive');
		var initScript = make_init_script(req.ident);
		render.write_board_head(resp, initScript, board, nav);
		paginationHtml = render.make_pagination_html(nav);
		resp.write(paginationHtml);
		resp.write('<hr>\n');
	});
	resp = write_gzip_head(req, resp, web.noCacheHeaders);
	var opts = {fullLinks: true, board: board};
	render.write_thread_html(yaku, req, resp, opts);
	yaku.once('end', function () {
		resp.write(paginationHtml);
		render.write_page_end(resp, req.ident, false);
		resp.end();
		yaku.disconnect();
	});
	yaku.once('error', function (err) {
		winston.error('index:' + err);
		resp.end();
		yaku.disconnect();
	});
});

web.resource(/^\/(\w+)\/page(\d+)$/, function (req, params, cb) {
	var board = params[1];
	if (!caps.temporal_access_check(req.ident, board))
		return cb(null, 302, '..');
	if (req.ident.suspension)
		return cb(null, 'ok'); /* TEMP */
	if (!caps.can_access_board(req.ident, board))
		return cb(404);
	var page = parseInt(params[2], 10);
	if (page > 0 && params[2][0] == '0') /* leading zeroes? */
		return cb(null, 'redirect', 'page' + page);

	var yaku = new db.Yakusoku(board, req.ident);
	yaku.get_tag(page);
	yaku.once('nomatch', function () {
		cb(null, 302, '.');
		yaku.disconnect();
	});
	yaku.once('begin', function (threadCount) {
		cb(null, 'ok', {
			board: board, page: page, yaku: yaku,
			threadCount: threadCount,
		});
	});
},
function (req, resp) {
	/* TEMP */
	if (req.ident.suspension)
		return render_suspension(req, resp);

	var board = this.board;
	var nav = page_nav(this.threadCount, this.page, board == 'archive');
	resp = write_gzip_head(req, resp, web.noCacheHeaders);
	var initScript = make_init_script(req.ident);
	render.write_board_head(resp, initScript, board, nav);
	var paginationHtml = render.make_pagination_html(nav);
	resp.write(paginationHtml);
	resp.write('<hr>\n');

	var opts = {fullLinks: true, board: board};
	render.write_thread_html(this.yaku, req, resp, opts);
	var self = this;
	this.yaku.once('end', function () {
		resp.write(paginationHtml);
		render.write_page_end(resp, req.ident, false);
		resp.end();
		self.finished();
	});
	this.yaku.once('error', function (err) {
		winston.error('page' + self.page + ': ' + err);
		resp.end();
		self.finished();
	});
},
function () {
	this.yaku.disconnect();
});

web.resource(/^\/(\w+)\/page(\d+)\/$/, function (req, params, cb) {
	if (!caps.temporal_access_check(req.ident, params[1]))
		cb(null, 302, '..');
	else
		cb(null, 'redirect', '../page' + params[2]);
});

web.resource(/^\/(\w+)\/(\d+)$/, function (req, params, cb) {
	var board = params[1];
	if (!caps.temporal_access_check(req.ident, board))
		return cb(null, 302, '.');
	if (req.ident.suspension)
		return cb(null, 'ok'); /* TEMP */
	if (!caps.can_access_board(req.ident, board))
		return cb(404);
	var num = parseInt(params[2], 10);
	if (!num)
		return cb(404);
	else if (params[2][0] == '0')
		return cb(null, 'redirect', '' + num);

	var op, json = web.prefers_json(req.headers.accept);
	if (board == 'graveyard') {
		op = num;
	}
	else {
		op = db.OPs[num];
		if (!op)
			return cb(404);
		if (!json && !db.OP_has_tag(board, op)) {
			var tag = db.first_tag_of(op);
			if (tag) {
				if (!caps.can_access_board(req.ident, tag))
					return cb(404);
				return redirect_thread(cb, num, op, tag);
			}
			else {
				winston.warn("Orphaned post " + num +
					"with tagless OP " + op);
				return cb(404);
			}
		}
		if (!json && op != num)
			return redirect_thread(cb, num, op);
	}
	if (!caps.can_access_thread(req.ident, op))
		return cb(404);
	if (json)
		return cb(null, 'ok', {json: true, num: num});

	var yaku = new db.Yakusoku(board, req.ident);
	var reader = new db.Reader(yaku);
	var opts = {redirect: true};

	var lastN = detect_last_n(req.query);
	if (lastN)
		opts.abbrev = lastN + config.ABBREVIATED_REPLIES;

	if (caps.can_administrate(req.ident) && 'reported' in req.query)
		opts.showDead = true;
	reader.get_thread(board, num, opts);
	reader.once('nomatch', function () {
		cb(404);
		yaku.disconnect();
	});
	reader.once('redirect', function (op, tag) {
		redirect_thread(cb, num, op, tag);
		yaku.disconnect();
	});
	reader.once('begin', function (preThread) {
		var headers = web.noCacheHeaders;
		cb(null, 'ok', {
			headers: headers,
			board: board, op: op,
			subject: preThread.subject,
			yaku: yaku, reader: reader,
			abbrev: opts.abbrev,
		});
	});
},
function (req, resp) {
	/* TEMP */
	if (req.ident.suspension)
		return render_suspension(req, resp);
	if (this.json)
		return write_json_post(req, resp, this.num);

	var board = this.board, op = this.op;

	resp = write_gzip_head(req, resp, this.headers);
	var initScript = make_init_script(req.ident);
	render.write_thread_head(resp, initScript, board, op, {
		subject: this.subject,
		abbrev: this.abbrev,
	});

	var opts = {fullPosts: true, board: board, loadAllPostsLink: true};
	render.write_thread_html(this.reader, req, resp, opts);
	var self = this;
	this.reader.once('end', function () {
		render.write_page_end(resp, req.ident, true);
		resp.end();
		self.finished();
	});
	function on_err(err) {
		winston.error('thread '+num+':', err);
		resp.end();
		self.finished();
	}
	this.reader.once('error', on_err);
	this.yaku.once('error', on_err);
},
function () {
	this.yaku.disconnect();
});

function write_json_post(req, resp, num) {
	var json = {TODO: true};

	var cache = json.editing ? 'no-cache' : 'private, max-age=86400';
	resp = write_gzip_head(req, resp, {
		'Content-Type': 'application/json',
		'Cache-Control': cache,
	});
	resp.end(JSON.stringify(json));
}

function detect_last_n(query) {
	for (var k in query) {
		var m = /^last(\d+)$/.exec(k);
		if (m) {
			var n = parseInt(m[1], 10);
			if (common.reasonable_last_n(n))
				return n;
		}
	}
	return 0;
}

web.resource(/^\/(\w+)\/(\d+)\/$/, function (req, params, cb) {
	if (!caps.temporal_access_check(req.ident, params[1]))
		cb(null, 302, '..');
	else
		cb(null, 'redirect', '../' + params[2]);
});

web.resource(/^\/outbound\/(g|iqdb)\/([\w+\/]{22}\.jpg)$/,
			function (req, params, cb) {
	var thumb = imager.config.MEDIA_URL + 'vint/' + params[2];

	// attempt to make protocol more absolute
	var u = urlParse(thumb, false, true);
	if (!u.protocol) {
		u.protocol = 'http:';
		thumb = u.format();
	}

	var service = params[1] == 'iqdb' ? 'http://iqdb.org/?url='
			: 'https://www.google.com/searchbyimage?image_url=';
	var dest = service + encodeURIComponent(thumb);
	cb(null, 303.1, dest);
});

web.resource(/^\/outbound\/hash\/([\w+\/]{22})$/, function (req, params, cb) {
	var dest = 'http://archive.foolz.us/_/search/image/' + escape(params[1]);
	cb(null, 303.1, dest);
});

web.resource(/^\/outbound\/a\/(\d{0,10})$/, function (req, params, cb) {
	var thread = parseInt(params[1], 10);
	var url = 'https://boards.4chan.org/a/';
	if (thread)
		url += 'thread/' + thread;
	cb(null, 303.1, url);
});

web.resource(/^\/outbound\/sysint\/(\d{0,10})$/, function (req, params, cb) {
	var thread = parseInt(params[1], 10);
	var url = '/sysint/';
	if (thread)
		url +=  thread;
	cb(null, 303.1, url);
});

web.resource(/^\/outbound\/nap\/(\d{0,10})$/, function (req, params, cb) {
	var thread = parseInt(params[1], 10);
	var url = 'https://nineball.party/nap/';
	if (thread)
		url += thread;
	cb(null, 303.1, url);
});

function make_init_script(ident) {
	var secretKey = STATE.hot.connTokenSecretKey;
	if (!ident || !secretKey)
		return '';
	var country = ident.country || 'x';
	var payload = JSON.stringify({
		ip: ident.ip,
		cc: country,
		ts: Date.now(),
	});
	// encrypt payload as 'ctoken'
	var iv = crypto.randomBytes(12);
	var cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv);
	var crypted = cipher.update(payload, 'utf8', 'hex');
	crypted += cipher.final('hex');
	var authTag = cipher.getAuthTag()
	if (authTag.length != 16) throw 'auth tag of unexpected length';
	var combined = iv.toString('hex') + authTag.toString('hex') + crypted;
	return '\t<script>var ctoken = ' + etc.json_paranoid(combined) + ';</script>\n';
}

function decrypt_ctoken(ctoken) {
	var secretKey = STATE.hot.connTokenSecretKey;
	if (!secretKey)
		return null;
	if (ctoken.length < 56) {
		winston.warn('ctoken too short');
		return null;
	}
	var iv = new Buffer(ctoken.slice(0, 24), 'hex');
	if (iv.length != 12) {
		winston.warn('iv not hex');
		return null;
	}
	var authTag = new Buffer(ctoken.slice(24, 56), 'hex');
	if (authTag.length != 16) {
		winston.warn('authTag not hex');
		return null;
	}
	try {
		var decipher = crypto.createDecipheriv('aes-256-gcm', secretKey, iv);
		decipher.setAuthTag(authTag);
		var plain = decipher.update(ctoken.slice(56), 'hex', 'utf8');
		plain += decipher.final('utf8');
		return JSON.parse(plain);
	}
	catch (e) {
		winston.warn(e);
	}
	return null;
}

var TWEET_CACHE = {};
var TWEET_CACHE_LEN = 0;

function expire_tweet(key) {
	if (TWEET_CACHE[key]) {
		delete TWEET_CACHE[key];
		TWEET_CACHE_LEN--;
	}
}

web.resource(/^\/outbound\/tweet\/(\w{1,15}\/status\/\d{4,20})$/,
function (req, params, cb) {
	var url = 'https://twitter.com/' + params[1];
	if (/^\d+$/.test(req.query.s))
		url += '?s=' + req.query.s;
	var theme = req.query.theme == 'dark' ? 'dark' : 'light';
	var params = {
		url: url,
		omit_script: true,
		theme: theme,
	};
	var key = 'tw:'+url;
	if (TWEET_CACHE[key])
		return cb(null, 'ok', TWEET_CACHE[key]);

	request.get({
		uri: 'https://publish.twitter.com/oembed',
		qs: params,
		json: true,
	}, function (err, twResp, json) {
		if (err)
			return cb(err);
		var code = twResp.statusCode;
		if (code < 200 || code >= 300) {
			if (code == 404)
				cb(404);
			else
				cb('twitter returned ' + code);
			return;
		}
		if (!json.html)
			return cb('unexpected tweet form');

		if (!TWEET_CACHE[key] && TWEET_CACHE_LEN < 50) {
			TWEET_CACHE[key] = {json: json};
			TWEET_CACHE_LEN++;
			setTimeout(expire_tweet.bind(null, key), 600*1000);
		}

		cb(null, 'ok', {json: json});
	});
}, function (req, resp) {
	resp = write_gzip_head(req, resp, {
		'Content-Type': 'application/json',
		'Cache-Control': config.DEBUG ? 'no-cache' : 'public, max-age=600',
	});
	resp.end(JSON.stringify(this.json));
});

web.route_get_auth(/^\/dead\/(src|thumb|mid)\/(\w+\.\w{3})$/,
			function (req, resp, params) {
	if (!caps.can_administrate(req.ident))
		return web.render_404(resp);
	imager.send_dead_image(params[1], params[2], resp);
});


/* Must be prepared to receive callback instantly */
function valid_links(frag, state, ident, callback) {
	var links = {};
	var onee = new common.OneeSama(function (num) {
		var op = db.OPs[num];
		if (op && caps.can_access_thread(ident, op))
			links[num] = db.OPs[num];
	});
	onee.callback = function (frag) {};
	onee.state = state;
	onee.fragment(frag);
	callback(null, _.isEmpty(links) ? null : links);
}

var insertSpec = [{
	frag: 'opt string',
	image: 'opt string',
	nonce: 'id',
	op: 'opt id',
	name: 'opt string',
	email: 'opt string',
	auth: 'opt string',
	subject: 'opt string',
	flavor: 'opt string',
}];

dispatcher[common.INSERT_POST] = function (msg, client) {
	if (!check(insertSpec, msg))
		return false;
	msg = msg[0];
	if (client.post)
		return update_post(msg.frag, client);
	if (!caps.can_access_board(client.ident, client.board))
		return false;
	var frag = msg.frag;
	if (frag && /^\s*$/g.test(frag))
		return false;
	if (!frag && !msg.image)
		return false;
	if (config.DEBUG)
		debug_command(client, frag);

	allocate_post(msg, client, function (err) {
		if (err)
			client.kotowaru(Muggle("Allocation failure.", err));
	});
	return true;
}

function inactive_board_check(client) {
	if (caps.can_administrate(client.ident))
		return true;
	return ['graveyard', 'archive'].indexOf(client.board) == -1;
}

function allocate_post(msg, client, callback) {
	if (client.post)
		return callback(Muggle("Already have a post."));
	if (!inactive_board_check(client))
		return callback(Muggle("Can't post here."));
	var post = {time: Date.now(), nonce: msg.nonce};
	var body = '';
	var ip = client.ident.ip;
	var extra = {ip: ip, board: client.board};
	var image_alloc;
	if (msg.image) {
		if (!/^\d+$/.test(msg.image))
			return callback(Muggle('Expired image token.'));
		image_alloc = msg.image;
	}
	if (msg.frag) {
		if (/^\s*$/g.test(msg.frag))
			return callback(Muggle('Bad post body.'));
		if (msg.frag.length > common.MAX_POST_CHARS)
			return callback(Muggle('Post is too long.'));
		body = msg.frag.replace(config.EXCLUDE_REGEXP, '');
	}

	if (msg.op) {
		if (db.OPs[msg.op] != msg.op)
			return callback(Muggle('Thread does not exist.'));
		if (!db.OP_has_tag(extra.board, msg.op))
			return callback(Muggle('Thread does not exist.'));
		post.op = msg.op;
	}
	else {
		if (!image_alloc)
			return callback(Muggle('Image missing.'));
		var subject = (msg.subject || '').trim();
		subject = subject.replace(config.EXCLUDE_REGEXP, '');
		subject = subject.replace(/[「」]/g, '');
		subject = subject.slice(0, config.SUBJECT_MAX_LENGTH);
		if (subject)
			post.subject = subject;
	}

	/* TODO: Check against client.watching? */
	if (msg.name) {
		var parsed = common.parse_name(msg.name);
		post.name = parsed[0];
		var spec = STATE.hot.SPECIAL_TRIPCODES;
		if (spec && parsed[1] && parsed[1] in spec) {
			post.trip = spec[parsed[1]];
		}
		else if (parsed[1] || parsed[2]) {
			var trip = tripcode.hash(parsed[1], parsed[2]);
			if (trip)
				post.trip = trip;
		}
	}
	if (msg.email) {
		post.email = msg.email.trim().substr(0, 320);
		if (common.is_noko(post.email))
			delete post.email;
	}
	if (msg.flavor && /^\w+$/.test(msg.flavor)) {
		if (msg.flavor == 'floop')
			post.flavor = 'floop';
	}
	post.state = common.initial_state();

	if ('auth' in msg) {
		if (!msg.auth || !client.ident
				|| msg.auth !== client.ident.auth)
			return callback(Muggle('Bad auth.'));
		post.auth = msg.auth;
	}

	if (post.op)
		client.db.check_thread_locked(post.op, checked);
	else
		client.db.check_throttle(ip, checked);

	function checked(err) {
		if (err)
			return callback(err);
		client.db.reserve_post(post.op, ip, got_reservation);
	}

	function got_reservation(err, num) {
		if (err)
			return callback(err);
		if (!client.synced)
			return callback(Muggle('Dropped; post aborted.'));
		if (client.post)
			return callback(Muggle('Already have a post.'));

		if (body.length && is_game_board(client.board))
			amusement.roll_dice(body, post, extra);

		client.post = post;
		post.num = num;
		var supplements = {
			links: valid_links.bind(null, body, post.state,
					client.ident),
		};
		if (image_alloc)
			supplements.image = imager.obtain_image_alloc.bind(
					null, image_alloc);
		async.parallel(supplements, got_supplements);
	}
	function got_supplements(err, rs) {
		if (err) {
			if (client.post === post)
				client.post = null;
			return callback(Muggle("Attachment error.", err));
		}
		if (!client.synced)
			return callback(Muggle('Dropped; post aborted.'));
		post.links = rs.links;
		if (rs.image)
			extra.image_alloc = rs.image;
		client.db.insert_post(post, body, extra, inserted);
	}
	function inserted(err) {
		if (err) {
			if (client.post === post)
				client.post = null;
			return callback(Muggle("Couldn't allocate post.",err));
		}
		post.body = body;
		callback(null);
	}
	return true;
}

function update_post(frag, client) {
	if (typeof frag != 'string')
		return false;
	if (config.DEBUG)
		debug_command(client, frag);
	frag = frag.replace(config.EXCLUDE_REGEXP, '');
	var post = client.post;
	if (!post)
		return false;
	var limit = common.MAX_POST_CHARS;
	if (frag.length > limit || post.length >= limit)
		return false;
	var combined = post.length + frag.length;
	if (combined > limit)
		frag = frag.substr(0, combined - limit);
	var extra = {ip: client.ident.ip};
	if (is_game_board(client.board))
		amusement.roll_dice(frag, post, extra);
	post.body += frag;
	/* imporant: broadcast prior state */
	var old_state = post.state.slice();

	valid_links(frag, post.state, client.ident, function (err, links) {
		if (err)
			links = null; /* oh well */
		if (links) {
			if (!post.links)
				post.links = {};
			var new_links = {};
			for (var k in links) {
				var link = links[k];
				if (post.links[k] != link) {
					post.links[k] = link;
					new_links[k] = link;
				}
			}
			extra.links = links;
			extra.new_links = new_links;
		}

		client.db.append_post(post, frag, old_state, extra,
					function (err) {
			if (err)
				client.kotowaru(Muggle("Couldn't add text.",
						err));
		});
	});
	return true;
}
dispatcher[common.UPDATE_POST] = update_post;

function debug_command(client, frag) {
	if (!frag)
		return;
	if (/\bfail\b/.test(frag))
		client.kotowaru(Muggle("Failure requested."));
	else if (/\bclose\b/.test(frag))
		client.socket.close();
}

dispatcher[common.FINISH_POST] = function (msg, client) {
	if (!check([], msg))
		return false;
	if (!client.post)
		return true; /* whatever */
	client.finish_post(function (err) {
		if (err)
			client.kotowaru(Muggle("Couldn't finish post.", err));
	});
	return true;
}

dispatcher[common.DELETE_POSTS] = caps.mod_handler(function (nums, client) {
	if (!inactive_board_check(client))
		return client.kotowaru(Muggle("Couldn't delete."));
	/* Omit to-be-deleted posts that are inside to-be-deleted threads */
	var ops = {}, OPs = db.OPs;
	nums.forEach(function (num) {
		if (num == OPs[num])
			ops[num] = 1;
	});
	nums = nums.filter(function (num) {
		var op = OPs[num];
		return op == num || !(OPs[num] in ops);
	});

	client.db.remove_posts(nums, function (err, dels) {
		if (err)
			client.kotowaru(Muggle("Couldn't delete.", err));
	});
});

dispatcher[common.LOCK_THREAD] = caps.mod_handler(function (nums, client) {
	if (!inactive_board_check(client))
		return client.kotowaru(Muggle("Couldn't (un)lock thread."));
	nums = nums.filter(function (op) { return db.OPs[op] == op; });
	async.forEach(nums, client.db.toggle_thread_lock.bind(client.db),
				function (err) {
		if (err)
			client.kotowaru(Muggle(
					"Couldn't (un)lock thread.", err));
	});
});

dispatcher[common.DELETE_IMAGES] = caps.mod_handler(function (nums, client) {
	if (!inactive_board_check(client))
		return client.kotowaru(Muggle("Couldn't delete images."));
	client.db.remove_images(nums, function (err, dels) {
		if (err)
			client.kotowaru(Muggle("Couldn't delete images.",err));
	});
});

dispatcher[common.INSERT_IMAGE] = function (msg, client) {
	if (!check(['string'], msg))
		return false;
	var alloc = msg[0];
	if (!client.post || client.post.image)
		return false;
	imager.obtain_image_alloc(alloc, function (err, alloc) {
		if (err)
			return client.kotowaru(Muggle("Image lost.", err));
		if (!client.post || client.post.image)
			return;
		client.db.add_image(client.post, alloc, client.ident.ip,
					function (err) {
			if (err)
				client.kotowaru(Muggle(
					"Image insertion problem.", err));
		});
	});
	return true;
};

dispatcher[common.SPOILER_IMAGES] = caps.mod_handler(function (nums, client) {
	if (!inactive_board_check(client))
		return client.kotowaru(Muggle("Couldn't spoiler images."));
	client.db.force_image_spoilers(nums, function (err) {
		if (err)
			client.kotowaru(Muggle("Couldn't spoiler images.",
					err));
	});
});

dispatcher[common.EXECUTE_JS] = function (msg, client) {
	if (!caps.can_administrate(client.ident))
		return false;
	if (!check(['id'], msg))
		return false;
	var op = msg[0];
	client.db.set_fun_thread(op, function (err) {
		if (err)
			client.kotowaru(err);
	});
	return true;
};

function is_game_board(board) {
	return config.GAME_BOARDS.indexOf(board) >= 0;
}

function render_suspension(req, resp) {
setTimeout(function () {
	var ban = req.ident.suspension, tmpl = RES.suspensionTmpl;
	resp.writeHead(200, web.noCacheHeaders);
	resp.write(tmpl[0]);
	resp.write(escape(ban.why || ''));
	resp.write(tmpl[1]);
	resp.write(escape(ban.until || ''));
	resp.write(tmpl[2]);
	resp.write(escape(STATE.hot.EMAIL || '<missing>'));
	resp.end(tmpl[3]);
}, 2000);
}

function get_sockjs_script_sync() {
	var src = fs.readFileSync('tmpl/index.html', 'UTF-8');
	return src.match(/sockjs-[\d.]+(?:\.min)?\.js/)[0];
}

function sockjs_log(sev, message) {
	if (message.length > 80)
		message = message.slice(0, 60) + '[\u2026]' + message.slice(message.length - 14);
	if (sev == 'info')
		winston.verbose(message);
	else if (sev == 'error')
		winston.error(message);
}
if (config.DEBUG) {
	winston.remove(winston.transports.Console);
	winston.add(winston.transports.Console, {level: 'verbose'});
}
else {
	winston.add(winston.transports.File, {level: 'warn', filename: 'error.log'});
}

function start_server() {
	var is_unix_socket = (typeof config.LISTEN_PORT == 'string');
	if (is_unix_socket) {
		try { fs.unlinkSync(config.LISTEN_PORT); } catch (e) {}
	}
	web.server.listen(config.LISTEN_PORT, config.LISTEN_HOST);
	if (is_unix_socket) {
		fs.chmodSync(config.LISTEN_PORT, '777'); // TEMP
	}


	var sockjsPath = 'js/' + get_sockjs_script_sync();
	var sockOpts = {
		sockjs_url: imager.config.MEDIA_URL + sockjsPath,
		prefix: config.SOCKET_PATH,
		jsessionid: false,
		log: sockjs_log,
		websocket: config.USE_WEBSOCKETS,
	};
	var sockJs = require('sockjs').createServer(sockOpts);
	web.server.on('upgrade', function (req, resp) {
		resp.end();
	});
	sockJs.installHandlers(web.server);

	sockJs.on('connection', function (socket) {
		var ip = socket.remoteAddress;
		var country;
		if (config.TRUST_X_FORWARDED_FOR) {
			var ff = web.parse_forwarded_for(
					socket.headers['x-forwarded-for']);
			if (ff)
				ip = ff;
		}
		if (!ip) {
			winston.warn('no ip from ' + socket);
			socket.close();
			return;
		}

		// parse ctoken
		var url = urlParse(socket.url, true);
		if (url.query && url.query.ctoken) {
			var token = decrypt_ctoken(url.query.ctoken);
			if (token) {
				if (token.ts + 24*60*60*1000 < Date.now()) {
					// token expired, ask for a new one?
					winston.warn('ctoken: expired');
				}
				if (ip != token.ip)
					winston.info('ctoken: ' + ip + ' != ' + token.ip);
				country = token.cc;
			}
			else {
				winston.log('ctoken: invalid from ' + ip);
			}
		} else {
			winston.warn('ctoken: MISSING from ' + ip);
		}

		var client = new okyaku.Okyaku(socket, ip, country);
		socket.on('data', client.on_message.bind(client));
		socket.on('close', client.on_close.bind(client));
	});

	process.on('SIGHUP', hot_reloader);
	db.on_pub('reloadHot', hot_reloader);

	if (config.DAEMON) {
		var cfg = config.DAEMON;
		var daemon = require('daemon');
		var pid = daemon.start(process.stdout.fd, process.stderr.fd);
		var lock = config.PID_FILE;
		daemon.lock(lock);
		winston.remove(winston.transports.Console);
		return;
	}

	process.nextTick(non_daemon_pid_setup);

	winston.info('Listening on '
			+ (config.LISTEN_HOST || '')
			+ (is_unix_socket ? '' : ':')
			+ (config.LISTEN_PORT + '.'));
}

function hot_reloader() {
	STATE.reload_hot_resources(function (err) {
		if (err) {
			winston.error("Error trying to reload:");
			winston.error(err);
			return;
		}
		okyaku.scan_client_caps();
		winston.info('Reloaded initial state.');
	});
}

function non_daemon_pid_setup() {
	var pidFile = config.PID_FILE;
	fs.writeFile(pidFile, process.pid+'\n', function (err) {
		if (err)
			return winston.warn("Couldn't write pid: " + err);
		process.once('SIGINT', delete_pid);
		process.once('SIGTERM', delete_pid);
		winston.info('PID ' + process.pid + ' written in ' + pidFile);
	});

	function delete_pid() {
		try {
			fs.unlinkSync(pidFile);
		}
		catch (e) { }
		process.exit(1);
	}
}

if (require.main == module) {
	if (!process.getuid())
		throw new Error("Refusing to run as root.");
	if (!tripcode.setSalt(config.SECURE_SALT))
		throw "Bad SECURE_SALT";
	async.series([
		imager.make_media_dirs,
		setup_imager_relay,
		STATE.reload_hot_resources,
		db.track_OPs,
	], function (err) {
		if (err)
			throw err;

		var yaku = new db.Yakusoku(null, db.UPKEEP_IDENT);
		var onegai;
		var writes = [];
		if (!config.READ_ONLY) {
			writes.push(yaku.finish_all.bind(yaku));
			if (!imager.is_standalone()) {
				onegai = new imager.Onegai;
				writes.push(onegai.delete_temporaries.bind(
						onegai));
			}
		}
		async.series(writes, function (err) {
			if (err)
				throw err;
			yaku.disconnect();
			if (onegai)
				onegai.disconnect();
			process.nextTick(start_server);
		});
	});
}
