'use strict';
'require view';
'require form';
'require uci';
'require rpc';
'require ui';
'require poll';
'require request';
'require dom';
'require fs';


/* --------------------------------------------------------------------------
 * This section should be sufficient to isolate the changes that any forked
 * versions need to change with a custom support url.
 */

const support_url = 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552';
const support_link = E('a', { href: support_url }, _('this forum thread'));

function detailsBlock(title, content, pre) {
	/* Formatter for discourse-based forum "details" block.
	 *
	 * If the above support_url is changed to github, say, then you'd
	 * probably need to get rid of the '[details...]' syntax.
	 */
	return ! content ? '' : ''.concat(
		'[details="', title, '"]\n',
		pre ? '```\n' : '',
		content, '\n',
		pre ? '```\n' : '',
		'[/details]\n',
	);
}

/* -------------------------------------------------------------------------- */

const callPackagelist = rpc.declare({
	object: 'rpc-sys',
	method: 'packagelist',
});

const callSystemBoard = rpc.declare({
	object: 'system',
	method: 'board',
});

const callUpgradeStart = rpc.declare({
	object: 'rpc-sys',
	method: 'upgrade_start',
	params: ['keep'],
});

/**
 * Returns the branch of a given version. This helps to offer upgrades
 * for point releases (aka within the branch).
 *
 * Logic:
 * SNAPSHOT -> SNAPSHOT
 * 21.02-SNAPSHOT -> 21.02
 * 21.02.0-rc1 -> 21.02
 * 19.07.8 -> 19.07
 *
 * @param {string} version
 * Input version from which to determine the branch
 * @returns {string}
 * The determined branch
 */
function get_branch(version) {
	return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
}

/**
 * The OpenWrt revision string contains both a hash as well as the number
 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
 * snapshot is newer than another.
 *
 * @param {string} revision
 * Revision string of a OpenWrt device
 * @returns {integer}
 * The number of commits since OpenWrt/LEDE reboot
 */
function get_revision_count(revision) {
	return parseInt(revision.substring(1).split('-')[0]);
}

return view.extend({
	steps: {
		init:                    [  0, _('Received build request')],
		container_setup:         [ 10, _('Setting up ImageBuilder')],
		validate_revision:       [ 20, _('Validating revision')],
		validate_manifest:       [ 30, _('Validating package selection')],
		calculate_packages_hash: [ 40, _('Calculating package hash')],
		building_image:          [ 50, _('Generating firmware image')],
		signing_images:          [ 95, _('Signing images')],
		done:                    [100, _('Completed generating firmware image')],
		failed:                  [100, _('Failed to generate firmware image')],

		/* Obsolete status values, retained for backward compatibility. */
		download_imagebuilder:   [ 20, _('Downloading ImageBuilder archive')],
		unpack_imagebuilder:     [ 40, _('Setting Up ImageBuilder')],
	},

	request_hash: new Map(),
	sha256_unsigned: '',

	applyPackageChanges: async function(package_info) {
		let { url, target, version, packages } = package_info;

		const overview_url = `${url}/api/v1/overview`;
		const revision_url = `${url}/api/v1/revision/${version}/${target}`;

		let changes, target_revision;

		await Promise.all([
			request.get(overview_url)
				.then(response => response.json())
				.then(json => json.branches)
				.then(branches => branches[get_branch(version)])
				.then(branch => { changes = branch.package_changes; })
				.catch(error => {
					throw Error(`Get overview failed:<br>${overview_url}<br>${error}`);
				}),

			request.get(revision_url)
				.then(response => response.json())
				.then(json => json.revision)
				.then(revision => { target_revision = get_revision_count(revision); })
				.catch(error => {
					throw Error(`Get revision failed:<br>${revision_url}<br>${error}`);
				}),
		]);

		for (const change of changes) {
			let idx = packages.indexOf(change.source);
			if (idx >= 0 && change.revision <= target_revision) {
				if (change.target)
					packages[idx] = change.target;
				else
					packages.splice(idx, 1);
			}
		}
		return packages;
	},

	selectImage: function (images, data, firmware) {
		var filesystemFilter = function(e) {
			return (e.filesystem == firmware.filesystem);
		}
		var typeFilter = function(e) {
			let efi_targets = ['armsr', 'loongarch', 'x86'];
			let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
			if (efi_capable) {
				if (data.efi) {
					return (e.type == 'combined-efi');
				} else {
					return (e.type == 'combined');
				}
			} else {
				return (e.type == 'sysupgrade' || e.type == 'combined');
			}
		}
		return images.filter(filesystemFilter).filter(typeFilter)[0];
	},

	handle200: function (response, content, data, firmware) {
		response = response.json();
		let image = this.selectImage(response.images, data, firmware);

		if (image.name != undefined) {
			this.sha256_unsigned = image.sha256_unsigned;
			let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;

			let keep = E('input', { type: 'checkbox' });
			keep.checked = true;

			let fields = [
				_('Version'),
				`${response.version_number} ${response.version_code}`,
				_('SHA256'),
				image.sha256,
			];

			if (data.advanced_mode == 1) {
				fields.push(
					_('Profile'),
					response.id,
					_('Target'),
					response.target,
					_('Build Date'),
					response.build_at,
					_('Filename'),
					image.name,
					_('Filesystem'),
					image.filesystem
				);
			}

			fields.push(
				'',
				E('a', { href: sysupgrade_url }, _('Download firmware image'))
			);
			if (data.rebuilder) {
				fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
			}

			let table = E('div', { class: 'table' });

			for (let i = 0; i < fields.length; i += 2) {
				table.appendChild(
					E('tr', { class: 'tr' }, [
						E('td', { class: 'td left', width: '33%' }, [fields[i]]),
						E('td', { class: 'td left' }, [fields[i + 1]]),
					])
				);
			}

			let modal_body = [
				table,
				E(
					'p',
					{ class: 'mt-2' },
					E('label', { class: 'btn' }, [
						keep,
						' ',
						_('Keep settings and retain the current configuration'),
					])
				),
				E('div', { class: 'right' }, [
					E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
					' ',
					E(
						'button',
						{
							class: 'btn cbi-button cbi-button-positive important',
							click: ui.createHandlerFn(this, function () {
								this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
							}),
						},
						_('Install firmware image')
					),
				]),
			];

			ui.showModal(_('Successfully created firmware image'), modal_body);
			if (data.rebuilder) {
				this.handleRebuilder(content, data, firmware);
			}
		}
	},

	handle202: function (response) {
		if ('queue_position' in response) {
			ui.showModal(_('Queued...'), [
				E(
					'p',
					{ class: 'spinning' },
					_('Request in build queue position %s').format(
						response.queue_position
					)
				),
			]);
		} else {
			ui.showModal(_('Building Firmware...'), [
				E(
					'p',
					{ class: 'spinning' },
					_('Progress: %s%% %s').format(
						this.steps[response.imagebuilder_status][0],
						this.steps[response.imagebuilder_status][1]
					)
				),
			]);
		}
	},

	handleError: function (response, data, firmware, request_hash) {
		response = response.json();
		const request_data = {
			...data,
			request_hash: request_hash,
			sha256_unsigned: this.sha256_unsigned,
			...firmware
		};
		const request_str = JSON.stringify(request_data, null, 4);
		if (typeof response.detail != "string") {
			response.detail = JSON.stringify(response.detail, null, 4);
		}
		let body = [
			E('p', {}, [
				_('First, check'), ' ',
				support_link,
				_('.  If you don\'t find a solution there, then report all of the information below.')
			]),

			E(
				'button',
				{
					class: 'btn cbi-button cbi-button-positive important',
					style: 'margin-bottom: 1em; padding: 0.2em',
					click: ui.createHandlerFn(this, function () {
						var text = ''.concat(
							// No translations in here as it's intended for the forum.
							'Server response: %s\n\n'.format(response.detail),
							detailsBlock('Request Data', request_str, true),
							detailsBlock('STDOUT', response.stdout, true),
							detailsBlock('STDERR', response.stderr, true),
						);

						navigator.clipboard.writeText(text);

						ui.showModal(_('Data copied!'), [
							E('p', [
								_('Paste the contents of the clipboard to'), ' ',
								support_link,
								'.',
							]),
							E('div', { class: 'right' }, [
								E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
							]),
						]);
					}),
				},
				_('Copy error data to clipboard...')
			),

			E('p', _('Server response: %s').format(response.detail)),
			E('p', _('Request Data:')),
			E('pre', {}, request_str),
		];

		if (response.stdout) {
			body.push(
				E('b', 'STDOUT:'),
				E('pre', response.stdout),
			);
		}

		if (response.stderr) {
			body.push(
				E('b', 'STDERR:'),
				E('pre', response.stderr),
			);
		}

		body.push(
			E('div', { class: 'right' }, [
				E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
			]),
		);

		ui.showModal(_('Error building the firmware image'), body);
	},

	handleRequest: function (server, main, content, data, firmware) {
		let request_url = `${server}/api/v1/build`;
		let method = 'POST';
		let local_content = content;
		const request_hash = this.request_hash.get(server);

		/**
		 * If `request_hash` is available use a GET request instead of
		 * sending the entire object.
		 */
		if (request_hash) {
			request_url += `/${request_hash}`;
			local_content = {};
			method = 'GET';
		}

		request
			.request(request_url, { method: method, content: local_content })
			.then((response) => {
				switch (response.status) {
					case 202:
						response = response.json();

						this.request_hash.set(server, response.request_hash);

						if (main) {
							this.handle202(response);
						} else {
							let view = document.getElementById(server);
							view.innerText = `⏳	(${
								this.steps[response.imagebuilder_status][0]
							}%) ${server}`;
						}
						break;
					case 200:
						if (main == true) {
							poll.remove(this.pollFn);
							this.handle200(response, content, data, firmware);
						} else {
							poll.remove(this.rebuilder_polls[server]);
							response = response.json();
							let view = document.getElementById(server);
							let image = this.selectImage(response.images, data, firmware);
							if (image.sha256_unsigned == this.sha256_unsigned) {
								view.innerText = '✅ %s'.format(server);
							} else {
								view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
									response.bin_dir
								}/${image.name}">${_('Download')}</a>)`;
							}
						}
						break;
					default:  // any error or unexpected responses
						if (main == true) {
							poll.remove(this.pollFn);
							this.handleError(response, data, firmware, request_hash);
						} else {
							poll.remove(this.rebuilder_polls[server]);
							document.getElementById(server).innerText = '🚫 %s'.format(
								server
							);
						}
						break;
				}
			});
	},

	handleRebuilder: function (content, data, firmware) {
		this.rebuilder_polls = {};
		for (let rebuilder of data.rebuilder) {
			this.rebuilder_polls[rebuilder] = L.bind(
				this.handleRequest,
				this,
				rebuilder,
				false,
				content,
				data,
				firmware
			);
			poll.add(this.rebuilder_polls[rebuilder], 5);
			document.getElementById(
				'rebuilder_status'
			).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
		}
		poll.start();
	},

	handleInstall: function (url, keep, sha256) {
		ui.showModal(_('Downloading...'), [
			E(
				'p',
				{ class: 'spinning' },
				_('Downloading firmware from server to browser')
			),
		]);

		request
			.get(url, {
				headers: {
					'Content-Type': 'application/x-www-form-urlencoded',
				},
				responseType: 'blob',
			})
			.then((response) => {
				let form_data = new FormData();
				form_data.append('sessionid', rpc.getSessionID());
				form_data.append('filename', '/tmp/firmware.bin');
				form_data.append('filemode', 600);
				form_data.append('filedata', response.blob());

				ui.showModal(_('Uploading...'), [
					E(
						'p',
						{ class: 'spinning' },
						_('Uploading firmware from browser to device')
					),
				]);

				request
					.get(`${L.env.cgi_base}/cgi-upload`, {
						method: 'PUT',
						content: form_data,
					})
					.then((response) => response.json())
					.then((response) => {
						if (response.sha256sum != sha256) {
							ui.showModal(_('Wrong checksum'), [
								E('p', _('Error during download of firmware. Please try again')),
								E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
							]);
						} else {
							ui.showModal(_('Installing...'), [
								E('div', { class: 'spinning' }, [
									E('p', _('Installing the sysupgrade image...')),
									E('p',
									_('Once the image is written, the system will reboot.')
									+ ' ' +
									_('This should take at least a minute, so please wait for the login screen.')
									),
									E('b', _('While you are waiting, do not unpower device!')),
								]),
							]);

							L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
								// Wait 10 seconds before we try to reconnect...
								let hosts = keep ? [] : ['192.168.1.1', 'openwrt.lan'];
								setTimeout(() => { ui.awaitReconnect(...hosts); }, 10000);
							});
						}
					});
			});
	},

	handleCheck: function (data, firmware) {
		this.request_hash.clear();
		let { url, revision, advanced_mode, branch } = data;
		let { version, target, profile, packages } = firmware;
		let candidates = [];

		const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
		const request_url = `${url}/api/v1/${endpoint}`;

		ui.showModal(_('Searching...'), [
			E(
				'p',
				{ class: 'spinning' },
				_('Searching for an available sysupgrade of %s - %s').format(
					version,
					revision
				)
			),
		]);

		L.resolveDefault(request.get(request_url)).then((response) => {
			if (!response.ok) {
				ui.showModal(_('Error connecting to upgrade server'), [
					E(
						'p',
						{},
						_('Could not reach API at "%s". Please try again later.').format(
							response.url
						)
					),
					E('pre', {}, response.responseText),
					E('div', { class: 'right' }, [
						E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
					]),
				]);
				return;
			}
			if (version.endsWith('SNAPSHOT')) {
				const remote_revision = response.json().revision;
				if (
					get_revision_count(revision) < get_revision_count(remote_revision)
				) {
					candidates.push([version, remote_revision]);
				}
			} else {
				const latest = response.json().latest;

				// ensure order: newest to oldest release
				latest.sort().reverse();

				for (let remote_version of latest) {
					let remote_branch = get_branch(remote_version);

					// already latest version installed
					if (version == remote_version) {
						break;
					}

					candidates.unshift([remote_version, null]);

					// don't offer branches older than the current
					if (branch == remote_branch) {
						break;
					}
				}
			}

			// allow to re-install running firmware in advanced mode
			if (advanced_mode == 1) {
				candidates.unshift([version, revision]);
			}

			if (candidates.length) {
				let s, o;

				let mapdata = {
					request: {
						profile,
						version: candidates[0][0],
						packages: Object.keys(packages).sort(),
					},
				};

				let map = new form.JSONMap(mapdata, '');

				s = map.section(
					form.NamedSection,
					'request',
					'',
					'',
					'Use defaults for the safest update'
				);
				o = s.option(form.ListValue, 'version', 'Select firmware version');
				for (let candidate of candidates) {
					if (candidate[0] == version && candidate[1] == revision) {
						o.value(
							candidate[0],
							_('[installed] %s').format(
								candidate[1]
									? `${candidate[0]} - ${candidate[1]}`
									: candidate[0]
							)
						);
					} else {
						o.value(
							candidate[0],
							candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
						);
					}
				}

				if (advanced_mode == 1) {
					o = s.option(form.Value, 'profile', _('Board Name / Profile'));
					o = s.option(form.DynamicList, 'packages', _('Packages'));
				}

				L.resolveDefault(map.render()).then((form_rendered) => {
					ui.showModal(_('New firmware upgrade available'), [
						E(
							'p',
							_('Currently running: %s - %s').format(
								version,
								revision
							)
						),
						form_rendered,
						E('div', { class: 'right' }, [
							E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
							' ',
							E(
								'button',
								{
									class: 'btn cbi-button cbi-button-positive important',
									click: ui.createHandlerFn(this, function () {
										map.save().then(() => {
											this.applyPackageChanges({
												url,
												target,
												version:  mapdata.request.version,
												packages: mapdata.request.packages,
											}).then((packages) => {
												const content = {
													...firmware,
													packages: packages,
													version: mapdata.request.version,
													profile: mapdata.request.profile
												};
												this.pollFn = L.bind(function () {
													this.handleRequest(url, true, content, data, firmware);
												}, this);
												poll.add(this.pollFn, 5);
												poll.start();
											})
											.catch(error => {
											    ui.addNotification(null, E('p', error.message));
											    ui.hideModal();
											});
										});
									}),
								},
								_('Request firmware image')
							),
						]),
					]);
				});
			} else {
				ui.showModal(_('No upgrade available'), [
					E(
						'p',
						_('The device runs the latest firmware version %s - %s').format(
							version,
							revision
						)
					),
					E('div', { class: 'right' }, [
						E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
					]),
				]);
			}
		});
	},

	load: async function () {
		const promises = await Promise.all([
			L.resolveDefault(callPackagelist(), {}),
			L.resolveDefault(callSystemBoard(), {}),
			L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
			uci.load('attendedsysupgrade'),
		]);
		const data = {
			system_board: promises[1],
			advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
			url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
			branch: get_branch(promises[1].release.version),
			revision: promises[1].release.revision,
			efi: promises[2] != null,
			rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder'),
		};
		const firmware = {
			client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
			packages: promises[0].packages,
			profile: promises[1].board_name,
			target: promises[1].release.target,
			version: promises[1].release.version,
			diff_packages: true,
			filesystem: promises[1].rootfs_type,

			// If the user has changed the rootfs partition size via owut,
			// then make sure to keep new image the same size.  A null value
			// is interpreted by the ASU server as "default".
			rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
		};
		return [data, firmware];
	},

	render: function (response) {
		const data = response[0];
		const firmware = response[1];

		return E('p', [
			E('h2', _('Attended Sysupgrade')),
			E(
				'p',
				_(
					'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
				)
			),
			E(
				'p',
				_(
					'This is done by building a new firmware on demand via an online service.'
				)
			),
			E(
				'p',
				_('Currently running: %s - %s').format(
					firmware.version,
					data.revision
				)
			),
			E(
				'button',
				{
					class: 'btn cbi-button cbi-button-positive important',
					click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
				},
				_('Search for firmware upgrade')
			),
		]);
	},
	handleSaveApply: null,
	handleSave: null,
	handleReset: null,
});
