'use strict';
/**
 * Bundle OTA with rollback — the A/B-slot + health-gate pattern (same approach as Android A/B,
 * Mender, RAUC, Capacitor live-updates): keep versioned slots, switch the active pointer atomically,
 * keep the previous slot, and roll back if the new version doesn't confirm healthy.
 *
 * The health gate itself (crash detection + timeout + explicit confirm) lives in main.js because it
 * needs the window lifecycle; this module owns the slots, download/verify/extract, scripts and state.
 */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const extract = require('extract-zip');

// Baked OTA public key (ECDSA P-256, SPKI DER base64). The control plane signs "version:sha256" with the
// matching private key; we verify here so a compromised plane can't push unsigned/forged bytes. Overridable
// via createOta(baseDir, publicKeyDerB64) from config.
const DEFAULT_OTA_PUBLIC_KEY_B64 =
	'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7cUi5712YiH84MwTaGPDPYUY5R06Lead4fZ5D7XwVoc8et4fiEYrTLk6IstWqUpEFIVeKyjsFNFznt/F4ujMxg==';

const sha256hex = (s) => crypto.createHash('sha256').update(String(s == null ? '' : s)).digest('hex');

function createOta(baseDir, publicKeyB64) {
	const otaPublicKeyB64 = publicKeyB64 || DEFAULT_OTA_PUBLIC_KEY_B64;
	// Fail-CLOSED: a build with no baked key refuses ALL updates (an unverifiable update is the threat we
	// guard against). Verify the full canonical manifest so kind/version/versionCode/scripts are all bound.
	function verifySignature(info) {
		if (!otaPublicKeyB64) {
			console.error('[ota] no OTA public key baked — refusing all updates');
			return false;
		}
		if (!info || !info.signature) return false; // unsigned → rejected
		try {
			const message = [
				'ota1',
				info.kind || 'bundle',
				info.version,
				info.versionCode || 0,
				String(info.sha256 || '').toLowerCase(),
				sha256hex(info.preflight || ''),
				sha256hex(info.postflight || ''),
			].join('|');
			const key = crypto.createPublicKey({ key: Buffer.from(otaPublicKeyB64, 'base64'), format: 'der', type: 'spki' });
			return crypto.verify('sha256', Buffer.from(message), key, Buffer.from(info.signature, 'base64'));
		} catch (e) {
			return false;
		}
	}
	const SLOTS = path.join(baseDir, 'slots');
	const STATE = path.join(baseDir, 'state.json');
	const slotDir = (v) => path.join(SLOTS, String(v).replace(/[^a-zA-Z0-9._-]/g, '_'));
	const READY_SENTINEL = '.ota-ready'; // written LAST; a slot without it is incomplete (crash mid-extract)
	const isSlotReady = (v) => {
		try {
			return !!v && fs.existsSync(path.join(slotDir(v), READY_SENTINEL));
		} catch (e) {
			return false;
		}
	};
	const markSlotReady = (v) => {
		try {
			fs.writeFileSync(path.join(slotDir(v), READY_SENTINEL), new Date().toISOString());
		} catch (e) {
			/* ignore */
		}
	};

	function readState() {
		try {
			return JSON.parse(fs.readFileSync(STATE, 'utf8'));
		} catch (e) {
			return { active: null, previous: null, pending: null };
		}
	}
	// Atomic + durable: write to tmp, fsync the data, rename over the real file. A power loss can never
	// leave a truncated state.json that would (on next boot) silently revert the device to the seed slot.
	function writeState(s) {
		fs.mkdirSync(baseDir, { recursive: true });
		const tmp = STATE + '.tmp';
		const fd = fs.openSync(tmp, 'w');
		try {
			fs.writeSync(fd, JSON.stringify(s, null, 2));
			fs.fsyncSync(fd);
		} finally {
			fs.closeSync(fd);
		}
		fs.renameSync(tmp, STATE);
	}

	/** Seed slot 0 from the bundled player on first run. */
	function seed(resourcesDir, version) {
		const s = readState();
		if (s.active && isSlotReady(s.active)) return s.active; // only trust a COMPLETE slot
		const dir = slotDir(version);
		fs.rmSync(dir, { recursive: true, force: true });
		fs.cpSync(resourcesDir, dir, { recursive: true });
		markSlotReady(version); // sentinel last, so a crash mid-copy isn't mistaken for a good slot
		writeState({ active: version, previous: null, pending: null });
		return version;
	}

	// Serve the active slot only if it's complete; otherwise null → main.js falls back to the bundled seed.
	const getServeDir = () => {
		const s = readState();
		return s.active && isSlotReady(s.active) ? slotDir(s.active) : null;
	};
	const currentVersion = () => readState().active;
	const isPending = () => !!readState().pending;

	// Monotonic version floor — the highest version ever installed. Blocks downgrade/replay even across a
	// rollback (a rolled-back device still won't re-accept the old signed bundle it was just exploited by).
	const FLOOR = path.join(baseDir, 'version-floor');
	const readFloor = () => {
		try {
			return fs.readFileSync(FLOOR, 'utf8').trim() || null;
		} catch (e) {
			return null;
		}
	};
	const writeFloor = (v) => {
		try {
			if (isNewerVersion(v, readFloor())) fs.writeFileSync(FLOOR, String(v));
		} catch (e) {
			/* ignore */
		}
	};
	const aboveFloor = (v) => isNewerVersion(v, readFloor()) || v === readFloor();

	/**
	 * Check for an update and, if any, download+verify+extract it into a new slot, run preflight
	 * (before) and postflight (after), then switch active → new (keeping previous) and mark pending.
	 * Returns { updated, version, previousVersion } or { upToDate, version }.
	 */
	async function stage(opts) {
		const cur = readState();
		const info = await getJson(`${opts.centralUrl}/device/update?version=${encodeURIComponent(cur.active || '')}`, opts.deviceToken);
		if (!info || !info.update) return { upToDate: true, version: cur.active };

		// A bundle with no advertised digest cannot be integrity-checked → refuse it outright.
		if (!info.sha256 || !/^[a-f0-9]{64}$/i.test(info.sha256)) {
			throw new Error('release has no valid sha256 — refusing unverifiable update');
		}
		// Free-space preflight: need room for the zip + the extracted tree (~zip again, often more).
		// Refuse rather than fill the disk mid-extract and corrupt the slot. Best-effort (skip if unknown).
		if (info.size) {
			const free = freeBytes(baseDir);
			if (free != null && free < Number(info.size) * 3) {
				throw new Error(`insufficient disk space for OTA (need ~${Math.round((info.size * 3) / 1048576)}MB, free ${Math.round(free / 1048576)}MB)`);
			}
		}

		fs.mkdirSync(baseDir, { recursive: true });
		const origin = new URL(opts.centralUrl).origin;
		const tmpZip = path.join(baseDir, 'download.zip.tmp');
		// Stream to disk while hashing — never buffer the whole (up to 500MB) bundle in RAM on a low-end box.
		const sha = await downloadToFile(origin + info.url, opts.deviceToken, tmpZip);
		if (sha !== info.sha256.toLowerCase()) {
			try {
				fs.unlinkSync(tmpZip);
			} catch (e) {
				/* ignore */
			}
			throw new Error('bundle checksum mismatch — refusing update');
		}
		// Cryptographic authenticity: verify the full signed manifest (kind/version/versionCode/sha/scripts).
		// info.kind must be 'bundle' here; reject an APK manifest served on the bundle channel.
		if ((info.kind || 'bundle') !== 'bundle' || sha !== String(info.sha256 || '').toLowerCase() || !verifySignature(info)) {
			try {
				fs.unlinkSync(tmpZip);
			} catch (e) {
				/* ignore */
			}
			throw new Error('bundle signature/identity invalid — refusing update');
		}
		// Anti-downgrade: never apply a bundle older-or-equal than what's installed (replay protection),
		// using a persisted monotonic version floor so even a rollback can't be re-exploited.
		if (!isNewerVersion(info.version, currentVersion()) || !aboveFloor(info.version)) {
			try {
				fs.unlinkSync(tmpZip);
			} catch (e) {
				/* ignore */
			}
			console.error('[ota] refusing downgrade/replay to', info.version, '(floor', readFloor() + ')');
			return { upToDate: true, version: cur.active };
		}

		if (info.preflight) {
			const r = await opts.runScript(info.preflight, { OTA_PHASE: 'preflight', OTA_VERSION: info.version, OTA_PREV_DIR: slotDir(cur.active || '') });
			if (r && r.ok === false) throw new Error('preflight script failed — aborting update');
		}

		const newDir = slotDir(info.version);
		fs.rmSync(newDir, { recursive: true, force: true }); // clear any incomplete prior attempt (no sentinel)
		fs.mkdirSync(newDir, { recursive: true });
		await extract(tmpZip, { dir: newDir }); // extract-zip guards against zip-slip
		try {
			fs.unlinkSync(tmpZip);
		} catch (e) {
			/* ignore */
		}

		if (info.postflight) {
			const r = await opts.runScript(info.postflight, { OTA_PHASE: 'postflight', OTA_VERSION: info.version, OTA_NEW_DIR: newDir });
			if (r && r.ok === false) throw new Error('postflight script failed — aborting update'); // bad slot never gets the sentinel
		}

		markSlotReady(info.version); // sentinel LAST — only now is the slot trustworthy to serve
		writeFloor(info.version); // raise the anti-downgrade floor
		writeState({ active: info.version, previous: cur.active, pending: { version: info.version, since: Date.now() } });
		return { updated: true, version: info.version, previousVersion: cur.active };
	}

	/** Mark the pending version as good. */
	function commit() {
		const s = readState();
		s.pending = null;
		writeState(s);
	}

	/** Revert to the previous slot. Returns the version we rolled back to (verified complete, else null). */
	function rollback() {
		const s = readState();
		// Only roll back to a slot that actually exists and is complete; a dangling pointer would
		// serve "Not found" forever. If the previous slot is gone, drop to no-active → seed fallback.
		if (s.previous && isSlotReady(s.previous)) {
			writeState({ active: s.previous, previous: null, pending: null });
			return s.previous;
		}
		writeState({ active: isSlotReady(s.active) ? s.active : null, previous: null, pending: null });
		return isSlotReady(s.active) ? s.active : null;
	}

	/**
	 * Native-shell self-update (kind:'brightsign'). Unlike the bundle (served by our local http server), the
	 * SHELL is the autorun + shell/*.js + node_modules loaded from file://. We can't replace the running shell
	 * in place, so we use an A/B pointer: download+verify the signed shell release, extract it into a versioned
	 * slot under `opts.slotsDir`, mark it ready, and return the slot dir. The caller writes the autorun pointer;
	 * autorun.brs loads the pointed slot on next boot and reverts to the baked `sd:/shell` if it fails to boot.
	 * Returns { updated, version, dir } or { upToDate }.
	 */
	async function stageShell(opts) {
		const info = await getJson(`${opts.centralUrl}/device/shell-update?versionCode=${opts.currentVersionCode || 0}`, opts.deviceToken);
		if (!info || !info.update) return { upToDate: true };
		if (info.kind !== 'brightsign') throw new Error('shell-update kind mismatch: ' + info.kind);
		if (!info.sha256 || !/^[a-f0-9]{64}$/i.test(info.sha256)) throw new Error('shell release has no valid sha256');
		fs.mkdirSync(opts.slotsDir, { recursive: true });
		if (info.size) {
			const free = freeBytes(opts.slotsDir);
			if (free != null && free < Number(info.size) * 3) throw new Error('insufficient disk space for shell update');
		}
		const origin = new URL(opts.centralUrl).origin;
		const tmpZip = path.join(opts.slotsDir, 'shell-download.zip.tmp');
		const sha = await downloadToFile(origin + info.url, opts.deviceToken, tmpZip);
		// Integrity + authenticity: checksum AND the full signed manifest (kind bound to 'brightsign').
		if (sha !== info.sha256.toLowerCase() || !verifySignature(info)) {
			try { fs.unlinkSync(tmpZip); } catch (e) { /* ignore */ }
			throw new Error('shell signature/checksum invalid — refusing update');
		}
		const dir = path.join(opts.slotsDir, String(info.version).replace(/[^a-zA-Z0-9._-]/g, '_'));
		fs.rmSync(dir, { recursive: true, force: true }); // clear any incomplete prior attempt
		fs.mkdirSync(dir, { recursive: true });
		await extract(tmpZip, { dir }); // zip-slip guarded
		try { fs.unlinkSync(tmpZip); } catch (e) { /* ignore */ }
		fs.writeFileSync(path.join(dir, READY_SENTINEL), new Date().toISOString()); // sentinel LAST
		return { updated: true, version: info.version, dir };
	}

	return { seed, getServeDir, currentVersion, isPending, isSlotReady, stage, stageShell, commit, rollback };
}

/** True if version `a` is strictly newer than `b` (numeric dot-segments, then lexical). null b → true. */
function isNewerVersion(a, b) {
	if (!b) return true;
	if (!a) return false;
	const pa = String(a).split('.');
	const pb = String(b).split('.');
	const n = Math.max(pa.length, pb.length);
	for (let i = 0; i < n; i++) {
		const na = parseInt(pa[i] || '0', 10);
		const nb = parseInt(pb[i] || '0', 10);
		if (!isNaN(na) && !isNaN(nb)) {
			if (na !== nb) return na > nb;
		} else {
			const sa = pa[i] || '';
			const sb = pb[i] || '';
			if (sa !== sb) return sa > sb;
		}
	}
	return false;
}

/** Best-effort free-space (bytes) on the filesystem holding `dir`; null if unknown. */
function freeBytes(dir) {
	try {
		const st = fs.statfsSync(dir);
		return st.bavail * st.bsize;
	} catch (e) {
		return null;
	}
}

/* ───────────────────────── http helpers ───────────────────────── */

const MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024; // 500MB ceiling on any OTA download

function isLocalHost(hostname) {
	return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
}

function requestBuffer(url, token) {
	return new Promise((resolve, reject) => {
		const u = new URL(url);
		// Enforce TLS for anything that isn't loopback — OTA payloads carry executable code.
		if (u.protocol !== 'https:' && !isLocalHost(u.hostname)) {
			reject(new Error(`refusing insecure OTA transport (${u.protocol}//) for ${u.hostname}`));
			return;
		}
		const mod = u.protocol === 'https:' ? https : http;
		const req = mod.get(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, (res) => {
			if (res.statusCode !== 200) {
				res.resume();
				reject(new Error(`HTTP ${res.statusCode} for ${url}`));
				return;
			}
			const chunks = [];
			let size = 0;
			res.on('data', (c) => {
				size += c.length;
				if (size > MAX_DOWNLOAD_BYTES) {
					req.destroy(new Error('OTA download exceeds size limit'));
					return;
				}
				chunks.push(c);
			});
			res.on('end', () => resolve(Buffer.concat(chunks)));
		});
		req.on('error', reject);
		req.setTimeout(60000, () => req.destroy(new Error('request timeout')));
	});
}

async function getJson(url, token) {
	const b = await requestBuffer(url, token);
	try {
		return JSON.parse(b.toString());
	} catch (e) {
		return null;
	}
}
const download = (url, token) => requestBuffer(url, token);

/** Stream a download straight to `destPath`, hashing as it goes. Resolves the sha256 hex. */
function downloadToFile(url, token, destPath) {
	return new Promise((resolve, reject) => {
		const u = new URL(url);
		if (u.protocol !== 'https:' && !isLocalHost(u.hostname)) {
			reject(new Error(`refusing insecure OTA transport (${u.protocol}//) for ${u.hostname}`));
			return;
		}
		const mod = u.protocol === 'https:' ? https : http;
		const out = fs.createWriteStream(destPath);
		const hash = crypto.createHash('sha256');
		let size = 0;
		let settled = false;
		const fail = (err) => {
			if (settled) return;
			settled = true;
			try {
				out.destroy();
			} catch (e) {
				/* ignore */
			}
			try {
				fs.unlinkSync(destPath);
			} catch (e) {
				/* ignore */
			}
			reject(err);
		};
		const req = mod.get(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, (res) => {
			if (res.statusCode !== 200) {
				res.resume();
				fail(new Error(`HTTP ${res.statusCode} for ${url}`));
				return;
			}
			res.on('data', (c) => {
				size += c.length;
				if (size > MAX_DOWNLOAD_BYTES) {
					req.destroy(new Error('OTA download exceeds size limit'));
					return;
				}
				hash.update(c);
			});
			res.pipe(out);
			out.on('finish', () => {
				if (settled) return;
				out.close(() => {
					settled = true;
					resolve(hash.digest('hex'));
				});
			});
		});
		req.on('error', fail);
		out.on('error', fail);
		req.setTimeout(60000, () => req.destroy(new Error('request timeout')));
	});
}

module.exports = { createOta };
