'use strict';
'require view';
'require fs';
'require ui';
'require rpc';

var css = '								\
	.controls {							\
		display: flex;					\
		margin: .5em 0 1em 0;			\
		flex-wrap: wrap;				\
		justify-content: space-around;	\
	}									\
										\
	.controls > * {						\
		padding: .25em;					\
		white-space: nowrap;			\
		flex: 1 1 33%;					\
		box-sizing: border-box;			\
		display: flex;					\
		flex-wrap: wrap;				\
	}									\
										\
	.controls > *:first-child,			\
	.controls > * > label {				\
		flex-basis: 100%;				\
		min-width: 250px;				\
	}									\
										\
	.controls > *:nth-child(2),			\
	.controls > *:nth-child(3) {		\
		flex-basis: 20%;				\
	}									\
										\
	.controls > * > .btn {				\
		flex-basis: 20px;				\
		text-align: center;				\
	}									\
										\
	.controls > * > * {					\
		flex-grow: 1;					\
		align-self: center;				\
	}									\
										\
	.controls > div > input {			\
		width: auto;					\
	}									\
										\
	.td.version,						\
	.td.size {							\
		white-space: nowrap;			\
	}									\
										\
	ul.deps, ul.deps ul, ul.errors {	\
		margin-left: 1em;				\
	}									\
										\
	ul.deps li, ul.errors li {			\
		list-style: none;				\
	}									\
										\
	ul.deps li:before {					\
		content: "↳";					\
		display: inline-block;			\
		width: 1em;						\
		margin-left: -1em;				\
	}									\
										\
	ul.deps li > span {					\
		white-space: nowrap;			\
	}									\
										\
	ul.errors li {						\
		color: #c44;					\
		font-size: 90%;					\
		font-weight: bold;				\
		padding-left: 1.5em;			\
	}									\
										\
	ul.errors li:before {				\
		content: "⚠";					\
		display: inline-block;			\
		width: 1.5em;					\
		margin-left: -1.5em;			\
	}									\
';

var isReadonlyView = !L.hasViewPermission() || null;

var callMountPoints = rpc.declare({
	object: 'luci',
	method: 'getMountPoints',
	expect: { result: [] }
});

var packages = {
	available: { providers: {}, pkgs: {} },
	installed: { providers: {}, pkgs: {} }
};

var currentDisplayMode = 'available', currentDisplayRows = [];

function parseList(s, dest)
{
	var re = /([^\n]*)\n/g,
	    pkg = null, key = null, val = null, m;

	while ((m = re.exec(s)) !== null) {
		if (m[1].match(/^\s(.*)$/)) {
			if (pkg !== null && key !== null && val !== null)
				val += '\n' + RegExp.$1.trim();

			continue;
		}

		if (key !== null && val !== null) {
			switch (key) {
			case 'package':
				pkg = { name: val };
				break;

			case 'depends':
			case 'provides':
				var list = val.split(/\s*,\s*/);
				if (list.length !== 1 || list[0].length > 0)
					pkg[key] = list;
				break;

			case 'installed-time':
				pkg.installtime = new Date(+val * 1000);
				break;

			case 'installed-size':
				pkg.installsize = +val;
				break;

			case 'status':
				var stat = val.split(/\s+/),
				    mode = stat[1],
				    installed = stat[2];

				switch (mode) {
				case 'user':
				case 'hold':
					pkg[mode] = true;
					break;
				}

				switch (installed) {
				case 'installed':
					pkg.installed = true;
					break;
				}
				break;

			case 'essential':
				if (val === 'yes')
					pkg.essential = true;
				break;

			case 'size':
				pkg.size = +val;
				break;

			case 'architecture':
			case 'auto-installed':
			case 'filename':
			case 'sha256sum':
			case 'section':
				break;

			default:
				pkg[key] = val;
				break;
			}

			key = val = null;
		}

		if (m[1].trim().match(/^([\w-]+)\s*:(.+)$/)) {
			key = RegExp.$1.toLowerCase();
			val = RegExp.$2.trim();
		}
		else {
			dest.pkgs[pkg.name] = pkg;

			var provides = dest.providers[pkg.name] ? [] : [ pkg.name ];

			if (pkg.provides)
				provides.push.apply(provides, pkg.provides);

			provides.forEach(function(p) {
				dest.providers[p] = dest.providers[p] || [];
				dest.providers[p].push(pkg);
			});
		}
	}
}

function display(pattern)
{
	var src = packages[currentDisplayMode === 'updates' ? 'installed' : currentDisplayMode],
	    table = document.querySelector('#packages'),
	    pager = document.querySelector('#pager');

	currentDisplayRows.length = 0;

	if (typeof(pattern) === 'string' && pattern.length > 0)
		pattern = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig');

	for (var name in src.pkgs) {
		var pkg = src.pkgs[name],
		    desc = pkg.description || '',
		    altsize = null;

		if (!pkg.size && packages.available.pkgs[name])
			altsize = packages.available.pkgs[name].size;

		if (!desc && packages.available.pkgs[name])
			desc = packages.available.pkgs[name].description || '';

		desc = desc.split(/\n/);
		desc = desc[0].trim() + (desc.length > 1 ? '…' : '');

		if ((pattern instanceof RegExp) &&
		    !name.match(pattern) && !desc.match(pattern))
			continue;

		var btn, ver;

		if (currentDisplayMode === 'updates') {
			var avail = packages.available.pkgs[name],
			    inst  = packages.installed.pkgs[name];

			if (!inst || !inst.installed)
				continue;

			if (!avail || compareVersion(avail.version, pkg.version) <= 0)
				continue;

			ver = '%s » %s'.format(
				truncateVersion(pkg.version || '-'),
				truncateVersion(avail.version || '-'));

			btn = E('div', {
				'class': 'btn cbi-button-positive',
				'data-package': name,
				'click': handleInstall
			}, _('Upgrade…'));
		}
		else if (currentDisplayMode === 'installed') {
			if (!pkg.installed)
				continue;

			ver = truncateVersion(pkg.version || '-');
			btn = E('div', {
				'class': 'btn cbi-button-negative',
				'data-package': name,
				'click': handleRemove
			}, _('Remove…'));
		}
		else {
			var inst = packages.installed.pkgs[name];

			ver = truncateVersion(pkg.version || '-');

			if (!inst || !inst.installed)
				btn = E('div', {
					'class': 'btn cbi-button-action',
					'data-package': name,
					'click': handleInstall
				}, _('Install…'));
			else if (inst.installed && inst.version != pkg.version)
				btn = E('div', {
					'class': 'btn cbi-button-positive',
					'data-package': name,
					'click': handleInstall
				}, _('Upgrade…'));
			else
				btn = E('div', {
					'class': 'btn cbi-button-neutral',
					'disabled': 'disabled'
				}, _('Installed'));
		}

		name = '%h'.format(name);
		desc = '%h'.format(desc || '-');

		if (pattern) {
			name = name.replace(pattern, '<ins>$&</ins>');
			desc = desc.replace(pattern, '<ins>$&</ins>');
		}

		currentDisplayRows.push([
			name,
			ver,
			pkg.size ? '%.1024mB'.format(pkg.size)
			         : (altsize ? '~%.1024mB'.format(altsize) : '-'),
			desc,
			btn
		]);
	}

	currentDisplayRows.sort(function(a, b) {
		if (a[0] < b[0])
			return -1;
		else if (a[0] > b[0])
			return 1;
		else
			return 0;
	});

	pager.parentNode.style.display = '';
	pager.setAttribute('data-offset', 100);
	handlePage({ target: pager.querySelector('.prev') });
}

function handlePage(ev)
{
	var filter = document.querySelector('input[name="filter"]'),
	    pager = ev.target.parentNode,
	    offset = +pager.getAttribute('data-offset'),
	    next = ev.target.classList.contains('next');

	if ((next && (offset + 100) >= currentDisplayRows.length) ||
	    (!next && (offset < 100)))
	    return;

	offset += next ? 100 : -100;
	pager.setAttribute('data-offset', offset);
	pager.querySelector('.text').firstChild.data = currentDisplayRows.length
		? _('Displaying %d-%d of %d').format(1 + offset, Math.min(offset + 100, currentDisplayRows.length), currentDisplayRows.length)
		: _('No packages');

	if (offset < 100)
		pager.querySelector('.prev').setAttribute('disabled', 'disabled');
	else
		pager.querySelector('.prev').removeAttribute('disabled');

	if ((offset + 100) >= currentDisplayRows.length)
		pager.querySelector('.next').setAttribute('disabled', 'disabled');
	else
		pager.querySelector('.next').removeAttribute('disabled');

	var placeholder = _('No information available');

	if (filter.value)
		placeholder = [
			E('span', {}, _('No packages matching "<strong>%h</strong>".').format(filter.value)), ' (',
			E('a', { href: '#', onclick: 'handleReset(event)' }, _('Reset')), ')'
		];

	cbi_update_table('#packages', currentDisplayRows.slice(offset, offset + 100),
		placeholder);
}

function handleMode(ev)
{
	var tab = findParent(ev.target, 'li');
	if (tab.getAttribute('data-mode') === currentDisplayMode)
		return;

	tab.parentNode.querySelectorAll('li').forEach(function(li) {
		li.classList.remove('cbi-tab');
		li.classList.add('cbi-tab-disabled');
	});

	tab.classList.remove('cbi-tab-disabled');
	tab.classList.add('cbi-tab');

	currentDisplayMode = tab.getAttribute('data-mode');

	display(document.querySelector('input[name="filter"]').value);

	ev.target.blur();
	ev.preventDefault();
}

function orderOf(c)
{
	if (c === '~')
		return -1;
	else if (c === '' || c >= '0' && c <= '9')
		return 0;
	else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
		return c.charCodeAt(0);
	else
		return c.charCodeAt(0) + 256;
}

function compareVersion(val, ref)
{
	var vi = 0, ri = 0,
	    isdigit = { 0:1, 1:1, 2:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1, 9:1 };

	val = val || '';
	ref = ref || '';

	if (val === ref)
		return 0;

	while (vi < val.length || ri < ref.length) {
		var first_diff = 0;

		while ((vi < val.length && !isdigit[val.charAt(vi)]) ||
		       (ri < ref.length && !isdigit[ref.charAt(ri)])) {
			var vc = orderOf(val.charAt(vi)), rc = orderOf(ref.charAt(ri));
			if (vc !== rc)
				return vc - rc;

			vi++; ri++;
		}

		while (val.charAt(vi) === '0')
			vi++;

		while (ref.charAt(ri) === '0')
			ri++;

		while (isdigit[val.charAt(vi)] && isdigit[ref.charAt(ri)]) {
			first_diff = first_diff || (val.charCodeAt(vi) - ref.charCodeAt(ri));
			vi++; ri++;
		}

		if (isdigit[val.charAt(vi)])
			return 1;
		else if (isdigit[ref.charAt(ri)])
			return -1;
		else if (first_diff)
			return first_diff;
	}

	return 0;
}

function versionSatisfied(ver, ref, vop)
{
	var r = compareVersion(ver, ref);

	switch (vop) {
	case '<':
	case '<=':
		return r <= 0;

	case '>':
	case '>=':
		return r >= 0;

	case '<<':
		return r < 0;

	case '>>':
		return r > 0;

	case '=':
		return r == 0;
	}

	return false;
}

function pkgStatus(pkg, vop, ver, info)
{
	info.errors = info.errors || [];
	info.install = info.install || [];

	if (pkg.installed) {
		if (vop && !versionSatisfied(pkg.version, ver, vop)) {
			var repl = null;

			(packages.available.providers[pkg.name] || []).forEach(function(p) {
				if (!repl && versionSatisfied(p.version, ver, vop))
					repl = p;
			});

			if (repl) {
				info.install.push(repl);
				return E('span', {
					'class': 'label',
					'data-tooltip': _('Requires update to %h %h')
						.format(repl.name, repl.version)
				}, _('Needs upgrade'));
			}

			info.errors.push(_('The installed version of package <em>%h</em> is not compatible, require %s while %s is installed.').format(pkg.name, truncateVersion(ver, vop), truncateVersion(pkg.version)));

			return E('span', {
				'class': 'label warning',
				'data-tooltip': _('Require version %h %h,\ninstalled %h')
					.format(vop, ver, pkg.version)
			}, _('Version incompatible'));
		}

		return E('span', { 'class': 'label notice' }, _('Installed'));
	}
	else if (!pkg.missing) {
		if (!vop || versionSatisfied(pkg.version, ver, vop)) {
			info.install.push(pkg);
			return E('span', { 'class': 'label' }, _('Not installed'));
		}

		info.errors.push(_('The repository version of package <em>%h</em> is not compatible, require %s but only %s is available.')
				.format(pkg.name, truncateVersion(ver, vop), truncateVersion(pkg.version)));

		return E('span', {
			'class': 'label warning',
			'data-tooltip': _('Require version %h %h,\ninstalled %h')
				.format(vop, ver, pkg.version)
		}, _('Version incompatible'));
	}
	else {
		info.errors.push(_('Required dependency package <em>%h</em> is not available in any repository.').format(pkg.name));

		return E('span', { 'class': 'label warning' }, _('Not available'));
	}
}

function renderDependencyItem(dep, info)
{
	var li = E('li'),
	    vop = dep.version ? dep.version[0] : null,
	    ver = dep.version ? dep.version[1] : null,
	    depends = [];

	for (var i = 0; dep.pkgs && i < dep.pkgs.length; i++) {
		var pkg = packages.installed.pkgs[dep.pkgs[i]] ||
		          packages.available.pkgs[dep.pkgs[i]] ||
		          { name: dep.name };

		if (i > 0)
			li.appendChild(document.createTextNode(' | '));

		var text = pkg.name;

		if (pkg.installsize)
			text += ' (%.1024mB)'.format(pkg.installsize);
		else if (pkg.size)
			text += ' (~%.1024mB)'.format(pkg.size);

		li.appendChild(E('span', { 'data-tooltip': pkg.description },
			[ text, ' ', pkgStatus(pkg, vop, ver, info) ]));

		(pkg.depends || []).forEach(function(d) {
			if (depends.indexOf(d) === -1)
				depends.push(d);
		});
	}

	if (!li.firstChild)
		li.appendChild(E('span', {},
			[ dep.name, ' ',
			  pkgStatus({ name: dep.name, missing: true }, vop, ver, info) ]));

	var subdeps = renderDependencies(depends, info);
	if (subdeps)
		li.appendChild(subdeps);

	return li;
}

function renderDependencies(depends, info)
{
	var deps = depends || [],
	    items = [];

	info.seen = info.seen || [];

	for (var i = 0; i < deps.length; i++) {
		var dep, vop, ver;

		if (deps[i] === 'libc')
			continue;

		if (deps[i].match(/^(.+)\s+\((<=|<|>|>=|=|<<|>>)(.+)\)$/)) {
			dep = RegExp.$1.trim();
			vop = RegExp.$2.trim();
			ver = RegExp.$3.trim();
		}
		else {
			dep = deps[i].trim();
			vop = ver = null;
		}

		if (info.seen[dep])
			continue;

		var pkgs = [];

		(packages.installed.providers[dep] || []).forEach(function(p) {
			if (pkgs.indexOf(p.name) === -1) pkgs.push(p.name);
		});

		(packages.available.providers[dep] || []).forEach(function(p) {
			if (pkgs.indexOf(p.name) === -1) pkgs.push(p.name);
		});

		info.seen[dep] = {
			name:    dep,
			pkgs:    pkgs,
			version: [vop, ver]
		};

		items.push(renderDependencyItem(info.seen[dep], info));
	}

	if (items.length)
		return E('ul', { 'class': 'deps' }, items);

	return null;
}

function truncateVersion(v, op)
{
	v = v.replace(/\b(([a-f0-9]{8})[a-f0-9]{24,32})\b/,
		'<span data-tooltip="$1">$2…</span>');

	if (!op || op === '=')
		return v;

	return '%h %h'.format(op, v);
}

function handleReset(ev)
{
	var filter = document.querySelector('input[name="filter"]');

	filter.value = '';
	display();
}

function handleInstall(ev)
{
	var name = ev.target.getAttribute('data-package'),
	    pkg = packages.available.pkgs[name],
	    depcache = {},
	    size;

	if (pkg.installsize)
		size = _('~%.1024mB installed').format(pkg.installsize);
	else if (pkg.size)
		size = _('~%.1024mB compressed').format(pkg.size);
	else
		size = _('unknown');

	var deps = renderDependencies(pkg.depends, depcache),
	    tree = null, errs = null, inst = null, desc = null;

	if (depcache.errors && depcache.errors.length) {
		errs = E('ul', { 'class': 'errors' });
		depcache.errors.forEach(function(err) {
			errs.appendChild(E('li', {}, err));
		});
	}

	var totalsize = pkg.installsize || pkg.size || 0,
	    totalpkgs = 1;

	if (depcache.install && depcache.install.length)
		depcache.install.forEach(function(ipkg) {
			totalsize += ipkg.installsize || ipkg.size || 0;
			totalpkgs++;
		});

	inst = E('p', {},
		_('Require approx. %.1024mB size for %d package(s) to install.')
			.format(totalsize, totalpkgs));

	if (deps) {
		tree = E('li', '<strong>%s:</strong>'.format(_('Dependencies')));
		tree.appendChild(deps);
	}

	if (pkg.description) {
		desc = E('div', {}, [
			E('h5', {}, _('Description')),
			E('p', {}, pkg.description)
		]);
	}

	ui.showModal(_('Details for package <em>%h</em>').format(pkg.name), [
		E('ul', {}, [
			E('li', '<strong>%s:</strong> %h'.format(_('Version'), pkg.version)),
			E('li', '<strong>%s:</strong> %h'.format(_('Size'), size)),
			tree || '',
		]),
		desc || '',
		errs || inst || '',
		E('div', { 'class': 'right' }, [
			E('label', { 'class': 'cbi-checkbox', 'style': 'float:left' }, [
				E('input', { 'id': 'overwrite-cb', 'type': 'checkbox', 'name': 'overwrite', 'disabled': isReadonlyView }), ' ',
				E('label', { 'for': 'overwrite-cb' }), ' ',
				_('Overwrite files from other package(s)')
			]),
			E('div', {
				'class': 'btn',
				'click': ui.hideModal
			}, _('Cancel')),
			' ',
			E('div', {
				'data-command': 'install',
				'data-package': name,
				'class': 'btn cbi-button-action',
				'click': handleOpkg,
				'disabled': isReadonlyView
			}, _('Install'))
		])
	]);
}

function handleManualInstall(ev)
{
	var name_or_url = document.querySelector('input[name="install"]').value,
	    install = E('div', {
			'class': 'btn cbi-button-action',
			'data-command': 'install',
			'data-package': name_or_url,
			'click': function(ev) {
				document.querySelector('input[name="install"]').value = '';
				handleOpkg(ev);
			}
		}, _('Install')), warning;

	if (!name_or_url.length) {
		return;
	}
	else if (name_or_url.indexOf('/') !== -1) {
		warning = E('p', {}, _('Installing packages from untrusted sources is a potential security risk! Really attempt to install <em>%h</em>?').format(name_or_url));
	}
	else if (!packages.available.providers[name_or_url]) {
		warning = E('p', {}, _('The package <em>%h</em> is not available in any configured repository.').format(name_or_url));
		install = '';
	}
	else {
		warning = E('p', {}, _('Really attempt to install <em>%h</em>?').format(name_or_url));
	}

	ui.showModal(_('Manually install package'), [
		warning,
		E('div', { 'class': 'right' }, [
			E('div', {
				'click': ui.hideModal,
				'class': 'btn cbi-button-neutral'
			}, _('Cancel')),
			' ', install
		])
	]);
}

function handleConfig(ev)
{
	var conf = {};

	ui.showModal(_('OPKG Configuration'), [
		E('p', { 'class': 'spinning' }, _('Loading configuration data…'))
	]);

	fs.list('/etc/opkg').then(function(partials) {
		var files = [ '/etc/opkg.conf' ];

		for (var i = 0; i < partials.length; i++)
			if (partials[i].type == 'file' && partials[i].name.match(/\.conf$/))
				files.push('/etc/opkg/' + partials[i].name);

		return Promise.all(files.map(function(file) {
			return fs.read(file)
				.then(L.bind(function(conf, file, res) { conf[file] = res }, this, conf, file))
				.catch(function(err) {
					ui.addNotification(null, E('p', {}, [ _('Unable to read %s: %s').format(file, err) ]));
					ui.hideModal();
					throw err;
				});
		}));
	}).then(function() {
		var body = [
			E('p', {}, _('Below is a listing of the various configuration files used by <em>opkg</em>. Use <em>opkg.conf</em> for global settings and <em>customfeeds.conf</em> for custom repository entries. The configuration in the other files may be changed but is usually not preserved by <em>sysupgrade</em>.'))
		];

		Object.keys(conf).sort().forEach(function(file) {
			body.push(E('h5', {}, '%h'.format(file)));
			body.push(E('textarea', {
				'name': file,
				'rows': Math.max(Math.min(L.toArray(conf[file].match(/\n/g)).length, 10), 3)
			}, '%h'.format(conf[file])));
		});

		body.push(E('div', { 'class': 'right' }, [
			E('div', {
				'class': 'btn cbi-button-neutral',
				'click': ui.hideModal
			}, _('Cancel')),
			' ',
			E('div', {
				'class': 'btn cbi-button-positive',
				'click': function(ev) {
					var data = {};
					findParent(ev.target, '.modal').querySelectorAll('textarea[name]')
						.forEach(function(textarea) {
							data[textarea.getAttribute('name')] = textarea.value
						});

					ui.showModal(_('OPKG Configuration'), [
						E('p', { 'class': 'spinning' }, _('Saving configuration data…'))
					]);

					Promise.all(Object.keys(data).map(function(file) {
						return fs.write(file, data[file]).catch(function(err) {
							ui.addNotification(null, E('p', {}, [ _('Unable to save %s: %s').format(file, err) ]));
						});
					})).then(ui.hideModal);
				},
				'disabled': isReadonlyView
			}, _('Save')),
		]));

		ui.showModal(_('OPKG Configuration'), body);
	});
}

function handleRemove(ev)
{
	var name = ev.target.getAttribute('data-package'),
	    pkg = packages.installed.pkgs[name],
	    avail = packages.available.pkgs[name] || {},
	    size, desc;

	if (avail.installsize)
		size = _('~%.1024mB installed').format(avail.installsize);
	else if (avail.size)
		size = _('~%.1024mB compressed').format(avail.size);
	else
		size = _('unknown');

	if (avail.description) {
		desc = E('div', {}, [
			E('h5', {}, _('Description')),
			E('p', {}, avail.description)
		]);
	}

	ui.showModal(_('Remove package <em>%h</em>').format(pkg.name), [
		E('ul', {}, [
			E('li', '<strong>%s:</strong> %h'.format(_('Version'), pkg.version)),
			E('li', '<strong>%s:</strong> %h'.format(_('Size'), size))
		]),
		desc || '',
		E('div', { 'style': 'display:flex; justify-content:space-between; flex-wrap:wrap' }, [
			E('label', { 'class': 'cbi-checkbox', 'style': 'float:left' }, [
				E('input', { 'id': 'autoremove-cb', 'type': 'checkbox', 'checked': 'checked', 'name': 'autoremove', 'disabled': isReadonlyView }), ' ',
				E('label', { 'for': 'autoremove-cb' }), ' ',
				_('Automatically remove unused dependencies')
			]),
			E('div', { 'style': 'flex-grow:1', 'class': 'right' }, [
				E('div', {
					'class': 'btn',
					'click': ui.hideModal
				}, _('Cancel')),
				' ',
				E('div', {
					'data-command': 'remove',
					'data-package': name,
					'class': 'btn cbi-button-negative',
					'click': handleOpkg,
					'disabled': isReadonlyView
				}, _('Remove'))
			])
		])
	]);
}

function handleOpkg(ev)
{
	return new Promise(function(resolveFn, rejectFn) {
		var cmd = ev.target.getAttribute('data-command'),
		    pkg = ev.target.getAttribute('data-package'),
		    rem = document.querySelector('input[name="autoremove"]'),
		    owr = document.querySelector('input[name="overwrite"]');

		var dlg = ui.showModal(_('Executing package manager'), [
			E('p', { 'class': 'spinning' },
				_('Waiting for the <em>opkg %h</em> command to complete…').format(cmd))
		]);

		var argv = [ cmd, '--force-removal-of-dependent-packages' ];

		if (rem && rem.checked)
			argv.push('--autoremove');

		if (owr && owr.checked)
			argv.push('--force-overwrite');

		if (pkg != null)
			argv.push(pkg);

		fs.exec_direct('/usr/libexec/opkg-call', argv, 'json').then(function(res) {
			dlg.removeChild(dlg.lastChild);

			if (res.stdout)
				dlg.appendChild(E('pre', [ res.stdout ]));

			if (res.stderr) {
				dlg.appendChild(E('h5', _('Errors')));
				dlg.appendChild(E('pre', { 'class': 'errors' }, [ res.stderr ]));
			}

			if (res.code !== 0)
				dlg.appendChild(E('p', _('The <em>opkg %h</em> command failed with code <code>%d</code>.').format(cmd, (res.code & 0xff) || -1)));

			dlg.appendChild(E('div', { 'class': 'right' },
				E('div', {
					'class': 'btn',
					'click': L.bind(function(res) {
						if (ui.menu && ui.menu.flushCache)
							ui.menu.flushCache();

						ui.hideModal();
						updateLists();

						if (res.code !== 0)
							rejectFn(new Error(res.stderr || 'opkg error %d'.format(res.code)));
						else
							resolveFn(res);
					}, this, res)
				}, _('Dismiss'))));
		}).catch(function(err) {
			ui.addNotification(null, E('p', _('Unable to execute <em>opkg %s</em> command: %s').format(cmd, err)));
			ui.hideModal();
		});
	});
}

function handleUpload(ev)
{
	var path = '/tmp/upload.ipk';
	return ui.uploadFile(path).then(L.bind(function(btn, res) {
		ui.showModal(_('Manually install package'), [
			E('p', {}, _('Installing packages from untrusted sources is a potential security risk! Really attempt to install <em>%h</em>?').format(res.name)),
			E('ul', {}, [
				res.size ? E('li', {}, '%s: %1024.2mB'.format(_('Size'), res.size)) : '',
				res.checksum ? E('li', {}, '%s: %s'.format(_('MD5'), res.checksum)) : '',
				res.sha256sum ? E('li', {}, '%s: %s'.format(_('SHA256'), res.sha256sum)) : ''
			]),
			E('div', { 'class': 'right' }, [
				E('div', {
					'click': function(ev) {
						ui.hideModal();
						fs.remove(path);
					},
					'class': 'btn cbi-button-neutral'
				}, _('Cancel')), ' ',
				E('div', {
					'class': 'btn cbi-button-action',
					'data-command': 'install',
					'data-package': path,
					'click': function(ev) {
						handleOpkg(ev).finally(function() {
							fs.remove(path)
						});
					}
				}, _('Install'))
			])
		]);
	}, this, ev.target));
}

function downloadLists()
{
	return Promise.all([
		callMountPoints(),
		fs.exec_direct('/usr/libexec/opkg-call', [ 'list-available' ]),
		fs.exec_direct('/usr/libexec/opkg-call', [ 'list-installed' ])
	]);
}

function updateLists(data)
{
	cbi_update_table('#packages', [],
		E('div', { 'class': 'spinning' }, _('Loading package information…')));

	packages.available = { providers: {}, pkgs: {} };
	packages.installed = { providers: {}, pkgs: {} };

	return (data ? Promise.resolve(data) : downloadLists()).then(function(data) {
		var pg = document.querySelector('.cbi-progressbar'),
		    mount = L.toArray(data[0].filter(function(m) { return m.mount == '/' || m.mount == '/overlay' }))
		    	.sort(function(a, b) { return a.mount > b.mount })[0] || { size: 0, free: 0 };

		pg.firstElementChild.style.width = Math.floor(mount.size ? ((100 / mount.size) * mount.free) : 100) + '%';
		pg.setAttribute('title', '%s (%.1024mB)'.format(pg.firstElementChild.style.width, mount.free));

		parseList(data[1], packages.available);
		parseList(data[2], packages.installed);

		display(document.querySelector('input[name="filter"]').value);
	});
}

var keyTimeout = null;

function handleKeyUp(ev) {
	if (keyTimeout !== null)
		window.clearTimeout(keyTimeout);

	keyTimeout = window.setTimeout(function() {
		display(ev.target.value);
	}, 250);
}

return view.extend({
	load: function() {
		return downloadLists();
	},

	render: function(listData) {
		var query = decodeURIComponent(L.toArray(location.search.match(/\bquery=([^=]+)\b/))[1] || '');

		var view = E([], [
			E('style', { 'type': 'text/css' }, [ css ]),

			E('h2', {}, _('Software')),

			E('div', { 'class': 'controls' }, [
				E('div', {}, [
					E('label', {}, _('Free space') + ':'),
					E('div', { 'class': 'cbi-progressbar', 'title': _('unknown') }, E('div', {}, [ '\u00a0' ]))
				]),

				E('div', {}, [
					E('label', {}, _('Filter') + ':'),
					E('span', { 'class': 'control-group' }, [
						E('input', { 'type': 'text', 'name': 'filter', 'placeholder': _('Type to filter…'), 'value': query, 'keyup': handleKeyUp }),
						E('button', { 'class': 'btn cbi-button', 'click': handleReset }, [ _('Clear') ])
					])
				]),

				E('div', {}, [
					E('label', {}, _('Download and install package') + ':'),
					E('span', { 'class': 'control-group' }, [
						E('input', { 'type': 'text', 'name': 'install', 'placeholder': _('Package name or URL…'), 'keydown': function(ev) { if (ev.keyCode === 13) handleManualInstall(ev) }, 'disabled': isReadonlyView }),
						E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': handleManualInstall, 'disabled': isReadonlyView }, [ _('OK') ])
					])
				]),

				E('div', {}, [
					E('label', {}, _('Actions') + ':'), ' ',
					E('span', { 'class': 'control-group' }, [
						E('button', { 'class': 'btn cbi-button-positive', 'data-command': 'update', 'click': handleOpkg, 'disabled': isReadonlyView }, [ _('Update lists…') ]), ' ',
						E('button', { 'class': 'btn cbi-button-action', 'click': handleUpload, 'disabled': isReadonlyView }, [ _('Upload Package…') ]), ' ',
						E('button', { 'class': 'btn cbi-button-neutral', 'click': handleConfig }, [ _('Configure opkg…') ])
					])
				])
			]),

			E('ul', { 'class': 'cbi-tabmenu mode' }, [
				E('li', { 'data-mode': 'available', 'class': 'available cbi-tab', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Available') ])),
				E('li', { 'data-mode': 'installed', 'class': 'installed cbi-tab-disabled', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Installed') ])),
				E('li', { 'data-mode': 'updates', 'class': 'installed cbi-tab-disabled', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Updates') ]))
			]),

			E('div', { 'class': 'controls', 'style': 'display:none' }, [
				E('div', { 'id': 'pager', 'class': 'center' }, [
					E('button', { 'class': 'btn cbi-button-neutral prev', 'aria-label': _('Previous page'), 'click': handlePage }, [ '«' ]),
					E('div', { 'class': 'text' }, [ 'dummy' ]),
					E('button', { 'class': 'btn cbi-button-neutral next', 'aria-label': _('Next page'), 'click': handlePage }, [ '»' ])
				])
			]),

			E('div', { 'id': 'packages', 'class': 'table' }, [
				E('div', { 'class': 'tr cbi-section-table-titles' }, [
					E('div', { 'class': 'th col-2 left' }, [ _('Package name') ]),
					E('div', { 'class': 'th col-2 left version' }, [ _('Version') ]),
					E('div', { 'class': 'th col-1 center size'}, [ _('Size (.ipk)') ]),
					E('div', { 'class': 'th col-10 left' }, [ _('Description') ]),
					E('div', { 'class': 'th right cbi-section-actions' }, [ '\u00a0' ])
				])
			])
		]);

		requestAnimationFrame(function() {
			updateLists(listData)
		});

		return view;
	},

	handleSave: null,
	handleSaveApply: null,
	handleReset: null
});