'use strict';
/**
 * BrightSign kiosk shell — device agent.
 *
 * Runs in the Node-enabled widget's V8 (loaded by boot.html). Mirrors the desktop main.js orchestrator,
 * but the bridge is delivered by INJECTING `window.__nativeProvisioning` into the player <iframe> (which is
 * loaded from the stable local http origin so the service worker + IndexedDB work) instead of an Electron
 * preload. Single shared V8 ⇒ the injected functions call straight into this agent (no message port).
 *
 * Boot sequence (resolves the audit's boot-origin chicken-and-egg, R8-BS-02):
 *   autorun.brs → Node widget loads file://…/boot.html  → startAgent()
 *     1. seed/rollback OTA slot, start the local http server on a FIXED port (Node http; there is NO
 *        @brightsign/httpserver) serving the active slot;
 *     2. create an <iframe src="http://localhost:PORT/"> — the player runs at the STABLE origin;
 *     3. inject the bridge + auth globals into the iframe window;
 *     4. connect the WS command channel, start telemetry/OTA/watchdog loops.
 */
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const crypto = require('crypto');
let WebSocket = null;
try {
	WebSocket = require('ws');
} catch (e) {
	/* ws is vendored into node_modules on the SD image; guard for dev */
}
const device = require('./device');
const telemetry = require('./telemetry');
const diagnostics = require('./diagnostics');
const { createOta } = require('./ota');
const { createIdentity } = require('./identity');
const { createSyncCoordinator } = require('./syncCoordinator');

const DEFAULTS = {
	centralPairingUrl: 'https://plane.videomood.io/api',
	telemetryIntervalMs: 5 * 60 * 1000,
	deepTelemetry: false,
	orientation: 'landscape',
	// Default-DENY for destructive power (reboot/display on-off) — matches desktop/Tizen/webOS: panel control
	// acts immediately on a live signage screen, so the plane operator opts in PER DEVICE. The capability is
	// always available on BrightSign (charter §1), but the SAFETY gate is the product-wide convention.
	allowSystemCommands: false,
};

const SEED_VERSION = '0.0.0';
const FIXED_PORT = 38291; // stable origin so IndexedDB/SW survive reboots (charter §4)
const HEALTH_TIMEOUT_MS = 60000;
const OTA_POLL_MS = 30 * 60 * 1000;
const CONTENT_BEAT_TIMEOUT_MS = 120000;
const LOG_CAP = 500;
const FATAL_FILE = 'last-fatal.log';
const FATAL_DEBOUNCE_MS = 10000; // don't storm the SD on a tight error loop

function startAgent(opts) {
	const win = (opts && opts.window) || (typeof window !== 'undefined' ? window : null);
	const doc = (opts && opts.document) || (typeof document !== 'undefined' ? document : null);
	const dataDir = (opts && opts.dataDir) || '/storage/sd/player-data';
	const bundleSeedDir = (opts && opts.bundleDir) || '/storage/sd/player'; // the shipped dist/ bundle on SD

	const CONFIG = Object.assign({}, DEFAULTS, loadConfig(opts && opts.configPath));

	// Sentry crash-reporting (optional, config-driven). Baked DSN via config.json sentryDsn; default '' =
	// disabled. Mirrors the desktop shell (platforms/desktop/main.js). @sentry/node is vendored into the SD
	// image only when a DSN is configured; absent module → silently disabled.
	let Sentry = null;
	if (CONFIG.sentryDsn) {
		try {
			Sentry = require('@sentry/node');
			Sentry.init({ dsn: CONFIG.sentryDsn, release: 'smil-player-brightsign', environment: 'production' });
			console.log('[shell] Sentry enabled');
		} catch (e) {
			Sentry = null;
			console.log('[shell] Sentry unavailable:', e.message);
		}
	}
	function sentryCapture(err) {
		if (!Sentry) return;
		try {
			Sentry.captureException(err instanceof Error ? err : new Error(String(err)));
		} catch (e) {
			/* never throw from the reporter */
		}
	}

	const identity = createIdentity(dataDir);
	const ota = createOta(path.join(dataDir, 'ota'), CONFIG.otaPublicKey);
	// Decentralized multi-screen sync: BrightSign is Node-capable, so (like the desktop shell) it can host the
	// embedded WS broker on the LAN and take part in master election/failover (documentation/sync-multi-schermo.md).
	const syncCoordinator = createSyncCoordinator();

	let staticPort = 0;
	let iframe = null;
	let ws = null;
	let wsConnected = false;
	let reconnectTimer = null;
	let reconnectAttempts = 0;
	let wsPingTimer = null;
	let telemetryTimer = null;
	let otaPollTimer = null;
	let contentWatchdog = null;
	let lastContentBeat = 0;
	let lastLifecycle = 'booting';
	let healthGate = null;
	const logBuffer = [];

	/* ───────────── logging (ring buffer + roSystemLog when on-device) ───────────── */
	function pushLog(level, args) {
		try {
			const line = `${new Date().toISOString()} [${level}] ` + args.map((a) => (typeof a === 'string' ? a : safeJson(a))).join(' ');
			logBuffer.push(line);
			if (logBuffer.length > LOG_CAP) logBuffer.shift();
			// console.log does NOT reach the BrightSign system log; emit via roSystemLog when available.
			systemLog(line);
		} catch (e) {
			/* never throw from logging */
		}
	}
	['log', 'warn', 'error'].forEach((lvl) => {
		const orig = console[lvl] ? console[lvl].bind(console) : function () {};
		console[lvl] = (...a) => {
			pushLog(lvl, a);
			orig(...a);
		};
	});
	const logTail = (n = 120) => logBuffer.slice(-n);
	function systemLog(line) {
		try {
			const SystemLog = require('@brightsign/legacy/rosystemlog');
			if (SystemLog) new SystemLog().SendLine ? new SystemLog().SendLine(line) : null;
		} catch (e) {
			/* off-device or unavailable */
		}
	}

	/* ───────────── config ───────────── */
	function loadConfig(p) {
		const candidates = [p, path.join(dataDir, 'config.json'), '/storage/sd/config.json', path.join(__dirname, '..', 'config.json')].filter(Boolean);
		for (const c of candidates) {
			try {
				const cfg = JSON.parse(fs.readFileSync(c, 'utf8'));
				console.log('[shell] config from', c);
				return cfg;
			} catch (e) {
				/* next */
			}
		}
		console.log('[shell] no config.json — using defaults');
		return {};
	}
	const centralUrl = () => CONFIG.centralPairingUrl || '';
	function backendWsUrl() {
		if (CONFIG.backendWsUrl) return CONFIG.backendWsUrl;
		try {
			const u = new URL(centralUrl());
			return `${u.protocol === 'https:' ? 'wss:' : 'ws:'}//${u.host}/ws`;
		} catch (e) {
			return '';
		}
	}
	const bundleVersion = () => {
		try {
			return ota.currentVersion() || SEED_VERSION;
		} catch (e) {
			return SEED_VERSION;
		}
	};
	// shell (native) version, stamped onto the SD image by build-image.sh → shell/version.json
	let _shellVer = null;
	function shellVersion() {
		if (_shellVer) return _shellVer;
		try {
			_shellVer = JSON.parse(fs.readFileSync(path.join(__dirname, 'version.json'), 'utf8'));
		} catch (e) {
			_shellVer = { version: CONFIG.shellVersion || null, versionCode: null };
		}
		return _shellVer;
	}
	// best-effort size of the on-disk app cache (OTA slots + web storage) for the dashboard footprint metric
	function appCacheBytes() {
		const dirSize = (dir) => {
			let total = 0;
			let stack = [dir];
			try {
				while (stack.length) {
					const d = stack.pop();
					for (const name of fs.readdirSync(d)) {
						const fp = path.join(d, name);
						let st;
						try {
							st = fs.statSync(fp);
						} catch (e) {
							continue;
						}
						if (st.isDirectory()) stack.push(fp);
						else total += st.size;
					}
				}
			} catch (e) {
				/* dir missing */
			}
			return total;
		};
		return dirSize(path.join(dataDir, 'ota')) + dirSize(path.join(dataDir, 'webstorage'));
	}

	/* ───────────── local static server (Node http — NO roHttpServer) ───────────── */
	const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml', '.wgt': 'application/octet-stream' };
	function startStaticServer() {
		const server = http.createServer((req, res) => {
			const root = ota.getServeDir() || bundleSeedDir;
			let p = (req.url || '/').split('?')[0];
			res.setHeader('Cache-Control', 'no-store, must-revalidate');
			if (p === '/config.json') {
				res.setHeader('Content-Type', 'application/json');
				const payload = { centralPairingUrl: centralUrl() };
				const PLAYER_KEYS = ['objectFit', 'videoObjectFit', 'imageObjectFit', 'backupImageUrl', 'debugEnabled', 'videoBackground'];
				for (const k of PLAYER_KEYS) if (CONFIG[k] !== undefined) payload[k] = CONFIG[k];
				return res.end(JSON.stringify(payload));
			}
			if (p === '/') p = '/index.html';
			let decoded;
			try {
				decoded = decodeURIComponent(p);
			} catch (e) {
				res.statusCode = 400;
				return res.end('Bad request');
			}
			const rootResolved = path.resolve(root);
			const fp = path.resolve(rootResolved, '.' + path.normalize(decoded).replace(/\\/g, '/'));
			if (fp !== rootResolved && !fp.startsWith(rootResolved + path.sep)) {
				res.statusCode = 403;
				return res.end('Forbidden');
			}
			fs.readFile(fp, (err, buf) => {
				if (err) {
					res.statusCode = 404;
					return res.end('Not found');
				}
				res.setHeader('Content-Type', MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream');
				res.end(buf);
			});
		});
		return new Promise((resolve) => {
			const onListening = () => {
				staticPort = server.address().port;
				resolve();
			};
			server.once('error', () => server.listen(0, '127.0.0.1', onListening));
			server.listen(FIXED_PORT, '127.0.0.1', onListening);
		});
	}

	/* ───────────── player iframe + bridge injection ───────────── */
	const playerOrigin = () => `http://127.0.0.1:${staticPort}`;
	function loadPlayer() {
		if (!doc) return;
		if (!iframe) {
			iframe = doc.createElement('iframe');
			iframe.setAttribute('style', 'position:fixed;inset:0;border:0;width:100vw;height:100vh;background:#000');
			iframe.setAttribute('allow', 'autoplay; fullscreen');
			iframe.addEventListener('load', () => {
				injectBridge();
				if (lastLifecycle === 'booting' || lastLifecycle === 'started') emitLifecycle('started');
			});
			doc.body.appendChild(iframe);
		}
		iframe.src = playerOrigin() + '/';
	}
	function blankScreen() {
		if (iframe && iframe.parentNode) {
			iframe.parentNode.removeChild(iframe);
			iframe = null;
		}
		if (doc && doc.body) doc.body.style.background = '#000';
	}
	/** Rebuild (NOT reload) the player — reloading a Node-context page is undefined on BrightSign; we tear the
	 *  iframe down and recreate it, which is the supported recovery for a wedged widget content (audit R8-BS-04). */
	function rebuildPlayer() {
		blankScreen();
		if (!isMaintenance()) loadPlayer();
	}

	function injectBridge() {
		try {
			const w = iframe && iframe.contentWindow;
			if (!w) return; // cross-origin read requires the global "disable web security" registry flag (README)
			w.__nativeProvisioning = {
				getIdentity: () => identity.getIdentity(),
				setIdentity: (json) => {
					const ok = identity.setIdentity(json);
					if (ok) {
						connectWs();
						setTimeout(checkForUpdate, 8000);
					}
					return ok;
				},
				clearIdentity: () => {
					identity.clearIdentity();
					disconnectWs();
				},
				getHardwareId: () => identity.getHardwareId(),
				reportHealthy: () => {
					commitHealth();
					if (!isMaintenance()) emitLifecycle('playing');
				},
				reportState: (state) => {
					if (typeof state === 'string' && /^[a-z-]{1,32}$/.test(state)) emitLifecycle(state);
				},
				heartbeat: () => {
					lastContentBeat = Date.now();
				},
				reportPlay: (json) => proofOfPlay(json),
				// decentralized sync: the player hands the plane-supplied roster to the coordinator (election),
				// then reads the elected master's ws url. getSyncMaster is synchronous (same V8) like Android's
				// @JavascriptInterface and the desktop sendSync bridge.
				configureSync: (json) => {
					try {
						const o = typeof json === 'string' ? JSON.parse(json) : json || {};
						syncCoordinator.configure(o.group || '', o.deviceId || '', Array.isArray(o.roster) ? o.roster : []);
					} catch (e) {
						/* ignore */
					}
				},
				getSyncMaster: () => syncCoordinator.masterUrl() || '',
			};
			// NOTE: window.getAuthHeaders / __onAuthFailure / __CENTRAL_PAIRING_URL are installed by the bundle's
			// OWN provisioning code (src/provisioning) and the central URL comes from /config.json (served above) —
			// the shell must NOT set them (matches the desktop/Tizen shells).
		} catch (e) {
			console.log('[shell] bridge injection failed (web security flag?):', e && e.message);
		}
	}

	/* ───────────── lifecycle + plane transport ───────────── */
	function wsSend(obj) {
		if (ws && wsConnected && ws.readyState === 1) {
			try {
				ws.send(JSON.stringify(obj));
				return true;
			} catch (e) {
				/* ignore */
			}
		}
		return false;
	}
	function emitLifecycle(state, detail) {
		lastLifecycle = state;
		const msg = { type: 'lifecycle', state, version: bundleVersion(), at: new Date().toISOString() };
		if (detail) msg.detail = detail;
		if (!wsSend(msg)) postJson('/device/lifecycle', { state, version: msg.version, detail: detail || null });
		console.log('[lifecycle]', state, detail ? safeJson(detail) : '');
	}
	function postJson(suffix, body) {
		const token = identity.deviceToken();
		const base = centralUrl();
		if (!token || !base) return;
		try {
			const u = new URL(base.replace(/\/+$/, '') + suffix);
			const mod = u.protocol === 'https:' ? https : http;
			const req = mod.request(u, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } });
			req.on('error', () => {});
			req.end(JSON.stringify(body));
		} catch (e) {
			/* ignore */
		}
	}
	function postRaw(suffix, contentType, buf) {
		const token = identity.deviceToken();
		const base = centralUrl();
		if (!token || !base) return;
		try {
			const u = new URL(base.replace(/\/+$/, '') + suffix);
			const mod = u.protocol === 'https:' ? https : http;
			const req = mod.request(u, { method: 'POST', headers: { 'Content-Type': contentType, Authorization: `Bearer ${token}` } });
			req.on('error', () => {});
			req.end(buf);
		} catch (e) {
			/* ignore */
		}
	}

	/* A-4: probe which web features the BrightSign Chromium actually exposes. Series 4 (Chromium ≤87) has no
	 * Service Worker → register() silently no-ops and offline store-and-play degrades; the operator needs to
	 * see this at bring-up. Cheap existence checks (no network), degrades to false off-device. */
	function probeBrowserCaps() {
		const nav = (win && win.navigator) || (typeof navigator !== 'undefined' ? navigator : null);
		const g = win || (typeof globalThis !== 'undefined' ? globalThis : {});
		const has = (v) => {
			try {
				return !!v;
			} catch (e) {
				return false;
			}
		};
		return {
			serviceWorker: has(nav && nav.serviceWorker),
			cacheStorage: has(g && g.caches),
			indexedDB: has(g && g.indexedDB),
			userAgent: (nav && nav.userAgent) || null,
		};
	}

	/* ───────────── telemetry ───────────── */
	async function sendTelemetry(deep, asSnapshot, id) {
		try {
			const sv = shellVersion();
			const extra = {
				appVersion: sv.version || null,
				appVersionCode: sv.versionCode || null,
				bundleVersion: bundleVersion(),
				appCacheBytes: appCacheBytes(),
				displayIndex: typeof readRuntime().displayIndex === 'number' ? readRuntime().displayIndex : 0,
				orientation: orientation(),
				deepTelemetry: deepTelemetryOn(),
				debugOverlay: !!readRuntime().debugOverlay,
				maintenance: isMaintenance(),
				browserCapabilities: probeBrowserCaps(),
			};
			const data = deep ? await telemetry.collectDeep(extra) : await telemetry.collectBasic(extra);
			const msg = { type: asSnapshot ? 'snapshot' : 'telemetry', data };
			if (id) msg.id = id;
			if (!wsSend(msg)) postJson('/device/telemetry', { snapshot: !!asSnapshot, data });
			return data;
		} catch (e) {
			console.log('[telemetry] failed:', e.message);
			return null;
		}
	}
	function startTelemetryLoop() {
		if (telemetryTimer) clearInterval(telemetryTimer);
		const interval = Math.max(60000, CONFIG.telemetryIntervalMs || 300000);
		telemetryTimer = setInterval(() => {
			if (identity.deviceToken()) void sendTelemetry(deepTelemetryOn(), false);
		}, interval);
	}

	/* ───────────── runtime overrides (orientation/deepTelemetry/maintenance) ───────────── */
	function readRuntime() {
		try {
			return JSON.parse(fs.readFileSync(path.join(dataDir, 'runtime.json'), 'utf8')) || {};
		} catch (e) {
			return {};
		}
	}
	function writeRuntime(patch) {
		const next = Object.assign(readRuntime(), patch);
		try {
			fs.mkdirSync(dataDir, { recursive: true });
			fs.writeFileSync(path.join(dataDir, 'runtime.json'), JSON.stringify(next));
		} catch (e) {
			/* ignore */
		}
		return next;
	}
	const deepTelemetryOn = () => {
		const r = readRuntime();
		return typeof r.deepTelemetry === 'boolean' ? r.deepTelemetry : !!CONFIG.deepTelemetry;
	};
	const orientation = () => readRuntime().orientation || CONFIG.orientation || 'landscape';
	const MAINT_FLAG = () => path.join(dataDir, 'maintenance.flag');
	const isMaintenance = () => {
		try {
			return fs.existsSync(MAINT_FLAG());
		} catch (e) {
			return false;
		}
	};

	/* ───────────── WebSocket command agent ───────────── */
	function connectWs() {
		const token = identity.deviceToken();
		const url = backendWsUrl();
		if (!token || !url || !WebSocket) return;
		disconnectWs();
		ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
		ws.isAlive = true;
		ws.on('pong', () => {
			ws.isAlive = true;
		});
		ws.on('open', () => {
			wsConnected = true;
			reconnectAttempts = 0;
			try {
				ws.send(JSON.stringify({ type: 'hello', version: bundleVersion() }));
			} catch (e) {
				/* ignore */
			}
			emitLifecycle(isMaintenance() ? 'maintenance-on' : lastLifecycle);
			void sendTelemetry(deepTelemetryOn(), false);
			if (wsPingTimer) clearInterval(wsPingTimer);
			wsPingTimer = setInterval(() => {
				if (!ws) return;
				if (ws.isAlive === false) {
					try {
						ws.terminate();
					} catch (e) {
						/* ignore */
					}
					return;
				}
				ws.isAlive = false;
				try {
					ws.ping();
				} catch (e) {
					/* ignore */
				}
			}, 25000);
		});
		ws.on('message', (data) => {
			let msg;
			try {
				msg = JSON.parse(data.toString());
			} catch (e) {
				return;
			}
			if (msg && msg.type === 'command') {
				Promise.resolve()
					.then(() => handleCommand(msg.command, msg.args || {}, msg.id))
					.catch((e) => console.log('[shell] command error:', e && e.message));
				try {
					ws.send(JSON.stringify({ type: 'ack', id: msg.id, command: msg.command, ok: true }));
				} catch (e) {
					/* ignore */
				}
			}
		});
		ws.on('close', () => {
			wsConnected = false;
			ws = null;
			if (wsPingTimer) {
				clearInterval(wsPingTimer);
				wsPingTimer = null;
			}
			scheduleReconnect();
		});
		ws.on('error', () => {
			try {
				ws.terminate();
			} catch (e) {
				/* ignore */
			}
		});
	}
	function scheduleReconnect() {
		if (reconnectTimer) return;
		reconnectAttempts = Math.min(reconnectAttempts + 1, 8);
		const base = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 120000);
		const delay = Math.floor(Math.random() * base);
		reconnectTimer = setTimeout(() => {
			reconnectTimer = null;
			if (identity.deviceToken()) connectWs();
		}, Math.max(1000, delay));
	}
	function disconnectWs() {
		if (ws) {
			try {
				ws.removeAllListeners();
				ws.close();
			} catch (e) {
				/* ignore */
			}
			ws = null;
		}
	}

	/* ───────────── OTA health gate ───────────── */
	function runScript() {
		// BrightSign OTA pre/postflight scripts are not run (no spawn in the widget). The bundle channel
		// rarely uses them; reject if present rather than silently ignore a required step.
		return Promise.resolve({ ok: true });
	}
	async function checkForUpdate() {
		const token = identity.deviceToken();
		if (!token || !centralUrl() || healthGate) return;
		try {
			const r = await ota.stage({ centralUrl: centralUrl(), deviceToken: token, runScript });
			if (r && r.updated) {
				emitLifecycle('updating', { version: r.version });
				if (!isMaintenance()) rebuildPlayer();
				armHealthGate(r.version, r.previousVersion);
			}
		} catch (e) {
			console.log('[ota] update failed:', e.message);
		}
	}
	/* ───────────── native-shell self-update (kind:'brightsign', SD A/B via autorun pointer) ───────────── */
	const shellSlotsDir = () => path.join(dataDir, 'shell-slots');
	const shellPointerPath = () => path.join(dataDir, 'shell-active.txt');
	// Map an fs path under the SD mount (/storage/sd/…) to the BrightScript file URL autorun.brs loads.
	function brightscriptUrlForSlot(dir) {
		const m = String(dir).replace(/\\/g, '/');
		let rel = null;
		if (m.indexOf('/storage/sd/') === 0) rel = m.slice('/storage/sd/'.length);
		else {
			const i = m.indexOf('/sd/');
			if (i >= 0) rel = m.slice(i + 4);
		}
		if (!rel) return null;
		return 'file:///sd:/' + rel.replace(/\/+$/, '') + '/boot.html';
	}
	async function checkShellUpdate() {
		const token = identity.deviceToken();
		if (!token || !centralUrl()) return;
		try {
			const sv = shellVersion();
			const r = await ota.stageShell({ centralUrl: centralUrl(), deviceToken: token, currentVersionCode: sv.versionCode || 0, slotsDir: shellSlotsDir() });
			if (!r || !r.updated) return;
			const url = brightscriptUrlForSlot(r.dir);
			if (!url) {
				console.warn('[shell] staged shell update but dataDir is not under SD — autorun pointer not written');
				return;
			}
			fs.writeFileSync(shellPointerPath(), url); // autorun.brs loads this slot next boot; reverts on failure
			emitLifecycle('updating', { version: r.version, shell: true });
			console.log('[shell] staged shell update', r.version, '→ rebooting to apply');
			setTimeout(() => device.reboot(), 1500);
		} catch (e) {
			console.log('[shell] shell-update failed:', e.message);
		}
	}

	function armHealthGate(version, prevVersion) {
		clearHealthGate();
		healthGate = { version, prevVersion, timer: setTimeout(commitHealth, HEALTH_TIMEOUT_MS) };
	}
	function clearHealthGate() {
		if (healthGate && healthGate.timer) clearTimeout(healthGate.timer);
		healthGate = null;
	}
	function commitHealth() {
		if (!healthGate) return;
		const v = healthGate.version;
		clearHealthGate();
		ota.commit();
		reportHealth(v, true, false);
		emitLifecycle('installed', { version: v });
	}
	function rollbackHealth(reason) {
		if (!healthGate) return;
		clearHealthGate();
		const to = ota.rollback();
		rebuildPlayer();
		reportHealth(to, false, true);
		emitLifecycle('rolled-back', { version: to, reason });
	}
	function reportHealth(version, ok, rolledBack) {
		postJson('/device/health', { version, ok, rolledBack });
	}

	/* ───────────── proof-of-play ───────────── */
	let playBuffer = [];
	let playFlush = null;
	function proofOfPlay(json) {
		try {
			const ev = typeof json === 'string' ? JSON.parse(json) : json;
			if (!ev || typeof ev !== 'object') return;
			// shape + cap to the plane's /device/play limits (type≤16, src≤400, region≤40)
			playBuffer.push({
				at: ev.at || new Date().toISOString(),
				type: String(ev.type || '').slice(0, 16),
				src: String(ev.src || '').slice(0, 400),
				region: String(ev.region || '').slice(0, 40),
			});
			if (playBuffer.length > 200) playBuffer.shift(); // ring cap (matches the other shells)
			if (!playFlush) playFlush = setInterval(flushPlay, 30000);
		} catch (e) {
			/* ignore */
		}
	}
	function flushPlay() {
		if (!playBuffer.length || !identity.deviceToken()) return;
		const events = playBuffer.splice(0, playBuffer.length);
		postJson('/device/play', { events });
	}

	/* ───────────── commands ───────────── */
	function systemAllowed(cmd) {
		if (CONFIG.allowSystemCommands) return true;
		console.log('[shell]', cmd, 'ignored (allowSystemCommands=false)');
		return false;
	}
	async function handleCommand(cmd, args, id) {
		args = args || {};
		switch (cmd) {
			case 'applet-refresh':
			case 'applet-reload':
			case 'app-restart':
				rebuildPlayer();
				break;
			case 'applet-disable':
				blankScreen();
				break;
			case 'applet-enable':
				if (!isMaintenance()) loadPlayer();
				break;
			case 'backup-reboot':
				// app restart (rebuild the widget), NOT an OS reboot — matches desktop app.relaunch semantics
				emitLifecycle('restarting');
				rebuildPlayer();
				break;
			case 'system-reboot':
				// destructive OS reboot — default-deny unless the device opts in (config allowSystemCommands)
				if (!systemAllowed('system-reboot')) break;
				emitLifecycle('restarting');
				device.reboot();
				break;
			case 'fs-wipeout':
				identity.clearIdentity();
				emitLifecycle('restarting');
				rebuildPlayer(); // clear + app restart (re-pairs on next boot); not an OS reboot
				break;
			case 'display-on':
				if (systemAllowed('display-on')) device.setDisplayPower(true);
				break;
			case 'display-off':
				if (systemAllowed('display-off')) device.setDisplayPower(false);
				break;
			case 'set-backlight':
				device.setBacklight(args.value);
				break;
			case 'set-volume':
				device.setVolume(args.value);
				break;
			case 'set-input':
				device.setDisplayInput(args.code);
				break;
			case 'set-orientation':
				writeRuntime({ orientation: String(args.value || 'landscape') });
				device.setOrientation(String(args.value || 'landscape'));
				break;
			case 'app-update':
				void checkForUpdate();
				break;
			case 'apk-update':
			case 'shell-update':
				// native-shell self-update: download+verify the kind:'brightsign' release, stage it into an SD
				// slot, point autorun at it, reboot. autorun reverts to the baked shell if the slot won't boot.
				void checkShellUpdate();
				break;
			case 'debug-overlay':
				// the in-bundle debug overlay reads this runtime flag (charter §5 — no native panel)
				writeRuntime({ debugOverlay: !!args.enabled });
				break;
			case 'snapshot':
				await sendTelemetry(false, true, id);
				break;
			case 'deep-snapshot':
				await sendTelemetry(true, true, id);
				break;
			case 'set-deep-telemetry':
				writeRuntime({ deepTelemetry: !!args.enabled });
				await sendTelemetry(!!args.enabled, true, id);
				break;
			case 'diagnostics':
				await runDiagnostics(id);
				break;
			case 'screenshot':
				await takeScreenshot();
				break;
			case 'logs-upload':
				postJson('/device/logs', { lines: logTail(LOG_CAP) });
				break;
			case 'maintenance-on':
				setMaintenance(true);
				break;
			case 'maintenance-off':
				setMaintenance(false);
				break;
			case 'set-display':
				// single-output device → no panel switch, but record + reflect it like the other shells
				writeRuntime({ displayIndex: parseInt(args.index, 10) || 0 });
				emitLifecycle('display-changed', { displayIndex: parseInt(args.index, 10) || 0 });
				break;
			case 'autostart-enable':
			case 'autostart-disable':
				// autostart is inherent (autorun.brs runs at boot) → ack as no-op
				break;
			default:
				console.log('[shell] unknown command:', cmd);
		}
	}

	function setMaintenance(on) {
		try {
			if (on) fs.writeFileSync(MAINT_FLAG(), new Date().toISOString());
			else fs.unlinkSync(MAINT_FLAG());
		} catch (e) {
			/* ignore */
		}
		if (on) blankScreen();
		else loadPlayer();
		emitLifecycle(on ? 'maintenance-on' : 'maintenance-off');
	}

	async function runDiagnostics(id) {
		let smilUrl = null;
		const o = identity.readObj();
		if (o && o.tenantBaseUrl && o.duid) smilUrl = `${String(o.tenantBaseUrl).replace(/\/+$/, '')}/playlists/devices/${o.duid}/smil`;
		const data = await diagnostics.run({ centralUrl: centralUrl(), smilUrl, wsConnected, logTail: () => logTail(40) });
		const msg = { type: 'diagnostics-result', data };
		if (id) msg.id = id;
		if (!wsSend(msg)) postJson('/device/diagnostics', { data });
	}

	/**
	 * Screenshot. @brightsign/screenshot writes JPEG/BMP (audit S-BS-02), but the plane wants PNG — so we
	 * capture to a temp JPEG, load it into an offscreen <canvas> in the widget, and export PNG before upload.
	 */
	async function takeScreenshot() {
		const MAX = 8 * 1024 * 1024;
		try {
			const jpg = device.captureScreenshot(path.join(dataDir, 'shot.jpg'));
			if (!jpg) {
				console.warn('[shell] screenshot unsupported (no @brightsign/screenshot module)');
				emitLifecycle('error', { reason: 'screenshot-unsupported' });
				return;
			}
			const png = doc && win ? await transcodeToPng(jpg) : null;
			if (!png || !png.length) {
				console.warn('[shell] screenshot transcode failed');
				emitLifecycle('error', { reason: 'screenshot-transcode-failed' });
				return;
			}
			if (png.length > MAX) {
				// A-5: don't silently drop — tell the plane the shot was rejected for size so the dashboard
				// shows a real outcome instead of an indefinite wait for a PNG that never arrives.
				console.warn('[shell] screenshot too large', png.length, '>', MAX);
				emitLifecycle('error', { reason: 'screenshot-too-large', bytes: png.length });
				return;
			}
			postRaw('/device/screenshot', 'image/png', png);
			console.log('[shell] screenshot uploaded', png.length, 'bytes');
		} catch (e) {
			console.log('[shell] screenshot failed:', e.message);
			emitLifecycle('error', { reason: 'screenshot-failed', detail: e.message });
		}
	}
	function transcodeToPng(jpgPath) {
		return new Promise((resolve) => {
			try {
				const bytes = fs.readFileSync(jpgPath);
				const b64 = bytes.toString('base64');
				const img = new win.Image();
				img.onload = () => {
					try {
						const canvas = doc.createElement('canvas');
						canvas.width = img.naturalWidth || 1920;
						canvas.height = img.naturalHeight || 1080;
						canvas.getContext('2d').drawImage(img, 0, 0);
						const dataUrl = canvas.toDataURL('image/png');
						resolve(Buffer.from(dataUrl.split(',')[1], 'base64'));
					} catch (e) {
						resolve(null);
					}
				};
				img.onerror = () => resolve(null);
				img.src = 'data:image/jpeg;base64,' + b64;
			} catch (e) {
				resolve(null);
			}
		});
	}

	/* ───────────── crash resilience (uncaught errors → fatal log → reported next boot) ─────────────
	 * The widget shares ONE V8 for Node + the page, so we catch BOTH layers: Node process events
	 * (uncaughtException/unhandledRejection) AND window events (error/unhandledrejection — these surface
	 * bundle crashes inside the iframe's parent context). Parity with the Tizen/webOS shells: a stray throw
	 * doesn't tear down the page (unlike Node), so we log + persist WITHOUT rebuilding (the content watchdog
	 * handles true wedges); the next boot surfaces the fatal to the plane via /device/logs {fatal:true}. */
	let lastFatalWrite = 0;
	function installErrorHandlers() {
		try {
			process.on('uncaughtException', (e) => persistFatal('uncaughtException', (e && (e.stack || e.message)) || String(e)));
			process.on('unhandledRejection', (r) => persistFatal('unhandledRejection', (r && (r.stack || r.message)) || String(r)));
		} catch (e) {
			/* ignore */
		}
		try {
			if (win && win.addEventListener) {
				win.addEventListener('error', (e) => persistFatal('error', (e && (e.message || (e.error && e.error.stack))) || 'unknown'));
				win.addEventListener('unhandledrejection', (e) => {
					const r = e && e.reason;
					persistFatal('unhandledrejection', (r && (r.stack || r.message)) || String(r));
				});
			}
		} catch (e) {
			/* ignore */
		}
	}
	function persistFatal(kind, detail) {
		const line = `${new Date().toISOString()} FATAL ${kind}: ${detail}`;
		console.error(line);
		sentryCapture(kind + ': ' + detail);
		const now = Date.now();
		if (now - lastFatalWrite < FATAL_DEBOUNCE_MS) return; // debounce a tight error loop
		lastFatalWrite = now;
		try {
			fs.mkdirSync(dataDir, { recursive: true });
			fs.writeFileSync(path.join(dataDir, FATAL_FILE), line + '\n' + logTail(80).join('\n'));
		} catch (e) {
			/* SD write may fail; the in-memory log still has it */
		}
		emitLifecycle('error', { reason: kind });
	}
	function reportPriorFatal() {
		try {
			const fp = path.join(dataDir, FATAL_FILE);
			if (!fs.existsSync(fp)) return;
			const body = fs.readFileSync(fp, 'utf8');
			postJson('/device/logs', { fatal: true, lines: String(body).slice(0, 64 * 1024).split('\n') });
			fs.unlinkSync(fp);
			console.log('[shell] reported prior-session fatal to plane');
		} catch (e) {
			/* ignore */
		}
	}

	/* A-7: a BrightSign with no NTP / unprovisioned timezone silently runs wallclock/dayparting on a wrong
	 * clock. We can't fix it from the shell, but we surface it: emit lifecycle:error + warn, and retry so the
	 * operator/plane sees it at bring-up. `device.getTimeZone()` reads roSystemTime when on-device. */
	let tzWarned = false;
	function checkTimezone() {
		let tz = null;
		try {
			tz = (device.getTimeZone && device.getTimeZone()) || null;
		} catch (e) {
			/* fall through to Intl */
		}
		if (!tz) {
			try {
				tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
			} catch (e) {
				/* ignore */
			}
		}
		const ok = !!tz && tz !== 'UTC' && tz !== 'Etc/Unknown' && tz !== 'Etc/UTC';
		if (!ok && !tzWarned) {
			tzWarned = true;
			console.warn('[shell] timezone not provisioned (got ' + (tz || 'none') + ') — wallclock/dayparting may drift');
			emitLifecycle('error', { reason: 'timezone-not-set', timezone: tz || null });
		} else if (ok) {
			tzWarned = false;
		}
		setTimeout(checkTimezone, 5 * 60 * 1000); // re-check; clears once provisioned
	}

	/* ───────────── content watchdog (heartbeat) ───────────── */
	function startContentWatchdog() {
		if (contentWatchdog) clearInterval(contentWatchdog);
		lastContentBeat = Date.now();
		contentWatchdog = setInterval(() => {
			if (isMaintenance() || healthGate) return;
			if (lastContentBeat && Date.now() - lastContentBeat > CONTENT_BEAT_TIMEOUT_MS) {
				console.log('[shell] no content heartbeat → rebuilding player');
				emitLifecycle('error', { reason: 'content-watchdog' });
				lastContentBeat = Date.now();
				rebuildPlayer();
			}
		}, 30000);
	}

	/* ───────────── boot ───────────── */
	async function boot() {
		installErrorHandlers();
		emitLifecycle('booting');
		try {
			ota.seed(bundleSeedDir, SEED_VERSION);
			if (ota.isPending()) {
				const to = ota.rollback();
				reportHealth(to, false, true);
			}
		} catch (e) {
			console.log('[ota] seed/rollback failed:', e.message);
		}
		reportPriorFatal(); // surface a previous-boot crash to the plane (after token is loaded by identity)
		checkTimezone(); // A-7: warn/emit if the device clock/timezone isn't provisioned (breaks wallclock)
		await startStaticServer();
		device.setOrientation(orientation());
		if (isMaintenance()) blankScreen();
		else loadPlayer();
		if (identity.deviceToken()) {
			connectWs();
			setTimeout(checkForUpdate, 8000);
		}
		startContentWatchdog();
		startTelemetryLoop();
		otaPollTimer = setInterval(checkForUpdate, OTA_POLL_MS);
		console.log('[shell] BrightSign agent up — origin', playerOrigin(), 'onDevice', device.onDevice);
	}

	boot().catch((e) => {
		console.log('[shell] boot failed:', e && e.message);
		try {
			emitLifecycle('error', { reason: 'boot-failed', detail: (e && e.message) || String(e) });
		} catch (_) {
			/* ignore */
		}
	});
	return { handleCommand, sendTelemetry, bundleVersion, _internal: { identity, ota } };
}

function safeJson(o) {
	try {
		return JSON.stringify(o);
	} catch (e) {
		return String(o);
	}
}

module.exports = { startAgent };
