'use strict';
'require form';
'require fs';
'require uci';
'require ui';
'require rpc';
'require view';
'require dockerman.api as jsapi';

/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
LICENSE: GPLv2.0
*/

// docker df endpoint can take a while to resolve everything on *big* docker setups
// L.env.timeout = 40;

// Start docker API RPC methods

// If you define new declarations here, remember to export them at the bottom.
const container_changes = rpc.declare({
	object: 'docker.container',
	method: 'changes',
	params: { id: '' },
});

const container_create = rpc.declare({
	object: 'docker.container',
	method: 'create',
	params: { query: { }, body: { } },
});

/* We don't use rpcd for export functions, use controller instead
const container_export = rpc.declare({
	object: 'docker.container',
	method: 'export',
	params: { id: '' },
});
*/

const container_info_archive = rpc.declare({
	object: 'docker.container',
	method: 'info_archive',
	params: { id: '', query: { path: '/' } },
});

const container_inspect = rpc.declare({
	object: 'docker.container',
	method: 'inspect',
	params: { id: '', query: { } },
});

const container_kill = rpc.declare({
	object: 'docker.container',
	method: 'kill',
	params: { id: '', query: { } },
});

const container_list = rpc.declare({
	object: 'docker.container',
	method: 'list',
	params: { query: { } },
});

const container_logs = rpc.declare({
	object: 'docker.container',
	method: 'logs',
	params: { id: '', query: { } },
});

const container_pause = rpc.declare({
	object: 'docker.container',
	method: 'pause',
	params: { id: '' },
});

const container_prune = rpc.declare({
	object: 'docker.container',
	method: 'prune',
	params: { query: { } },
});

const container_remove = rpc.declare({
	object: 'docker.container',
	method: 'remove',
	params: { id: '', query: { } },
});

const container_rename = rpc.declare({
	object: 'docker.container',
	method: 'rename',
	params: { id: '', query: { } },
});

const container_restart = rpc.declare({
	object: 'docker.container',
	method: 'restart',
	params: { id: '', query: { } },
});

const container_start = rpc.declare({
	object: 'docker.container',
	method: 'start',
	params: { id: '', query: { } },
});

const container_stats = rpc.declare({
	object: 'docker.container',
	method: 'stats',
	params: { id: '', query: { 'stream': false, 'one-shot': true } },
});

const container_stop = rpc.declare({
	object: 'docker.container',
	method: 'stop',
	params: { id: '', query: { } },
});

const container_top = rpc.declare({
	object: 'docker.container',
	method: 'top',
	params: { id: '', query: { 'ps_args': '' } },
});

const container_unpause = rpc.declare({
	object: 'docker.container',
	method: 'unpause',
	params: { id: '' },
});

const container_update = rpc.declare({
	object: 'docker.container',
	method: 'update',
	params: { id: '', body: { } },
});

const container_ttyd_start = rpc.declare({
	object: 'docker.container',
	method: 'ttyd_start',
	params: { id: '', cmd: '/bin/sh', port: 7682, uid: '' },
});

// Data Usage
const docker_df = rpc.declare({
	object: 'docker',
	method: 'df',
});

const docker_events = rpc.declare({
	object: 'docker',
	method: 'events',
	params: { query: { since: '', until: '', filters: '' } }
});

const docker_info = rpc.declare({
	object: 'docker',
	method: 'info',
});

const docker_version = rpc.declare({
	object: 'docker',
	method: 'version',
});

/* We don't use rpcd for import/build functions, use controller instead
const image_build = rpc.declare({
	object: 'docker.image',
	method: 'build',
	params: { query: { }, headers: { } },
});
*/

const image_create = rpc.declare({
	object: 'docker.image',
	method: 'create',
	params: { query: { }, headers: { } },
});

/* We don't use rpcd for export functions, use controller instead
const image_get = rpc.declare({
	object: 'docker.image',
	method: 'get',
	params: { id: '', query: { } },
});
*/

const image_history = rpc.declare({
	object: 'docker.image',
	method: 'history',
	params: { id: '' },
});

const image_inspect = rpc.declare({
	object: 'docker.image',
	method: 'inspect',
	params: { id: '' },
});

const image_list = rpc.declare({
	object: 'docker.image',
	method: 'list',
});

const image_prune = rpc.declare({
	object: 'docker.image',
	method: 'prune',
	params: { query: { } },
});

const image_push = rpc.declare({
	object: 'docker.image',
	method: 'push',
	params: { name: '', query: { }, headers: { } },
});

const image_remove = rpc.declare({
	object: 'docker.image',
	method: 'remove',
	params: { id: '', query: { } },
});

const image_tag = rpc.declare({
	object: 'docker.image',
	method: 'tag',
	params: { id: '', query: { } },
});

const network_connect = rpc.declare({
	object: 'docker.network',
	method: 'connect',
	params: { id: '', body: {} },
});

const network_create = rpc.declare({
	object: 'docker.network',
	method: 'create',
	params: { body: {} },
});

const network_disconnect = rpc.declare({
	object: 'docker.network',
	method: 'disconnect',
	params: { id: '', body: {} },
});

const network_inspect = rpc.declare({
	object: 'docker.network',
	method: 'inspect',
	params: { id: '' },
});

const network_list = rpc.declare({
	object: 'docker.network',
	method: 'list',
});

const network_prune = rpc.declare({
	object: 'docker.network',
	method: 'prune',
	params: { query: { } },
});

const network_remove = rpc.declare({
	object: 'docker.network',
	method: 'remove',
	params: { id: '' },
});

const volume_create = rpc.declare({
	object: 'docker.volume',
	method: 'create',
	params: { opts: {} },
});

const volume_inspect = rpc.declare({
	object: 'docker.volume',
	method: 'inspect',
	params: { id: '' },
});

const volume_list = rpc.declare({
	object: 'docker.volume',
	method: 'list',
});

const volume_prune = rpc.declare({
	object: 'docker.volume',
	method: 'prune',
	params: { query: { } },
});

const volume_remove = rpc.declare({
	object: 'docker.volume',
	method: 'remove',
	params: { id: '', query: { } },
});

// End docker API RPC methods

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


const callRcInit = rpc.declare({
	object: 'rc',
	method: 'init',
	params: [ 'name', 'action' ],
});

// End generic API methods


const builder = Object.freeze({
	prune: {e: '✂️', i18n: _('prune')},
});


const container = Object.freeze({
	attach: {e: '🔌', i18n: _('attach')},
	commit: {e: '🎯', i18n: _('commit')},
	copy: {e: '📃➡️📃', i18n: _('copy')},
	create: {e: '➕', i18n: _('create')},
	destroy: {e: '💥', i18n: _('destroy')},
	detach: {e: '❌🔌', i18n: _('detach')},
	die: {e: '🪦', i18n: _('die')},
	exec_create: {e: '➕', i18n: _('exec_create')},
	exec_detach: {e: '❌🔌', i18n: _('exec_detach')},
	exec_start: {e: '▶️', i18n: _('exec_start')},
	exec_die: {e: '🪦', i18n: _('exec_die')},
	export: {e: '📤⬇️', i18n: _('export')},
	health_status: {e: '🩺⚕️', i18n: _('health_status')},
	kill: {e: '☠️', i18n: _('kill')},
	oom: {e: '0️⃣🧠', i18n: _('oom')},
	pause: {e: '⏸️', i18n: _('pause')},
	rename: {e: '✍️', i18n: _('rename')},
	resize: {e: '↔️', i18n: _('resize')},
	restart: {e: '🔄', i18n: _('restart')},
	start: {e: '▶️', i18n: _('start')},
	stop: {e: '⏹️', i18n: _('stop')},
	top: {e: '🔝', i18n: _('top')},
	unpause: {e: '⏯️', i18n: _('unpause')},
	update: {e: '✏️', i18n: _('update')},
	prune: {e: '✂️', i18n: _('prune')},
});


const daemon = Object.freeze({
	reload: {e: '🔄', i18n: _('reload')},
});


const image = Object.freeze({
	create: {e: '➕', i18n: _('create')},
	delete: {e: '❌', i18n: _('delete')},
	import: {e: '➡️', i18n: _('Import')},
	load: {e: '⬆️', i18n: _('load')},
	pull: {e: '☁️⬇️', i18n: _('Pull')},
	push: {e: '☁️⬆️', i18n: _('Push')},
	save: {e: '💾', i18n: _('save')},
	tag: {e: '🏷️', i18n: _('tag')},
	untag: {e: '❌🏷️', i18n: _('untag')},
	prune: {e: '✂️', i18n: _('prune')},
});


const network = Object.freeze({
	create: {e: '➕', i18n: _('create')},
	connect: {e: '🔗', i18n: _('connect')},
	disconnect: {e: '⛓️‍💥', i18n: _('disconnect')},
	destroy: {e: '💥', i18n: _('destroy')},
	update: {e: '✏️', i18n: _('update')},
	remove: {e: '❌', i18n: _('remove')},
	prune: {e: '✂️', i18n: _('prune')},
});


const volume = Object.freeze({
	create: {e: '➕', i18n: _('create')},
	mount: {e: '⬆️', i18n: _('mount')},
	unmount: {e: '⬇️', i18n: _('unmount')},
	destroy: {e: '💥', i18n: _('destroy')},
	prune: {e: '✂️', i18n: _('prune')},
});


const CURTypes = Object.freeze({
	create: {e: '➕', i18n: _('create')},
	update: {e: '✏️', i18n: _('update')},
	remove: {e: '❌', i18n: _('remove')},
});


const config = CURTypes;
const node = CURTypes;
const secret = CURTypes;
const service = CURTypes;


const Types = Object.freeze({
	builder: {e: '🛠️', i18n: _('builder'), sub: builder},
	config: {e: '⚙️', i18n: _('config'), sub: config},
	container: {e: '🐳', i18n: _('container'), sub: container},
	daemon: {e: '🔁', i18n: _('daemon'), sub: daemon},
	image: {e: '🌄', i18n: _('image'), sub: image},
	network: {e: '모', i18n: _('network'), sub: network },
	node: {e: '✳️', i18n: _('node'), sub: node },
	plugin: {e: '🔌', i18n: _('plugin') },
	secret: {e: '🔐', i18n: _('secret'), sub: secret },
	service: {e: '🛎️', i18n: _('service'), sub: service },
	volume: {e: '💿', i18n: _('volume'), sub: volume},
});


const ActionTypes = Object.freeze({
	build: {e: '🏗️', i18n: _('Build')},
	clean: {e: '🧹', i18n: _('Clean')},
	create: {e: '🪄➕', i18n: _('Create')},
	edit: {e: '✏️', i18n: _('Edit')},
	force_remove: {e: '❌', i18n: _('Force remove')},
	history: {e: '🪶📜', i18n: _('History')},
	inspect: {e: '🔎', i18n: _('Inspect') },
	remove: {e: '❌', i18n: _('Remove')},
	save: {e: '⬇️', i18n: _('Save locally')},
	upload: {e: '⬆️', i18n: _('Upload')},
	prune: {e: '✂️', i18n: _('Prune')},
});


const ignored_headers = ['cache-control', 'connection', 'content-length', 'content-type', 'pragma',
	'Components', 'Platform'];

const dv = view.extend({
	outputText: '', // Initialize output text

	get dockerman_url() {
		return L.url('admin/services/dockerman');
	},

	parseHeaders(headers, array) {
		for(const [k, v] of Object.entries(headers)) {
			if (ignored_headers.includes(k)) continue;
			array.push({entry: k, value: v});
		}
	},

	parseBody(body, array) {
		for(const [k, v] of Object.entries(body)) {
			if (ignored_headers.includes(k)) continue;
			if (!v) continue;
			array.push({entry: k, value: (typeof v !== 'string') ? JSON.stringify(v) : v});
		}
	},

	rwxToMode(val) {
		if (!val) return undefined;
		const raw = String(val).trim();
		if (/^[0-7]+$/.test(raw)) return parseInt(raw, 8);
		const normalized = raw.replace(/[^rwx-]/gi, '').padEnd(9, '-').slice(0, 9);
		const chunkToNum = (chunk) => (
			(chunk[0] === 'r' ? 4 : 0) +
			(chunk[1] === 'w' ? 2 : 0) +
			(chunk[2] === 'x' ? 1 : 0)
		);
		const owner = chunkToNum(normalized.slice(0, 3));
		const group = chunkToNum(normalized.slice(3, 6));
		const other = chunkToNum(normalized.slice(6, 9));
		return (owner << 6) + (group << 3) + other;
	},

	modeToRwx(mode) {
		const perms = mode & 0o777; // extract permission bits

		const toRwx = n => 
			((n & 4) ? 'r' : '-') +
			((n & 2) ? 'w' : '-') +
			((n & 1) ? 'x' : '-');

		const owner = toRwx((perms >> 6) & 0b111);
		const group = toRwx((perms >> 3) & 0b111);
		const world = toRwx(perms & 0b111);

		return `${owner}${group}${world}`;
	},

	parseMemory(value) {
		if (!value) return 0;
		const rex = /^([0-9.]+) *([bkmgtp])?i? *[Bb]?/i;
		let [, amount, unit] = rex.exec(value.toLowerCase());
		amount = amount ? Number.parseFloat(amount) : 0;
		switch (unit) {
		default: break;
		case 'k': amount *= (2 ** 10); break;
		case 'm': amount *= (2 ** 20); break;
		case 'g': amount *= (2 ** 30); break;
		case 't': amount *= (2 ** 40); break;
		case 'p': amount *= (2 ** 50); break;
		}
		return amount;
	},

	listToKv: (list) => {
		const kv = {};
		const items = Array.isArray(list) ? list : (list != null ? [list] : []);
		items.forEach((entry) => {
			if (typeof entry !== 'string')
				return;

			const pos = entry.indexOf('=');
			if (pos <= 0)
				return;

			const key = entry.slice(0, pos);
			const val = entry.slice(pos + 1);
			if (key)
				kv[key] = val;
		});
		return kv;
	},

	objectToText(object) {
		let result = '';
		if (!object || typeof object !== 'object') return result;
		if (Object.keys(object).length === 0) return result;
		for (const [k, v] of Object.entries(object))
			result += `${!result ? '' : ', '}${k}: ${typeof v === 'object' ? this.objectToText(v) : v}`
		return result;
	},

	objectCfgValueTT(sid) {
		const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
		return (val != null && typeof val === 'object') ? dv.prototype.objectToText.call(dv.prototype, val) : val;
	},

	insertOutputFrame(s, m) {
		const frame = E('div', {
				'class': 'cbi-section'
			}, [
				E('h3', {}, _('Operational Output')),
				E('textarea', {
					'readonly': true,
					'rows': 30,
					'style': 'width: 100%; font-family: monospace;',
					'id': 'inspect-output-text'
				}, this.outputText)
			]);
		if (!m) return frame;
		// Output section, for inspect results
		s = m.section(form.NamedSection, null, 'inspect');
		s.anonymous = true;
		s.render = L.bind(() => {
			return frame;
		}, this);
	},

	insertOutput(text) {
		// send text to the output text-area and scroll to bottom
		this.outputText = text;
		const textarea = document.getElementById('inspect-output-text');
		if (textarea) {
			textarea.value = this.outputText;
			textarea.scrollTop = textarea.scrollHeight;
		}
	},

	buildTimeString(unixtime) {
		return new Date(unixtime * 1000).toLocaleDateString([], {
			year: 'numeric',
			month: '2-digit',
			day: '2-digit',
			hour: '2-digit',
			minute: '2-digit',
			second: '2-digit',
			hour12: false
		});
	},

	buildNetworkListValues(networks, option) {
		for (const network of networks) {
			let name = `${network?.Name}`;
			name += network?.Driver ? ` | ${network?.Driver}` : '';
			name += network?.IPAM?.Config?.[0] ? ` | ${network?.IPAM?.Config?.[0]?.Subnet}` : '';
			name += network?.IPAM?.Config?.[1] ? ` | ${network?.IPAM?.Config?.[1]?.Subnet}` : '';
			option.value(network?.Name, name);
		}
	},

	getContainerStatus(this_container) {
		if (!this_container?.State)
			return 'unknown';
		const state = this_container.State;
		if (state.Status === 'paused')
			return 'paused';
		else if (state.Running && !state.Restarting)
			return 'running';
		else if (state.Running && state.Restarting)
			return 'restarting';
		return 'stopped';
	},

	getImageFirstTag(image_list, image_id) {
		const imageArray = Array.isArray(image_list) ? image_list : [];
		const imageInfo = imageArray.find(img => img?.Id === image_id);
		const imageName = imageInfo && Array.isArray(imageInfo.RepoTags) 
			? imageInfo.RepoTags[0] 
			: 'unknown';
		return imageName;
	},

	parseNetworkLinksForContainer(networks, containerNetworks, name_links) {
		const links = [];

		if (!Array.isArray(containerNetworks)) {
			if (containerNetworks && typeof containerNetworks === 'object')
				containerNetworks = Object.values(containerNetworks);
		}

		for (const cNet of containerNetworks) {
			const network = networks.find(n => 
				n.Name === cNet.Name || 
				n.Id === cNet?.NetworkID ||
				n.Id === cNet.Name
			);

			if (network) {
				links.push(E('a', {
					href: `${this.dockerman_url}/network/${network.Id}`,
					title: network.Id,
					style: 'white-space: nowrap;'
				}, [name_links ? network.Name : network.Id.slice(0,12)]));
			}
		}

		if (!links.length)
			return '-';

		// Join with pipes
		const out = [];
		for (let i = 0; i < links.length; i++) {
			out.push(links[i]);
			if (i < links.length - 1)
				out.push(' | ');
		}

		return E('div', {}, out);
	},

	parseContainerLinksForNetwork(network, containers) {
		// Find all containers connected to this network
		const containerLinks = [];
		for (const cont of containers) {
			let isConnected = false;
			if (cont.NetworkSettings?.Networks?.[network?.Name]) {
				isConnected = true;
			}
			else
			if (cont.NetworkSettings?.Networks?.[network?.Id]) {
				isConnected = true;
			}

			if (isConnected) {
				const containerName = cont.Names?.[0]?.replace(/^\//, '') || cont.Id?.substring(0, 12);
				const containerId = cont.Id;

				containerLinks.push(E('a', {
					href: `${this.dockerman_url}/container/${containerId}`,
					title: containerId,
					style: 'white-space: nowrap;'
				}, [containerName]));
			}
		}

		if (!containerLinks.length)
			return '-';

		// Join with pipes
		const out = [];
		for (let i = 0; i < containerLinks.length; i++) {
			out.push(containerLinks[i]);
			if (i < containerLinks.length - 1)
				out.push(' | ');
		}

		return E('div', {}, out);
	},

	statusColor(status) {
		const s = (status || '').toLowerCase();
		if (s === 'running') return '#2ecc71'; // green
		if (s === 'paused') return '#f39c12'; // orange
		if (s === 'restarting') return '#f39c12'; // orange
		return '#d9534f'; // red for stopped/other
	},

	wrapStatusText(text, status, extraStyle = '') {
		const color = this.statusColor(status);
		return E('span', { style: `color:${color};${extraStyle || ''}` }, [text]);
	},

	/**
	 * Show a notification to the user with standardized formatting
	 * @param {string} title - The title of the notification (will be translated if needed)
	 * @param {string|Array<string>} message - Message(s) to display
	 * @param {number} [duration=5000] - Duration in milliseconds
	 * @param {string} [type='info'] - Type: 'success', 'info', 'warning', 'error'
	 */
	showNotification(title, message, duration = 5000, type = 'info') {
		const messages = Array.isArray(message) ? message : [message];
		ui.addTimeLimitedNotification(title, messages, duration, type);
	},

	/**
	 * Normalize a registry host address by stripping scheme and path
	 * @param {string} address - The registry address to normalize
	 * @returns {string|null} - Normalized hostname or null
	 */
	normalizeRegistryHost(address) {
		if (!address) return null;

		let addr = String(address).trim();
		// make exception for legacy Docker Hub registry https://index.docker.io/v1/
		if (addr.includes('index.docker.io'))
			return addr.toLowerCase();
		else {
			addr = addr.replace(/^[a-z]+:\/\//i, '');
			addr = addr.split('/')[0];
			addr = addr.replace(/\/$/, '');
		}
		if (!addr) return null;
		return addr.toLowerCase();
	},

	/**
	 * Ensure registry address has https:// scheme
	 * @param {string} address - The registry address
	 * @param {string} hostFallback - Fallback host if address is empty
	 * @returns {string|null} - Address with scheme or null
	 */
	ensureRegistryScheme(address, hostFallback) {
		const addr = String(address || '').trim() || hostFallback;
		if (!addr) return null;
		/* jsmin cannot handle /^https?:\/\//i.test(addr) - wrap in parentheses: OK
		https = new RegExp('https?:\/\/', 'i'); // also OK
		 */
		return (/^https?:\/\//i.test(addr)) ? addr : `https://${addr}`;
	},

	/**
	 * Encode auth object to base64
	 * @param {Object} obj - Object with username, password, serveraddress
	 * @returns {string|null} - Base64 encoded JSON or null on failure
	 */
	encodeBase64Json(obj) {
		const json = JSON.stringify(obj);
		try {
			return btoa(json);
		} catch (err) {
			try {
				return btoa(unescape(encodeURIComponent(json)));
			} catch (err2) {
				console.warn('Failed to encode registry auth', err2?.message || err2);
				return null;
			}
		}
	},

	/**
	 * Extract registry host from image tag
	 * @param {string} tag - The image tag
	 * @returns {string|null} - Registry hostname or null
	 */
	extractRegistryHostFromImage(tag) {
		if (!tag) return null;

		let ref = String(tag).trim();
		ref = ref.replace(/^[a-z]+:\/\//i, '');

		const slashIdx = ref.indexOf('/');
		const candidate = slashIdx === -1 ? ref : ref.slice(0, slashIdx);
		if (!candidate) return null;

		const hasDot = candidate.includes('.');
		const hasPort = /:[0-9]+$/.test(candidate);
		const isLocal = candidate === 'localhost' || candidate.startsWith('localhost:');
		if (!hasDot && !hasPort && !isLocal) return null;

		return candidate.toLowerCase();
	},

	/**
	 * Resolve registry credentials and build auth header
	 * @param {string} imageRef - The image reference
	 * @param {Map} registryAuthMap - Map of registry host to credentials
	 * @returns {string|null} - Base64 encoded auth string or null
	 */
	resolveRegistryAuth(imageRef, registryAuthMap) {
		const host = this.extractRegistryHostFromImage(imageRef);
		if (!host) return null;

		const creds = registryAuthMap.get(host);
		if (!creds?.username || !creds?.password) return null;

		return this.encodeBase64Json({
			username: creds.username,
			password: creds.password,
			serveraddress: this.ensureRegistryScheme(creds.serveraddress, host)
		});
	},

	/**
	 * Load registry auth credentials from UCI config
	 * @returns {Promise<Map>} - Promise resolving to Map of registry host to credentials
	 */
	loadRegistryAuthMap() {
		return new Promise((resolve) => {
			// Load UCI and extract auth sections
			const authMap = new Map();
			L.resolveDefault(uci.load('dockerd'), {}).then(() => {
				uci.sections('dockerd', 'auth', (section) => {
					const serverRaw = section?.serveraddress;
					const host = this.normalizeRegistryHost(serverRaw);
					if (!host) return;

					const username = section?.username || section?.user;
					const password = section?.token || section?.password;
					if (!username || !password) return;

					authMap.set(host, {
						username,
						password,
						serveraddress: serverRaw || host,
					});
				});
				resolve(authMap);
			}).catch(() => {
				// If loading fails, return empty map
				resolve(authMap);
			});
		});
	},

	/**
	 * Handle Docker API response with unified error checking and user feedback
	 * @param {Object} response - The Docker API response object
	 * @param {string} actionName - Name of the action (e.g., 'Start', 'Remove')
	 * @param {Object} [options={}] - Optional configuration
	 * @param {boolean} [options.showOutput=true] - Whether to insert JSON output
	 * @param {boolean} [options.showSuccess=true] - Whether to show success notification
	 * @param {string} [options.successMessage] - Custom success message
	 * @param {number} [options.successDuration=4000] - Success notification duration
	 * @param {number} [options.errorDuration=7000] - Error notification duration
	 * @param {Function} [options.onSuccess] - Callback on success
	 * @param {Function} [options.onError] - Callback on error
	 * @param {Object} [options.specialCases] - Map of status codes to handlers {304: {message: '...', type: 'notice'}}
	 * @returns {boolean} - true if successful, false otherwise
	 */
	handleDockerResponse(response, actionName, options = {}) {
		const {
			showOutput = true,
			showSuccess = true,
			successMessage = _('OK'),
			successDuration = 4000,
			errorDuration = 7000,
			onSuccess = null,
			onError = null,
			specialCases = {}
		} = options;

		// Handle special status codes first (e.g., 304 Not Modified)
		if (specialCases[response?.code]) {
			const special = specialCases[response.code];
			this.showNotification(
				actionName,
				special.message || _('No changes needed'),
				special.duration || 5000,
				special.type || 'notice'
			);
			if (onSuccess) onSuccess(response);
			return true;
		}

		// Insert output if requested
		if (showOutput && response?.body != null) {
			const outputText = response?.body !== "" 
				? (Array.isArray(response.body) || typeof response.body === 'object' 
					? JSON.stringify(response.body, null, 2) + '\n' 
					: String(response.body) + '\n')
				: `${response?.code} ${_('OK')}\n`;
			this.insertOutput(outputText);
		}

		// Check for errors (HTTP status >= 304)
		if (response?.code >= 304) {
			this.showNotification(
				actionName,
				response?.body?.message || _('Operation failed'),
				errorDuration,
				'warning'
			);
			if (onError) onError(response);
			return false;
		}

		// Success case
		if (showSuccess) {
			this.showNotification(actionName, successMessage, successDuration, 'success');
		}
		if (onSuccess) onSuccess(response);
		return true;
	},

	async getRegistryAuth(params, actionName) {
		// Extract registry candidate from params
		let registryCandidate = null;
		if (params?.query?.fromImage) {
			registryCandidate = params.query.fromImage;
		} else if (params?.query?.tag) {
			registryCandidate = params.query.tag;
		}

		if (params?.name && actionName === Types['image'].sub['push'].i18n) {
			registryCandidate = params.name;
		}

		// Try to load and inject registry auth if we have a registry candidate
		if (registryCandidate) {
			try {
				const authMap = await this.loadRegistryAuthMap();
				const auth = this.resolveRegistryAuth(registryCandidate, authMap);
				if (auth) {
					if (!params.headers) {
						params.headers = {};
					}
					params.headers['X-Registry-Auth'] = auth;
				}
			} catch (err) {
				// If auth loading fails, proceed without auth
			}
		}

		return params;
	},

	/**
	 * Execute a Docker API action with consistent error handling and user feedback
	 * Automatically adds X-Registry-Auth header for push/pull operations if credentials exist
	 * Uses streaming for pull/push operations via onChunk callback
	 * @param {Function} apiMethod - The Docker API method to call
	 * @param {Object} params - Parameters to pass to the API method
	 * @param {string} actionName - Display name for the action
	 * @param {Object} [options={}] - Options for handleDockerResponse
	 * @returns {Promise<boolean>} - Promise that resolves to true/false based on success
	 */
	async executeDockerAction(apiMethod, params, actionName, options = {}) {
		try {
			params = await this.getRegistryAuth(params, actionName);

			// Detect if this is a streaming operation and add callback if needed
			const isPull = params?.query?.fromImage;
			const isPush = params?.name;
			const useStreaming = (isPull || isPush) && options.showOutput !== false;

			if (useStreaming) {
				params.onChunk = (chunk) => {
					const output = chunk.raw || JSON.stringify(chunk, null, 2);
					this.insertOutput(output + '\n');
				};
			}

			// Execute the API call
			const response = await apiMethod(params);
			return this.handleDockerResponse(response, actionName, options);

		} catch (err) {
			this.showNotification(
				actionName,
				err?.message || String(err) || _('Unexpected error'),
				options.errorDuration || 7000,
				'error'
			);
			if (options.onError) options.onError(err);
			return false;
		}
	},

	/**
	 * Flexible file/URI transfer with progress tracking and API preference
	 * @param {Object} options - Upload configuration
	 * @param {string} [options.method] - method to use: POST, PUT, etc
	 * @param {string} [options.commandCPath] - controller API endpoint path (e.g. '/images/load')
	 * @param {string} [options.commandDPath] - docker API endpoint path (e.g. '/images/load')
	 * @param {string} [options.commandTitle] - Title for the command modal
	 * @param {string} [options.commandMessage] - Message shown during command
	 * @param {string} [options.successMessage] - Message on successful command
	 * @param {string} [options.pathElementId] - Optional ID of element containing command path
	 * @param {string} [options.defaultPath='/'] - Default path if pathElementId is not provided
	 * @param {Function} [options.getFormData] - Optional function to customize FormData (receives file, path)
	 * @param {Function} [options.onUpdate] - Optional function to report status progress fed back
	 * @param {boolean} [options.noFileUpload] - If true, only a URI is uploaded (no file)
	 */
	async handleXHRTransfer(options = {}) {
		const {
			q_params = {},
			method = 'POST',
			commandCPath = null,
			commandDPath = null,
			commandTitle = null, //_('Uploading…'),
			commandMessage = null, //_('Uploading file…'),
			successMessage = _('Successful'),
			showProgress = true,
			pathElementId = null,
			defaultPath = '/',
			getFormData = null,
			onUpdate = null,
			noFileUpload = false,
		} = options;

		const view = this;
		let commandPath = defaultPath;
		let params = await this.getRegistryAuth(q_params, commandTitle);

		// Get path from element if specified
		if (pathElementId) {
			commandPath = document.getElementById(pathElementId)?.value || defaultPath;
			if (!commandPath || commandPath === '') {
				this.showNotification(_('Error'), _('Please specify a path'), 5000, 'error');
				return;
			}
		}

		// Build query string if params provided
		let query_str = '';
		if (params.query) {
			let parts = [];
			for (let [key, value] of Object.entries(params.query)) {
				if (key != null && value != '') {
					if (Array.isArray(value)) {
						value.map(e => parts.push(`${key}=${e}`));
						continue;
					}
					parts.push(`${key}=${value}`);
				}
			}
			if (parts.length)
				query_str = '?' + parts.join('&');
		}

		// Prefer JS API if available, else fallback to controller
		let destUrl = `${this.dockerman_url}${commandCPath}${query_str}`;
		let useRawFile = false;
		try {
			const [ok, host] = await apiReady;
			if (ok && host) {
				destUrl = host + commandDPath + query_str;
				useRawFile = true;
			}
		} catch { }

		// Show progress dialog with progress bar element
		let progressBar = E('div', { 
			'style': 'width:0%; background-color: #0066cc; height: 20px; border-radius: 3px; transition: width 0.3s ease;' 
		});
		let msgTxt = E('p', {}, commandMessage);
		let msgTitle = E('h4', {}, commandTitle);
		let progressText = E('p', {}, '0%');

		if (showProgress) {
			ui.showModal(msgTitle, [
				msgTxt,
				progressText,
				E('div', { 
					'class': 'cbi-progressbar', 
					'style': 'margin: 10px 0; background-color: #e0e0e0; border-radius: 3px; overflow: hidden;' 
				}, progressBar)
			]);
		}

		const xhr = new XMLHttpRequest();
		xhr.timeout = 0;

		// Track upload progress
		xhr.upload.addEventListener('progress', (e) => {
			if (e.lengthComputable) {
				const percentComplete = Math.round((e.loaded / e.total) * 100);
				progressBar.style.width = percentComplete + '%';
				progressText.textContent = percentComplete + '%';
			}
		});

		// Track progressive response progress
		let lastIndex = 0;
		// let title = _('Progress');
		xhr.onprogress = (upd) => {
			const chunk = xhr.responseText.slice(lastIndex);
			lastIndex = xhr.responseText.length;
			const lines = chunk.split('\n').filter(Boolean);
			for (const line of lines) {
				try {
					const msg = JSON.parse(line);
					const percentComplete = Math.round((msg?.progressDetail?.current / msg?.progressDetail?.total) * 100) || 0;
					if (msg.stream && msg.stream != '\n')
						msgTxt.innerHTML = ansiToHtml(msg?.stream);
					if (msg.status)
						msgTitle.innerHTML = msg?.status
					progressBar.style.width = percentComplete + '%';
					progressText.textContent = percentComplete + '%';
					if (onUpdate) onUpdate(msg);
				} catch (e) {}
			}
		};

		xhr.addEventListener('load', () => {
			ui.hideModal();
			if (xhr.status >= 200 && xhr.status < 300) {
				view.showNotification(
					_('Command successful'),
					successMessage,
					4000,
					'success'
				);
			} else {
				let errorMsg = xhr.responseText || `HTTP ${xhr.status}`;
				try {
					const json = JSON.parse(xhr.responseText);
					errorMsg = json.error || errorMsg;
				} catch (e) {}
				view.showNotification(
					_('Command failed'),
					errorMsg,
					7000,
					'error'
				);
			}
		});

		xhr.addEventListener('error', () => {
			ui.hideModal();
			view.showNotification(
				_('Command failed'),
				_('Network error'),
				7000,
				'error'
			);
		});

		xhr.addEventListener('abort', () => {
			ui.hideModal();
			view.showNotification(
				_('Command cancelled'),
				'',
				5000,
				'warning'
			);
		});

		if (noFileUpload) {
			this.handleURLOnlyForm(xhr, method, params, destUrl);
		} else {
			this.handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile);
		}
	},


	handleURLOnlyForm(xhr, method, params, destUrl) {
		const formData = new FormData();
		formData.append('token', L.env.token);
		// if (params.name)
		// 	formData.append('name', params.name);

		xhr.open(method, destUrl);
		xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
		if (params.headers)
			for (let [hdr_name, hdr_value] of Object.entries(params.headers)) {
				xhr.setRequestHeader(hdr_name, hdr_value);
				// smuggle in the X-Registry-Auth header in the form data
				formData.append(hdr_name, hdr_value);
			}
		destUrl.includes(L.env.scriptname) ? xhr.send(formData) : xhr.send();
	},


	handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile) {
		const fileInput = document.createElement('input');
		fileInput.type = 'file';
		fileInput.style.display = 'none';

		fileInput.onchange = (ev) => {
			const files = ev.target?.files;
			if (!files || files.length === 0) {
				ui.hideModal();
				return;
			}
			const file = files[0];

			// Create FormData with file
			let formData;
			if (getFormData) {
				formData = getFormData(file, commandPath);
			} else {
				formData = new FormData();
				/* 'token' is necessary when "post": true is defined for image load endpoint */
				formData.append('token', L.env.token);
				formData.append('upload-name', file.name);
				formData.append('upload-archive', file);
			}

			xhr.open(method, destUrl);
			xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
			
			if (useRawFile) {
				xhr.setRequestHeader('Content-Type', 'application/x-tar');
				xhr.send(file);
			} else {
				xhr.send(formData);
			}
		};

		fileInput.oncancel = (ev) => {
			ui.hideModal();
			return;
		}

		// Trigger file picker
		document.body.appendChild(fileInput);
		fileInput.click();
		document.body.removeChild(fileInput);
	},
});

// ANSI color code converter to HTML
const ansiToHtml = function(text) {
	if (!text) return '';

	// First, strip out terminal control sequences that aren't color codes
	// These include cursor positioning, screen clearing, etc.
	text = text
		// Strip CSI sequences (cursor movement, screen clearing, etc.)
		// eslint-disable-next-line no-control-regex
		.replace(/\x1B\[[0-9;?]*[A-Za-z]/g, (match) => {
			// Keep only SGR (Select Graphic Rendition) sequences ending in 'm'
			if (match.endsWith('m')) {
				return match;
			}
			// Strip everything else (cursor positioning, screen clearing, etc.)
			return '';
		})
		// Strip OSC sequences (window title, etc.)
		// eslint-disable-next-line no-control-regex
		.replace(/\x1B\][^\x07]*\x07/g, '')
		// Strip other escape sequences
		// eslint-disable-next-line no-control-regex
		.replace(/\x1B[><=]/g, '')
		// Strip bell character
		// eslint-disable-next-line no-control-regex
		.replace(/\x07/g, '');

	// ANSI color codes mapping
	const ansiColorMap = {
		'30': '#000000', // Black
		'31': '#FF5555', // Red
		'32': '#55FF55', // Green
		'33': '#FFFF55', // Yellow
		'34': '#5555FF', // Blue
		'35': '#FF55FF', // Magenta
		'36': '#55FFFF', // Cyan
		'37': '#FFFFFF', // White
		'90': '#555555', // Bright Black
		'91': '#FF8787', // Bright Red
		'92': '#87FF87', // Bright Green
		'93': '#FFFF87', // Bright Yellow
		'94': '#8787FF', // Bright Blue
		'95': '#FF87FF', // Bright Magenta
		'96': '#87FFFF', // Bright Cyan
		'97': '#FFFFFF', // Bright White
	};

	// Escape HTML special characters
	const escapeHtml = (str) => {
		const map = {
			'&': '&amp;',
			'<': '&lt;',
			'>': '&gt;',
			'"': '&quot;',
			"'": '&#039;'
		};
		return str.replace(/[&<>"']/g, m => map[m]);
	};

	// Split by ANSI escape sequences and process
	// eslint-disable-next-line no-control-regex
	const ansiRegex = /\x1B\[([\d;]*)m/g;
	let html = '';
	let currentStyle = {};
	let lastIndex = 0;
	let match;
	let textBuffer = '';

	// Helper to flush current text with current style
	const flushText = () => {
		if (textBuffer) {
			const escaped = escapeHtml(textBuffer);
			if (Object.keys(currentStyle).length > 0) {
				let styleStr = '';
				if (currentStyle.color) {
					styleStr += `color: ${currentStyle.color};`;
				}
				if (currentStyle.bgColor) {
					styleStr += `background-color: ${currentStyle.bgColor};`;
				}
				if (currentStyle.bold) {
					styleStr += 'font-weight: bold;';
				}
				if (currentStyle.italic) {
					styleStr += 'font-style: italic;';
				}
				if (currentStyle.underline) {
					styleStr += 'text-decoration: underline;';
				}
				if (styleStr) {
					html += `<span style="${styleStr}">${escaped}</span>`;
				} else {
					html += escaped;
				}
			} else {
				html += escaped;
			}
			textBuffer = '';
		}
	};

	while ((match = ansiRegex.exec(text)) !== null) {
		// Add text before this escape sequence
		if (match.index > lastIndex) {
			textBuffer += text.substring(lastIndex, match.index);
		}

		// Flush current text with old style before changing style
		flushText();

		const codes = match[1] ? match[1].split(';').map(Number) : [0];

		for (const code of codes) {
			if (code === 0) {
				// Reset all styles
				currentStyle = {};
			} else if (code === 1) {
				// Bold
				currentStyle.bold = true;
			} else if (code === 3) {
				// Italic
				currentStyle.italic = true;
			} else if (code === 4) {
				// Underline
				currentStyle.underline = true;
			} else if (code >= 30 && code <= 37) {
				// Standard foreground color
				currentStyle.color = ansiColorMap[code];
			} else if (code >= 90 && code <= 97) {
				// Bright foreground color
				currentStyle.color = ansiColorMap[code];
			} else if (code >= 40 && code <= 47) {
				// Background color
				currentStyle.bgColor = ansiColorMap[code - 10];
			}
		}

		lastIndex = match.index + match[0].length;
	}

	// Add any remaining text
	if (lastIndex < text.length) {
		textBuffer += text.substring(lastIndex);
	}
	flushText();

	// Convert newlines and carriage returns to <br/>
	html = html.replace(/\r\n/g, '<br/>').replace(/\r/g, '<br/>').replace(/\n/g, '<br/>');

	return html;
};

// Decide at call time whether to use JS API or RPC. Keep constructor synchronous.
let js_api_available = false;

// Store the JS API availability state
const apiReady = jsapi.js_api_available().then(([ok, host]) => {
	js_api_available = ok;
	return [ok, host];
}).catch(() => {
	js_api_available = false;
	return [false, null];	
});

const preferApi = (apiMethod, rpcMethod) => (...args) => {
	return apiReady.then(([ok, host]) => ok ? apiMethod(...args) : rpcMethod(...args));
};

return L.Class.extend({
	Types: Types,
	ActionTypes: ActionTypes,
	ansiToHtml: ansiToHtml,
	callMountPoints: callMountPoints,
	callRcInit: callRcInit,
	dv: dv,
	js_api_ready: apiReady,
	container_attach_ws: preferApi(jsapi.container_attach_ws, () => Promise.reject(new Error('Docker JS API not available'))),
	container_changes: preferApi(jsapi.container_changes, container_changes),
	container_create: preferApi(jsapi.container_create, container_create),
	// container_export: container_export, // use controller instead
	container_info_archive: preferApi(jsapi.container_info_archive, container_info_archive),
	container_inspect: preferApi(jsapi.container_inspect, container_inspect),
	container_kill: preferApi(jsapi.container_kill, container_kill),
	container_list: preferApi(jsapi.container_list, container_list),
	container_logs: preferApi(jsapi.container_logs, container_logs),
	container_pause: preferApi(jsapi.container_pause, container_pause),
	container_prune: preferApi(jsapi.container_prune, container_prune),
	container_remove: preferApi(jsapi.container_remove, container_remove),
	container_rename: preferApi(jsapi.container_rename, container_rename),
	container_restart: preferApi(jsapi.container_restart, container_restart),
	container_start: preferApi(jsapi.container_start, container_start),
	container_stats: preferApi(jsapi.container_stats, container_stats),
	container_stop: preferApi(jsapi.container_stop, container_stop),
	container_top: preferApi(jsapi.container_top, container_top),
	container_ttyd_start: container_ttyd_start,
	container_unpause: preferApi(jsapi.container_unpause, container_unpause),
	container_update: preferApi(jsapi.container_update, container_update),
	docker_df: preferApi(jsapi.docker_df, docker_df),
	docker_events: preferApi(jsapi.docker_events, docker_events),
	docker_info: preferApi(jsapi.docker_info, docker_info),
	docker_version: preferApi(jsapi.docker_version, docker_version),
	image_build: preferApi(jsapi.image_build, () => Promise.reject(new Error('Docker JS API not available'))),
	image_create: preferApi(jsapi.image_create, image_create),
	// image_get: image_get, // use controller instead
	image_history: preferApi(jsapi.image_history, image_history),
	image_inspect: preferApi(jsapi.image_inspect, image_inspect),
	image_list: preferApi(jsapi.image_list, image_list),
	image_prune: preferApi(jsapi.image_prune, image_prune),
	image_push: preferApi(jsapi.image_push, image_push),
	image_remove: preferApi(jsapi.image_remove, image_remove),
	image_tag: preferApi(jsapi.image_tag, image_tag),
	network_connect: preferApi(jsapi.network_connect, network_connect),
	network_create: preferApi(jsapi.network_create, network_create),
	network_disconnect: preferApi(jsapi.network_disconnect, network_disconnect),
	network_inspect: preferApi(jsapi.network_inspect, network_inspect),
	network_list: preferApi(jsapi.network_list, network_list),
	network_prune: preferApi(jsapi.network_prune, network_prune),
	network_remove: preferApi(jsapi.network_remove, network_remove),
	volume_create: preferApi(jsapi.volume_create, volume_create),
	volume_inspect: preferApi(jsapi.volume_inspect, volume_inspect),
	volume_list: preferApi(jsapi.volume_list, volume_list),
	volume_prune: preferApi(jsapi.volume_prune, volume_prune),
	volume_remove: preferApi(jsapi.volume_remove, volume_remove),
});
