// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
// Licensed to the public under the Apache License 2.0.

import {
	urlencode as _urlencode,
	urldecode as _urldecode,
	urlencoded_parser, multipart_parser, header_attribute,
	ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS
} from 'lucihttp';

import {
	error as fserror,
	stdin, stdout, mkstemp
} from 'fs';

// luci.http module scope
export let HTTP_MAX_CONTENT = 1024*100;		// 100 kB maximum content size

// Decode a mime encoded http message body with multipart/form-data
// Content-Type. Stores all extracted data associated with its parameter name
// in the params table within the given message object. Multiple parameter
// values are stored as tables, ordinary ones as strings.
// If an optional file callback function is given then it is fed with the
// file contents chunk by chunk and only the extracted file name is stored
// within the params table. The callback function will be called subsequently
// with three arguments:
//  o Table containing decoded (name, file) and raw (headers) mime header data
//  o String value containing a chunk of the file data
//  o Boolean which indicates whether the current chunk is the last one (eof)
export function mimedecode_message_body(src, msg, file_cb) {
	let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
	let err, header, field, parser;

	parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) {
		if (what == parser.PART_INIT) {
			field = {};
		}
		else if (what == parser.HEADER_NAME) {
			header = lc(buffer);
		}
		else if (what == parser.HEADER_VALUE && header) {
			if (lc(header) == 'content-disposition' &&
			    header_attribute(buffer, null) == 'form-data') {
				field.name = header_attribute(buffer, 'name');
				field.file = header_attribute(buffer, 'filename');
				field[1] = field.file;
			}

			field.headers = field.headers || {};
			field.headers[header] = buffer;
		}
		else if (what == parser.PART_BEGIN) {
			return !field.file;
		}
		else if (what == parser.PART_DATA && field.name && length > 0) {
			if (field.file) {
				if (file_cb) {
					file_cb(field, buffer, false);

					msg.params[field.name] = msg.params[field.name] || field;
				}
				else {
					if (!field.fd)
						field.fd = mkstemp(field.name);

					if (field.fd) {
						field.fd.write(buffer);
						msg.params[field.name] = msg.params[field.name] || field;
					}
				}
			}
			else {
				field.value = buffer;
			}
		}
		else if (what == parser.PART_END && field.name) {
			if (field.file && msg.params[field.name]) {
				if (file_cb)
					file_cb(field, '', true);
				else if (field.fd)
					field.fd.seek(0);
			}
			else {
				let val = msg.params[field.name];

				if (type(val) == 'array')
					push(val, field.value || '');
				else if (val != null)
					msg.params[field.name] = [ val, field.value || '' ];
				else
					msg.params[field.name] = field.value || '';
			}

			field = null;
		}
		else if (what == parser.ERROR) {
			err = buffer;
		}

		return true;
	}, HTTP_MAX_CONTENT);

	while (true) {
		let chunk = src();

		len += length(chunk);

		if (maxlen && len > maxlen + 2)
			die('Message body size exceeds Content-Length');

		if (!parser.parse(chunk))
			die(err);

		if (chunk == null)
			break;
	}
};

// Decode an urlencoded http message body with application/x-www-urlencoded
// Content-Type. Stores all extracted data associated with its parameter name
// in the params table within the given message object. Multiple parameter
// values are stored as tables, ordinary ones as strings.
export function urldecode_message_body(src, msg) {
	let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
	let err, name, value, parser;

	parser = urlencoded_parser(function (what, buffer, length) {
		if (what == parser.TUPLE) {
			name = null;
			value = null;
		}
		else if (what == parser.NAME) {
			name = _urldecode(buffer, DECODE_PLUS);
		}
		else if (what == parser.VALUE && name) {
			let val = msg.params[name];

			if (type(val) == 'array')
				push(val, _urldecode(buffer, DECODE_PLUS) || '');
			else if (val != null)
				msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ];
			else
				msg.params[name] = _urldecode(buffer, DECODE_PLUS) || '';
		}
		else if (what == parser.ERROR) {
			err = buffer;
		}

		return true;
	}, HTTP_MAX_CONTENT);

	while (true) {
		let chunk = src();

		len += length(chunk);

		if (maxlen && len > maxlen + 2)
			die('Message body size exceeds Content-Length');

		if (!parser.parse(chunk))
			die(err);

		if (chunk == null)
			break;
	}
};

// This function will examine the Content-Type within the given message object
// to select the appropriate content decoder.
// Currently the application/x-www-urlencoded and application/form-data
// mime types are supported. If the encountered content encoding can't be
// handled then the whole message body will be stored unaltered as 'content'
// property within the given message object.
export function parse_message_body(src, msg, filecb) {
	if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') {
		let ctype = header_attribute(msg.env.CONTENT_TYPE, null);

		// Is it multipart/mime ?
		if (ctype == 'multipart/form-data')
			return mimedecode_message_body(src, msg, filecb);

		// Is it application/x-www-form-urlencoded ?
		else if (ctype == 'application/x-www-form-urlencoded')
			return urldecode_message_body(src, msg);

		// Unhandled encoding
		// If a file callback is given then feed it chunk by chunk, else
		// store whole buffer in message.content
		let sink;

		// If we have a file callback then feed it
		if (type(filecb) == 'function') {
			let meta = {
				name: 'raw',
				encoding: msg.env.CONTENT_TYPE
			};

			sink = (chunk) => {
				if (chunk != null)
					return filecb(meta, chunk, false);
				else
					return filecb(meta, null, true);
			};
		}

		// ... else append to .content
		else {
			let chunks = [], len = 0;

			sink = (chunk) => {
				len += length(chunk);

				if (len > HTTP_MAX_CONTENT)
					die('POST data exceeds maximum allowed length');

				if (chunk != null) {
					push(chunks, chunk);
				}
				else {
					msg.content = join('', chunks);
					msg.content_length = len;
				}
			};
		}

		// Pump data...
		while (true) {
			let chunk = src();

			sink(chunk);

			if (chunk == null)
				break;
		}

		return true;
	}

	return false;
};

export function build_querystring(q) {
	let s = [];

	for (let k, v in q) {
		push(s,
			length(s) ? '&' : '?',
			_urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k,
			'=',
			_urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v
		);
	}

	return join('', s);
};

export function urlencode(value) {
	if (value == null)
		return null;

	value = '' + value;

	return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value;
};

export function urldecode(value, decode_plus) {
	if (value == null)
		return null;

	value = '' + value;

	return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value;
};

// Extract and split urlencoded data pairs, separated bei either "&" or ";"
// from given url or string. Returns a table with urldecoded values.
// Simple parameters are stored as string values associated with the parameter
// name within the table. Parameters with multiple values are stored as array
// containing the corresponding values.
export function urldecode_params(url, tbl) {
	let parser, name, value;
	let params = tbl || {};

	parser = urlencoded_parser(function(what, buffer, length) {
		if (what == parser.TUPLE) {
			name = null;
			value = null;
		}
		else if (what == parser.NAME) {
			name = _urldecode(buffer);
		}
		else if (what == parser.VALUE && name) {
			params[name] = _urldecode(buffer) || '';
		}

		return true;
	});

	if (parser) {
		let m = match(('' + (url || '')), /[^?]*$/);

		parser.parse(m ? m[0] : '');
		parser.parse(null);
	}

	return params;
};

// Encode each key-value-pair in given table to x-www-urlencoded format,
// separated by '&'. Tables are encoded as parameters with multiple values by
// repeating the parameter name with each value.
export function urlencode_params(tbl) {
	let enc = [];

	for (let k, v in tbl) {
		if (type(v) == 'array') {
			for (let v2 in v) {
				if (length(enc))
					push(enc, '&');

				push(enc,
					_urlencode(k),
					'=',
					_urlencode('' + v2));
			}
		}
		else {
			if (length(enc))
				push(enc, '&');

			push(enc,
				_urlencode(k),
				'=',
				_urlencode('' + v));
		}
	}

	return join(enc, '');
};


// Default IO routines suitable for CGI invocation
let avail_len = +getenv('CONTENT_LENGTH');

const default_source = () => {
	let rlen = min(avail_len, 4096);

	if (rlen == 0) {
		stdin.close();

		return null;
	}

	let chunk = stdin.read(rlen);

	if (chunk == null)
		die(`Input read error: ${fserror()}`);

	avail_len -= length(chunk);

	return chunk;
};

const default_sink = (...chunks) => {
	for (let chunk in chunks)
		stdout.write(chunk);

	stdout.flush();
};

const Class = {
	formvalue: function(name, noparse) {
		if (!noparse && !this.parsed_input)
			this._parse_input();

		if (name != null)
			return this.message.params[name];
		else
			return this.message.params;
	},

	formvaluetable: function(prefix) {
		let vals = {};

		prefix = (prefix || '') + '.';

		if (!this.parsed_input)
			this._parse_input();

		for (let k, v in this.message.params)
			if (index(k, prefix) == 0)
				 vals[substr(k, length(prefix))] = '' + v;

		return vals;
	},

	content: function() {
		if (!this.parsed_input)
			this._parse_input();

		return this.message.content;
	},

	getcookie: function(name) {
		return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name);
	},

	getenv: function(name) {
		if (name != null)
			return this.message.env[name];
		else
			return this.message.env;
	},

	setfilehandler: function(callback) {
		if (type(callback) == 'resource' && type(callback.call) == 'function')
			this.filehandler = (...args) => callback.call(...args);
		else if (type(callback) == 'function')
			this.filehandler = callback;
		else
			die('Invalid callback argument for setfilehandler()');

		if (!this.parsed_input)
			return;

		// If input has already been parsed then uploads are stored as unlinked
		// temporary files pointed to by open file handles in the parameter
		// value table. Loop all params, and invoke the file callback for any
		// param with an open file handle.
		for (let name, value in this.message.params) {
			while (value?.fd) {
				let data = value.fd.read(1024);
				let eof = (length(data) == 0);

				this.filehandler(value, data, eof);

				if (eof) {
					value.fd.close();
					value.fd = null;
				}
			}
		}
	},

	_parse_input: function() {
		parse_message_body(
			this.input,
			this.message,
			this.filehandler
		);

		this.parsed_input = true;
	},

	close: function() {
		this.write_headers();
		this.closed = true;
	},

	header: function(key, value) {
		this.headers ??= {};
		this.headers[lc(key)] = value;
	},

	prepare_content: function(mime) {
		if (!this.headers?.['content-type']) {
			if (mime == 'application/xhtml+xml') {
				if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) {
					mime = 'text/html; charset=UTF-8';
					this.header('Vary', 'Accept');
				}
			}

			this.header('Content-Type', mime);
		}
	},

	status: function(code, message) {
		this.status_code = code ?? 200;
		this.status_message = message ?? 'OK';
	},

	write_headers: function() {
		if (this.eoh)
			return;

		if (!this.status_code)
			this.status();

		if (!this.headers?.['content-type'])
			this.header('Content-Type', 'text/html; charset=UTF-8');

		if (!this.headers?.['cache-control']) {
			this.header('Cache-Control', 'no-cache');
			this.header('Expires', '0');
		}

		if (!this.headers?.['x-frame-options'])
			this.header('X-Frame-Options', 'SAMEORIGIN');

		if (!this.headers?.['x-xss-protection'])
			this.header('X-XSS-Protection', '1; mode=block');

		if (!this.headers?.['x-content-type-options'])
			this.header('X-Content-Type-Options', 'nosniff');

		this.output('Status: ');
		this.output(this.status_code);
		this.output(' ');
		this.output(this.status_message);
		this.output('\r\n');

		for (let k, v in this.headers) {
			this.output(k);
			this.output(': ');
			this.output(v);
			this.output('\r\n');
		}

		this.output('\r\n');

		this.eoh = true;
	},

	// If the content chunk is nil this function will automatically invoke close.
	write: function(content) {
		if (content != null && !this.closed) {
			this.write_headers();
			this.output(content);

			return true;
		}
		else {
			this.close();
		}
	},

	redirect: function(url) {
		this.status(302, 'Found');
		this.header('Location', url ?? '/');
		this.close();
	},

	write_json: function(value) {
		this.write(sprintf('%.J', value));
	},

	urlencode,
	urlencode_params,

	urldecode,
	urldecode_params,

	build_querystring
};

export default function(env, sourcein, sinkout) {
	return proto({
		input: sourcein ?? default_source,
		output: sinkout ?? default_sink,

		// File handler nil by default to let .content() work
		file: null,

		// HTTP-Message table
		message: {
			env,
			headers: {},
			params: urldecode_params(env?.QUERY_STRING ?? '')
		},

		parsed_input: false
	}, Class);
};
