/** @module */

'use strict';

require('../../core-upgrade.js');

var childProcess = require('child_process');
var corepath = require('path');
var uuidv1 = require('uuid/v1');
var uuidv4 = require('uuid/v4');
var Negotiator = require('negotiator');
var semver = require('semver');

var pkg = require('../../package.json');
var apiUtils = require('./apiUtils.js');
var ContentUtils = require('../utils/ContentUtils.js').ContentUtils;
var DOMDataUtils = require('../utils/DOMDataUtils.js').DOMDataUtils;
var DOMUtils = require('../utils/DOMUtils.js').DOMUtils;
var MWParserEnv = require('../config/MWParserEnvironment.js').MWParserEnvironment;
var Promise = require('../utils/promise.js');
var LogData = require('../logger/LogData.js').LogData;
var TemplateRequest = require('../mw/ApiRequest.js').TemplateRequest;

/**
 * Create the API routes.
 * @param {ParsoidConfig} parsoidConfig
 * @param {Logger} processLogger
 * @param {Object} parsoidOptions
 * @param {Function} parse
 */
module.exports = function routes(parsoidConfig, processLogger, parsoidOptions, parse) {
	var routes = {};
	var metrics = parsoidConfig.metrics;

	// This helper is only to be used in middleware, before an environment
	// is setup.  The logger doesn't emit the expected location info.
	// You probably want `apiUtils.fatalRequest` instead.
	var errOut = function(res, text, httpStatus) {
		processLogger.log('fatal/request', text);
		apiUtils.errorResponse(res, text, httpStatus || 404);
	};

	// Middlewares

	var errorEncoding = new Map(Object.entries({
		'pagebundle': 'json',
		'html': 'html',
		'wikitext': 'plain',
		'lint': 'json',
	}));

	var validGets = new Set(['wikitext', 'html', 'pagebundle']);

	var wikitextTransforms = ['html', 'pagebundle'];
	if (parsoidConfig.linting) { wikitextTransforms.push('lint'); }

	var validTransforms = new Map(Object.entries({
		'wikitext': wikitextTransforms,
		'html': ['wikitext'],
		'pagebundle': ['wikitext', 'pagebundle'],
	}));

	routes.v3Middle = function(req, res, next) {
		res.locals.titleMissing = !req.params.title;
		res.locals.pageName = req.params.title || '';
		res.locals.oldid = req.params.revision || null;

		// "body_only" flag to return just the body (instead of the entire HTML doc)
		// We would like to deprecate use of this flag: T181657
		res.locals.body_only = !!(
			req.query.body_only || req.body.body_only
		);

		var opts = Object.assign({
			from: req.params.from,
			format: req.params.format,
		}, req.body);

		res.locals.errorEnc = errorEncoding.get(opts.format) || 'plain';

		if (req.method === 'GET' || req.method === 'HEAD') {
			if (!validGets.has(opts.format)) {
				return errOut(res, 'Invalid page format: ' + opts.format);
			}
		} else if (req.method === 'POST') {
			var transforms = validTransforms.get(opts.from);
			if (transforms === undefined || !transforms.includes(opts.format)) {
				return errOut(res, 'Invalid transform: ' + opts.from + '/to/' + opts.format);
			}
		} else {
			return errOut(res, 'Request method not supported.');
		}

		var iwp = parsoidConfig.getPrefixFor(req.params.domain);
		if (!iwp) {
			return errOut(res, 'Invalid domain: ' + req.params.domain);
		}
		res.locals.iwp = iwp;

		// "subst" flag to perform {{subst:}} template expansion
		res.locals.subst = !!(req.query.subst || req.body.subst);
		// This is only supported for the html format
		if (res.locals.subst && opts.format !== 'html') {
			return errOut(res, 'Substitution is only supported for the HTML format.', 501);
		}

		if (req.method === 'POST') {
			var original = opts.original || {};
			if (original.revid) {
				res.locals.oldid = original.revid;
			}
			if (original.title) {
				res.locals.titleMissing = false;
				res.locals.pageName = original.title;
			}
		}

		if (req.headers['content-language']) {
			res.locals.pagelanguage = req.headers['content-language'];
		}

		res.locals.envOptions = {
			// We use `prefix` but ought to use `domain` (T206764)
			prefix: res.locals.iwp,
			domain: req.params.domain,
			pageName: res.locals.pageName,
			cookie: req.headers.cookie,
			reqId: req.headers['x-request-id'],
			userAgent: req.headers['user-agent'],
			htmlVariantLanguage: req.headers['accept-language'] || null,
		};

		res.locals.opts = opts;
		next();
	};

	var activeRequests = new Map();
	routes.updateActiveRequests = function(req, res, next) {
		if (parsoidConfig.useWorker) { return next(); }
		var buf = Buffer.alloc(16);
		uuidv4(null, buf);
		var id = buf.toString('hex');
		var location = res.locals.iwp + '/' + res.locals.pageName +
			(res.locals.oldid ? '?oldid=' + res.locals.oldid : '');
		activeRequests.set(id, {
			location: location,
			timeout: setTimeout(function() {
				// This is pretty harsh but was, in effect, what we were doing
				// before with the cpu timeouts.  Shoud be removed with
				// T123446 and T110961.
				processLogger.log('fatal', 'Timed out processing: ' + location);
				// `processLogger` is async; give it some time to deliver the msg.
				setTimeout(function() { process.exit(1); }, 100);
			}, parsoidConfig.timeouts.request),
		});
		var current = [];
		activeRequests.forEach(function(val) {
			current.push(val.location);
		});
		process.emit('service_status', current);
		res.once('finish', function() {
			clearTimeout(activeRequests.get(id).timeout);
			activeRequests.delete(id);
		});
		next();
	};

	// FIXME: Preferably, a parsing environment would not be constructed
	// outside of the parser and used here in the http api.  It should be
	// noted that only the properties associated with the `envOptions` are
	// used in the actual parse.
	routes.parserEnvMw = function(req, res, next) {
		var errBack = Promise.async(function *(logData) {
			if (!res.headersSent) {
				var socket = res.socket;
				if (res.finished || (socket && !socket.writable)) {
					/* too late to send an error response, alas */
				} else {
					try {
						yield new Promise(function(resolve, reject) {
							res.once('finish', resolve);
							apiUtils.errorResponse(res, logData.fullMsg(), logData.flatLogObject().httpStatus);
						});
					} catch (e) {
						console.error(e.stack || e);
						res.end();
						throw e;
					}
				}
			}
		});
		MWParserEnv.getParserEnv(parsoidConfig, res.locals.envOptions)
		.then(function(env) {
			env.logger.registerBackend(/fatal(\/.*)?/, errBack);
			res.locals.env = env;
			next();
		})
		.catch(function(err) {
			processLogger.log('fatal/request', err);
			// Workaround how logdata flatten works so that the error object is
			// recursively flattened and a stack trace generated for this.
			return errBack(new LogData('error', ['error:', err, 'path:', req.path]));
		}).done();
	};

	routes.acceptable = function(req, res, next) {
		var env = res.locals.env;
		var opts = res.locals.opts;

		if (opts.format === 'wikitext') {
			return next();
		}

		// Parse accept header
		var negotiator = new Negotiator(req);
		var acceptableTypes = negotiator.mediaTypes(undefined, {
			detailed: true,
		});

		// Validate and set the content version
		if (!apiUtils.validateAndSetOutputContentVersion(res, acceptableTypes)) {
			var text = env.availableVersions.reduce(function(prev, curr) {
				switch (opts.format) {
					case 'html':
						prev += apiUtils.htmlContentType(curr);
						break;
					case 'pagebundle':
						prev += apiUtils.pagebundleContentType(curr);
						break;
					default:
						console.assert(false, `Unexpected format: ${opts.format}`);
				}
				return `${prev}\n`;
			}, 'Not acceptable.\n');
			return apiUtils.fatalRequest(env, text, 406);
		}

		next();
	};

	// Routes

	routes.home = function(req, res) {
		apiUtils.renderResponse(res, 'home', { dev: parsoidConfig.devAPI });
	};

	// robots.txt: no indexing.
	routes.robots = function(req, res) {
		apiUtils.plainResponse(res, 'User-agent: *\nDisallow: /\n');
	};

	// Return Parsoid version based on package.json + git sha1 if available
	var versionCache;
	routes.version = function(req, res) {
		if (!versionCache) {
			versionCache = Promise.resolve({
				name: pkg.name,
				version: pkg.version,
			}).then(function(v) {
				return Promise.promisify(
					childProcess.execFile, ['stdout', 'stderr'], childProcess
				)('git', ['rev-parse', 'HEAD'], {
					cwd: corepath.join(__dirname, '..'),
				}).then(function(out) {
					v.sha = out.stdout.slice(0, -1);
					return v;
				}, function(err) {  // eslint-disable-line
					/* ignore the error, maybe this isn't a git checkout */
					return v;
				});
			});
		}
		return versionCache.then(function(v) {
			apiUtils.jsonResponse(res, v);
		});
	};

	// v3 Routes

	// Spec'd in https://phabricator.wikimedia.org/T75955 and the API tests.

	var wt2html = Promise.async(function *(req, res, wt, reuseExpansions) {
		var env = res.locals.env;
		var opts = res.locals.opts;
		var oldid = res.locals.oldid;
		var target = env.normalizeAndResolvePageTitle();

		var pageBundle = !!(res.locals.opts && res.locals.opts.format === 'pagebundle');

		// Performance Timing options
		var startTimers = new Map();

		if (metrics) {
			// init refers to time elapsed before parsing begins
			startTimers.set('wt2html.init', Date.now());
			startTimers.set('wt2html.total', Date.now());
			if (semver.neq(env.outputContentVersion, MWParserEnv.prototype.availableVersions[0])) {
				metrics.increment('wt2html.parse.version.notdefault');
			}
		}

		if (typeof wt !== 'string' && !oldid) {
			// Redirect to the latest revid
			yield TemplateRequest.setPageSrcInfo(env, target);
			return apiUtils.redirectToOldid(req, res);
		}

		// Calling this `wikitext` so that it's easily distinguishable.
		// It may be modified by substTopLevelTemplates.
		var wikitext;
		var doSubst = (typeof wt === 'string' && res.locals.subst);
		if (doSubst) {
			wikitext = yield apiUtils.substTopLevelTemplates(env, target, wt);
		} else {
			wikitext = wt;
		}

		// Follow redirects if asked
		if (parsoidConfig.devAPI && req.query.follow_redirects) {
			// Get localized redirect matching regexp
			var reSrc = env.conf.wiki.getMagicWordMatcher('redirect').source;
			reSrc = '^[ \\t\\n\\r\\0\\x0b]*' +
				reSrc.substring(1, reSrc.length - 1) + // Strip ^ and $
				'[ \\t\\n\\r\\x0c]*(?::[ \\t\\n\\r\\x0c]*)?' +
				'\\[\\[([^\\]]+)\\]\\]';
			var re = new RegExp(reSrc, 'i');
			var s = wikitext;
			if (typeof wikitext !== 'string') {
				yield TemplateRequest.setPageSrcInfo(env, target, oldid);
				s = env.page.src;
			}
			var redirMatch = s.match(re);
			if (redirMatch) {
				return apiUtils._redirectToPage(redirMatch[2], req, res);
			}
		}

		var envOptions = Object.assign({
			pageBundle: pageBundle,
			// Set data-parsoid to be discarded, so that the subst'ed
			// content is considered new when it comes back.
			discardDataParsoid: doSubst,
		}, res.locals.envOptions);

		// VE, the only client using body_only property,
		// doesn't want section tags when this flag is set.
		// (T181226)
		if (res.locals.body_only) {
			envOptions.wrapSections = false;
		}

		if (typeof wikitext === 'string') {
			// Don't cache requests when wt is set in case somebody uses
			// GET for wikitext parsing
			apiUtils.setHeader(res, 'Cache-Control', 'private,no-cache,s-maxage=0');
		} else if (oldid) {
			envOptions.pageWithOldid = true;
			if (req.headers.cookie) {
				// Don't cache requests with a session.
				apiUtils.setHeader(res, 'Cache-Control', 'private,no-cache,s-maxage=0');
			}
			// Indicate the MediaWiki revision in a header as well for
			// ease of extraction in clients.
			apiUtils.setHeader(res, 'content-revision-id', oldid);
		} else {
			console.assert(false, 'Should be unreachable');
		}

		if (metrics) {
			var mstr = envOptions.pageWithOldid ? 'pageWithOldid' : 'wt';
			metrics.endTiming(`wt2html.${mstr}.init`, startTimers.get('wt2html.init'));
			startTimers.set(`wt2html.${mstr}.parse`, Date.now());
		}

		var out = yield parse({
			input: wikitext,
			mode: 'wt2html',
			parsoidOptions: parsoidOptions,
			envOptions: envOptions,
			oldid: oldid,
			contentmodel: opts.contentmodel,
			outputContentVersion: env.outputContentVersion,
			body_only: res.locals.body_only,
			cacheConfig: true,
			reuseExpansions: reuseExpansions,
			pagelanguage: res.locals.pagelanguage,
		});
		if (opts.format === 'lint') {
			apiUtils.jsonResponse(res, out.lint);
		} else {
			if (req.method === 'GET') {
				const tid = uuidv1();
				apiUtils.setHeader(res, 'Etag', `W/"${oldid}/${tid}"`);
			}
			apiUtils.wt2htmlRes(res, out.html, out.pb, out.contentmodel, out.headers, env.outputContentVersion);
		}
		var html = out.html;
		if (metrics) {
			if (startTimers.has('wt2html.wt.parse')) {
				metrics.endTiming(
					'wt2html.wt.parse', startTimers.get('wt2html.wt.parse')
				);
				metrics.timing('wt2html.wt.size.output', html.length);
			} else if (startTimers.has('wt2html.pageWithOldid.parse')) {
				metrics.endTiming(
					'wt2html.pageWithOldid.parse',
					startTimers.get('wt2html.pageWithOldid.parse')
				);
				metrics.timing('wt2html.pageWithOldid.size.output', html.length);
			}
			metrics.endTiming('wt2html.total', startTimers.get('wt2html.total'));
		}
	});

	var html2wt = Promise.async(function *(req, res, html) {
		var env = res.locals.env;
		var opts = res.locals.opts;

		var envOptions = Object.assign({
			scrubWikitext: apiUtils.shouldScrub(req, env.scrubWikitext),
		}, res.locals.envOptions);

		// Performance Timing options
		var startTimers = new Map();

		if (metrics) {
			startTimers.set('html2wt.init', Date.now());
			startTimers.set('html2wt.total', Date.now());
			startTimers.set('html2wt.init.domparse', Date.now());
		}

		var doc = DOMUtils.parseHTML(html);

		// send domparse time, input size and init time to statsd/Graphite
		// init time is the time elapsed before serialization
		// init.domParse, a component of init time, is the time elapsed
		// from html string to DOM tree
		if (metrics) {
			metrics.endTiming('html2wt.init.domparse',
				startTimers.get('html2wt.init.domparse'));
			metrics.timing('html2wt.size.input', html.length);
			metrics.endTiming('html2wt.init', startTimers.get('html2wt.init'));
		}

		var original = opts.original;
		var oldBody, origPb;

		// Get the content version of the edited doc, if available
		const vEdited = DOMUtils.extractInlinedContentVersion(doc);

		// Check for version mismatches between original & edited doc
		if (!(original && original.html)) {
			env.inputContentVersion = vEdited || env.inputContentVersion;
		} else {
			var vOriginal = apiUtils.versionFromType(original.html);
			if (vOriginal === null) {
				return apiUtils.fatalRequest(env, 'Content-type of original html is missing.', 400);
			}
			if (vEdited === null) {
				// If version of edited doc is unavailable we assume
				// the edited doc is derived from the original doc.
				// No downgrade necessary
				env.inputContentVersion = vOriginal;
			} else if (vEdited === vOriginal) {
				// No downgrade necessary
				env.inputContentVersion = vOriginal;
			} else {
				env.inputContentVersion = vEdited;
				// We need to downgrade the original to match the the edited doc's version.
				var downgrade = apiUtils.findDowngrade(vOriginal, vEdited);
				if (downgrade && opts.from === 'pagebundle') {  // Downgrades are only for pagebundle
					var oldDoc;
					({ doc: oldDoc, pb: origPb } = apiUtils.doDowngrade(downgrade, metrics, env, original, vOriginal));
					oldBody = oldDoc.body;
				} else {
					return apiUtils.fatalRequest(env,
						`Modified (${vEdited}) and original (${vOriginal}) html are of different type, and no path to downgrade.`,
					400);
				}
			}
		}

		if (metrics) {
			var ver = env.hasOwnProperty('inputContentVersion') ? env.inputContentVersion : 'default';
			metrics.increment('html2wt.original.version.' + ver);
			if (!vEdited) { metrics.increment('html2wt.original.version.notinline'); }
		}

		// Pass along the determined original version to the worker
		envOptions.inputContentVersion = env.inputContentVersion;

		var pb;

		// If available, the modified data-mw blob is applied, while preserving
		// existing inline data-mw.  But, no data-parsoid application, since
		// that's internal, we only expect to find it in its original,
		// unmodified form.
		if (opts.from === 'pagebundle' && opts['data-mw'] &&
				semver.satisfies(env.inputContentVersion, '^999.0.0')) {
			// `opts` isn't a revision, but we'll find a `data-mw` there.
			pb = apiUtils.extractPageBundle(opts);
			pb.parsoid = { ids: {} };  // So it validates
			apiUtils.validatePageBundle(pb, env.inputContentVersion);
			DOMDataUtils.applyPageBundle(doc, pb);
		}

		var oldhtml;
		var oldtext = null;

		if (original) {
			if (opts.from === 'pagebundle') {
				// Apply the pagebundle to the parsed doc.  This supports the
				// simple edit scenarios where data-mw might not necessarily
				// have been retrieved.
				if (!origPb) { origPb = apiUtils.extractPageBundle(original); }
				pb = origPb;
				// However, if a modified data-mw was provided,
				// original data-mw is omitted to avoid losing deletions.
				if (opts['data-mw'] &&
						semver.satisfies(env.inputContentVersion, '^999.0.0')) {
					// Don't modify `origPb`, it's used below.
					pb = { parsoid: pb.parsoid, mw: { ids: {} } };
				}
				apiUtils.validatePageBundle(pb, env.inputContentVersion);
				DOMDataUtils.applyPageBundle(doc, pb);
			}

			// If we got original src, set it
			if (original.wikitext) {
				// Don't overwrite env.page.meta!
				oldtext = original.wikitext.body;
			}

			// If we got original html, parse it
			if (original.html) {
				if (!oldBody) { oldBody = DOMUtils.parseHTML(original.html.body).body; }
				if (opts.from === 'pagebundle') {
					apiUtils.validatePageBundle(origPb, env.inputContentVersion);
					DOMDataUtils.applyPageBundle(oldBody.ownerDocument, origPb);
				}
				oldhtml = ContentUtils.toXML(oldBody);
			}
		}

		// As per https://www.mediawiki.org/wiki/Parsoid/API#v1_API_entry_points
		//   "Both it and the oldid parameter are needed for
		//    clean round-tripping of HTML retrieved earlier with"
		// So, no oldid => no selser
		var hasOldId = !!res.locals.oldid;
		var useSelser = hasOldId && parsoidConfig.useSelser;

		var selser;
		if (useSelser) {
			selser = { oldtext: oldtext, oldhtml: oldhtml };
		}

		var out = yield parse({
			input: ContentUtils.toXML(doc),
			mode: useSelser ? 'selser' : 'html2wt',
			parsoidOptions: parsoidOptions,
			envOptions: envOptions,
			oldid: res.locals.oldid,
			selser: selser,
			contentmodel: opts.contentmodel ||
				(opts.original && opts.original.contentmodel),
			cacheConfig: true,
		});
		if (metrics) {
			metrics.endTiming(
				'html2wt.total', startTimers.get('html2wt.total')
			);
			metrics.timing('html2wt.size.output', out.wt.length);
		}
		apiUtils.plainResponse(
			res, out.wt, undefined, apiUtils.wikitextContentType(env)
		);
	});

	var languageConversion = Promise.async(function *(res, revision, contentmodel) {
		var env = res.locals.env;
		var opts = res.locals.opts;

		const target = opts.updates.variant.target || res.locals.envOptions.htmlVariantLanguage;
		const source = opts.updates.variant.source;

		if (typeof target !== 'string') {
			return apiUtils.fatalRequest(env, 'Target variant is required.', 400);
		}
		if (!(source === null || source === undefined || typeof source === 'string')) {
			return apiUtils.fatalRequest(env, 'Bad source variant.', 400);
		}

		var pb = apiUtils.extractPageBundle(revision);
		// We deliberately don't validate the page bundle, since language
		// conversion can be done w/o data-parsoid or data-mw

		// XXX handle x-roundtrip
		// env.htmlVariantLanguage = target;
		// env.wtVariantLanguage = source;

		if (res.locals.pagelanguage) {
			env.page.pagelanguage = res.locals.pagelanguage;
		} else if (revision.revid) {
			// fetch pagelanguage from original pageinfo
			yield TemplateRequest.setPageSrcInfo(env, revision.title, revision.revid);
		} else {
			return apiUtils.fatalRequest(env, 'Unknown page language.', 400);
		}

		if (env.langConverterEnabled()) {
			const { html, headers } = yield parse({
				input: revision.html.body,
				mode: 'variant',
				parsoidOptions: parsoidOptions,
				envOptions: res.locals.envOptions,
				oldid: res.locals.oldid,
				contentmodel: contentmodel,
				body_only: res.locals.body_only,
				cacheConfig: true,
				pagelanguage: env.page.pagelanguage,
				variant: { source, target }
			});
			// Since this an update, return the `inputContentVersion` as the `outputContentVersion`
			apiUtils.wt2htmlRes(res, html, pb, contentmodel, headers, env.inputContentVersion);
		} else {
			// Return 400 if you request LanguageConversion for a page which
			// didn't set `Vary: Accept-Language`.
			const err = new Error("LanguageConversion is not enabled on this article.");
			err.httpStatus = 400;
			err.suppressLoggingStack = true;
			throw err;
		}
	});

	/**
	 * Update red links on a document.
	 *
	 * @param {Response} res
	 * @param {Object} revision
	 * @param {string} [contentmodel]
	 */
	var updateRedLinks = Promise.async(function *(res, revision, contentmodel) {
		var env = res.locals.env;

		var pb = apiUtils.extractPageBundle(revision);
		apiUtils.validatePageBundle(pb, env.inputContentVersion);

		if (parsoidConfig.useBatchAPI) {
			const { html, headers } = yield parse({
				input: revision.html.body,
				mode: 'redlinks',
				parsoidOptions: parsoidOptions,
				envOptions: res.locals.envOptions,
				oldid: res.locals.oldid,
				contentmodel: contentmodel,
				body_only: res.locals.body_only,
				cacheConfig: true,
			});
			// Since this an update, return the `inputContentVersion` as the `outputContentVersion`
			apiUtils.wt2htmlRes(res, html, pb, contentmodel, headers, env.inputContentVersion);
		} else {
			const err = new Error("Batch API is not enabled.");
			err.httpStatus = 500;
			err.suppressLoggingStack = true;
			throw err;
		}
	});

	var pb2pb = Promise.async(function *(req, res) { // eslint-disable-line require-yield
		var env = res.locals.env;
		var opts = res.locals.opts;

		var revision = opts.previous || opts.original;
		if (!revision || !revision.html) {
			return apiUtils.fatalRequest(env, 'Missing revision html.', 400);
		}

		env.inputContentVersion = apiUtils.versionFromType(revision.html);
		if (env.inputContentVersion === null) {
			return apiUtils.fatalRequest(env, 'Content-type of revision html is missing.', 400);
		}
		if (metrics) {
			metrics.increment('pb2pb.original.version.' + env.inputContentVersion);
		}

		var contentmodel = (revision && revision.contentmodel);

		if (opts.updates && (opts.updates.redlinks || opts.updates.variant)) {
			// If we're only updating parts of the original version, it should
			// satisfy the requested content version, since we'll be returning
			// that same one.
			// FIXME: Since this endpoint applies the acceptable middleware,
			// `env.outputContentVersion` is not what's been passed in, but what
			// can be produced.  Maybe that should be selectively applied so
			// that we can update older versions where it makes sense?
			// Uncommenting below implies that we can only update the latest
			// version, since carrot semantics is applied in both directions.
			// if (!semver.satisfies(env.inputContentVersion, '^' + env.outputContentVersion)) {
			// 	return apiUtils.fatalRequest(env, 'We do not know how to do this conversion.', 415);
			// }
			console.assert(revision === opts.original);
			if (opts.updates.redlinks) {
				// Q(arlolra): Should redlinks be more complex than a bool?
				// See gwicke's proposal at T114413#2240381
				return updateRedLinks(res, revision, contentmodel);
			} else if (opts.updates.variant) {
				return languageConversion(res, revision, contentmodel);
			}
			console.assert(false, 'Should not be reachable.');
		}

		// TODO(arlolra): subbu has some sage advice in T114413#2365456 that
		// we should probably be more explicit about the pb2pb conversion
		// requested rather than this increasingly complex fallback logic.

		var downgrade = apiUtils.findDowngrade(env.inputContentVersion, env.outputContentVersion);
		if (downgrade) {
			console.assert(revision === opts.original);
			return apiUtils.returnDowngrade(downgrade, metrics, env, revision, res, contentmodel);
		// Ensure we only reuse from semantically similar content versions.
		} else if (semver.satisfies(env.outputContentVersion, '^' + env.inputContentVersion)) {
			var doc = DOMUtils.parseHTML(revision.html.body);
			var pb = apiUtils.extractPageBundle(revision);
			apiUtils.validatePageBundle(pb, env.inputContentVersion);
			DOMDataUtils.applyPageBundle(doc, pb);
			var reuseExpansions = {
				updates: opts.updates,
				html: ContentUtils.toXML(doc),
			};
			// Kick off a reparse making use of old expansions
			return wt2html(req, res, undefined, reuseExpansions);
		} else {
			return apiUtils.fatalRequest(env, 'We do not know how to do this conversion.', 415);
		}
	});

	// GET requests
	routes.v3Get = Promise.async(function *(req, res) {
		var opts = res.locals.opts;
		var env = res.locals.env;

		if (opts.format === 'wikitext') {
			try {
				var target = env.normalizeAndResolvePageTitle();
				var oldid = res.locals.oldid;
				yield TemplateRequest.setPageSrcInfo(env, target, oldid);
				if (!oldid) {
					return apiUtils.redirectToOldid(req, res);
				}
				if (env.page.meta && env.page.meta.revision && env.page.meta.revision.contentmodel) {
					apiUtils.setHeader(res, 'x-contentmodel', env.page.meta.revision.contentmodel);
				}
				apiUtils.plainResponse(res, env.page.src, undefined, apiUtils.wikitextContentType(env));
			} catch (e) {
				apiUtils.errorHandler(env, e);
			}
		} else {
			return apiUtils.errorWrapper(env, wt2html(req, res));
		}
	});

	// POST requests
	routes.v3Post = Promise.async(function *(req, res) { // eslint-disable-line require-yield
		var opts = res.locals.opts;
		var env = res.locals.env;

		if (opts.from === 'wikitext') {
			// Accept wikitext as a string or object{body,headers}
			var wikitext = opts.wikitext;
			if (typeof wikitext !== 'string' && opts.wikitext) {
				wikitext = opts.wikitext.body;
				// We've been given a pagelanguage for this page.
				if (opts.wikitext.headers && opts.wikitext.headers['content-language']) {
					res.locals.pagelanguage = opts.wikitext.headers['content-language'];
				}
			}
			// We've been given source for this page
			if (typeof wikitext !== 'string' && opts.original && opts.original.wikitext) {
				wikitext = opts.original.wikitext.body;
				// We've been given a pagelanguage for this page.
				if (opts.original.wikitext.headers && opts.original.wikitext.headers['content-language']) {
					res.locals.pagelanguage = opts.original.wikitext.headers['content-language'];
				}
			}
			// Abort if no wikitext or title.
			if (typeof wikitext !== 'string' && res.locals.titleMissing) {
				return apiUtils.fatalRequest(env, 'No title or wikitext was provided.', 400);
			}
			return apiUtils.errorWrapper(env, wt2html(req, res, wikitext));
		} else {
			if (opts.format === 'wikitext') {
				// html is required for serialization
				if (opts.html === undefined) {
					return apiUtils.fatalRequest(env, 'No html was supplied.', 400);
				}
				// Accept html as a string or object{body,headers}
				var html = (typeof opts.html === 'string') ?
					opts.html : (opts.html.body || '');
				return apiUtils.errorWrapper(env, html2wt(req, res, html));
			} else {
				return apiUtils.errorWrapper(env, pb2pb(req, res));
			}
		}
	});

	return routes;
};