/**
 * Streaming, pure-Bun tar + compression for installer payload archives.
 *
 * The tar layer is hand-rolled ustar, emitted entry-by-entry with file bodies
 * read in 8 MiB slices, so build memory stays bounded regardless of payload
 * size — and there is no system `tar` dependency. (Shelling out to tar was a
 * reliability problem: macOS bsdtar emits xattr pax headers that GNU tar
 * rejects, and GNU tar parses "C:\..." as a remote-host spec.) The output is
 * plain ustar — long paths via the name/prefix split, file modes preserved
 * (executables stay executable), symlinks as typeflag '2' — readable by every
 * tar implementation and by apps' streaming extractors.
 *
 * Compression tiers, chosen per archive (config `compression` field or
 * `--compression` flag):
 *   - "basic"  — Bun's built-in CompressionStream (zstd default ≈ level 3;
 *                gzip default ≈ level 6). Fast, streaming, no level control.
 *                The default tier.
 *   - <number> — explicit level via node:zlib streaming transforms (zstd 1–22,
 *                gzip 1–9). zstd 22 uses a large window; its ratio depends on
 *                it. Slower to build, smallest output.
 *   - "none"   — no compression; a plain `.tar`. For payloads that are already
 *                compressed (video, archives) where recompression wastes time.
 */

import { readdirSync, statSync, lstatSync, readlinkSync, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import zlib from "node:zlib";
import { join, basename } from "node:path";

export type ArchiveFormat = "zstd" | "gzip";
export type ArchiveCompression = "none" | "basic" | number;

const READ_CHUNK = 8 * 1024 * 1024; // file read slice size (bounds memory)

/** File extension for an archive of the given format + compression. */
export function archiveExtension(format: ArchiveFormat, compression: ArchiveCompression): string {
	if (compression === "none") return "tar";
	return format === "gzip" ? "tar.gz" : "tar.zst";
}

/**
 * The effective numeric level recorded in the manifest: 0 for "none", the
 * library default for "basic" (zstd 3 / gzip 6 — approximate; CompressionStream
 * exposes no level), or the explicit number.
 */
export function effectiveLevel(format: ArchiveFormat, compression: ArchiveCompression): number {
	if (compression === "none") return 0;
	if (compression === "basic") return format === "gzip" ? 6 : 3;
	return compression;
}

/** Human label for build logs, e.g. "zstd level 22", "zstd (basic)", "uncompressed". */
export function compressionLabel(format: ArchiveFormat, compression: ArchiveCompression): string {
	if (compression === "none") return "uncompressed";
	if (compression === "basic") return `${format} (basic)`;
	return `${format} level ${compression}`;
}

/** A POSIX ustar 512-byte header. Splits long paths across name/prefix. */
function ustarHeader(
	path: string,
	size: number,
	type: "0" | "2" | "5",
	mode: number,
	linkname = "",
): Uint8Array {
	const bl = (s: string) => Buffer.byteLength(s);
	let name = path;
	let prefix = "";
	if (bl(name) > 100) {
		// Split at a "/" so the suffix fits the 100-byte name field and the
		// prefix fits the 155-byte prefix field (extractors rejoin them).
		let split = -1;
		for (let i = 0; i < path.length; i++) {
			if (path[i] !== "/") continue;
			if (bl(path.slice(0, i)) <= 155 && bl(path.slice(i + 1)) <= 100) {
				split = i;
				break;
			}
		}
		if (split < 0) throw new Error(`Path too long for ustar: ${path}`);
		prefix = path.slice(0, split);
		name = path.slice(split + 1);
	}
	if (bl(linkname) > 100) throw new Error(`Symlink target too long for ustar: ${linkname}`);
	const h = Buffer.alloc(512);
	h.write(name, 0);
	const putOct = (off: number, len: number, v: number) =>
		h.write(v.toString(8).padStart(len - 1, "0") + "\0", off);
	putOct(100, 8, mode & 0o7777);
	putOct(108, 8, 0); // uid
	putOct(116, 8, 0); // gid
	putOct(124, 12, size);
	putOct(136, 12, 0); // mtime
	h.write(type, 156);
	h.write(linkname, 157);
	h.write("ustar\0", 257);
	h.write("00", 263);
	h.write(prefix, 345);
	h.fill(0x20, 148, 156); // checksum field = spaces while summing
	let sum = 0;
	for (let i = 0; i < 512; i++) sum += h[i]!;
	h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
	return new Uint8Array(h.buffer, h.byteOffset, h.length);
}

type WalkEntry = {
	abs: string;
	rel: string;
	kind: "dir" | "file" | "symlink";
	size: number;
	mode: number;
};

/** Depth-first walk; directories before their contents; "/" separators. */
function* walk(absDir: string, rel: string): Generator<WalkEntry> {
	const entries = readdirSync(absDir, { withFileTypes: true }).sort((x, y) =>
		x.name.localeCompare(y.name),
	);
	for (const e of entries) {
		if (e.name === ".DS_Store") continue;
		const abs = join(absDir, e.name);
		const r = `${rel}/${e.name}`;
		if (e.isSymbolicLink()) {
			yield { abs, rel: r, kind: "symlink", size: 0, mode: 0o777 };
		} else if (e.isDirectory()) {
			yield { abs, rel: r, kind: "dir", size: 0, mode: lstatSync(abs).mode };
			yield* walk(abs, r);
		} else if (e.isFile()) {
			const st = statSync(abs);
			yield { abs, rel: r, kind: "file", size: st.size, mode: st.mode };
		}
		// Sockets/FIFOs/devices are skipped.
	}
}

/**
 * The tar bytes for the tree rooted at `srcDir`, as a lazy async sequence.
 * The directory's own name becomes the archive root.
 */
export async function* tarBytes(srcDir: string): AsyncGenerator<Uint8Array> {
	const rootName = basename(srcDir);
	yield ustarHeader(`${rootName}/`, 0, "5", statSync(srcDir).mode);
	for (const e of walk(srcDir, rootName)) {
		if (e.kind === "dir") {
			yield ustarHeader(`${e.rel}/`, 0, "5", e.mode);
			continue;
		}
		if (e.kind === "symlink") {
			yield ustarHeader(e.rel, 0, "2", e.mode, readlinkSync(e.abs));
			continue;
		}
		yield ustarHeader(e.rel, e.size, "0", e.mode);
		const f = Bun.file(e.abs);
		for (let off = 0; off < e.size; off += READ_CHUNK) {
			yield new Uint8Array(await f.slice(off, Math.min(off + READ_CHUNK, e.size)).arrayBuffer());
		}
		const pad = (512 - (e.size % 512)) % 512;
		if (pad) yield new Uint8Array(pad);
	}
	yield new Uint8Array(1024); // end-of-archive: two zero blocks
}

/**
 * Scan a prebuilt tar archive (optionally zstd/gzip compressed) and total its
 * file payload — the uncompressedBytes/fileCount progress denominators the
 * manifest needs. Streaming: headers are parsed and bodies skipped, so memory
 * stays bounded.
 */
export async function scanTarArchive(
	path: string,
	format: ArchiveFormat | "none",
): Promise<{ uncompressedBytes: number; fileCount: number }> {
	let stream = Bun.file(path).stream() as unknown as ReadableStream<Uint8Array>;
	if (format !== "none") {
		const ds = new DecompressionStream(
			(format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
		) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
		stream = stream.pipeThrough(ds);
	}
	const reader = stream.getReader();
	const td = new TextDecoder();
	let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
	let uncompressedBytes = 0;
	let fileCount = 0;
	let skip = 0; // bytes of body+padding left to discard
	while (true) {
		const { done, value } = await reader.read();
		if (done) break;
		if (buf.length === 0) buf = value;
		else {
			const merged = new Uint8Array(buf.length + value.length);
			merged.set(buf, 0);
			merged.set(value, buf.length);
			buf = merged;
		}
		while (true) {
			if (skip > 0) {
				const n = Math.min(skip, buf.length);
				buf = buf.subarray(n);
				skip -= n;
				if (skip > 0) break;
			}
			if (buf.length < 512) break;
			const h = buf.subarray(0, 512);
			buf = buf.subarray(512);
			let allZero = true;
			for (let i = 0; i < 512; i++) {
				if (h[i] !== 0) { allZero = false; break; }
			}
			if (allZero) continue; // end-of-archive marker block
			const size =
				parseInt(td.decode(h.subarray(124, 136)).replace(/\0.*$/, "").trim() || "0", 8) || 0;
			const typeflag = String.fromCharCode(h[156] || 0);
			// Regular files count toward the payload; metadata entries (long-name
			// 'L'/'K', pax 'x'/'g') and dirs/links don't, but their bodies are
			// still skipped.
			if (typeflag === "0" || typeflag === "\0") {
				uncompressedBytes += size;
				fileCount++;
			}
			skip = size + ((512 - (size % 512)) % 512);
		}
	}
	return { uncompressedBytes, fileCount };
}

/** Wrap an async generator as a web ReadableStream (for CompressionStream). */
function toReadable(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
	return new ReadableStream<Uint8Array>({
		async pull(c) {
			const { done, value } = await gen.next();
			if (done) c.close();
			else c.enqueue(value);
		},
	});
}

/**
 * Tar `srcDir` and write it to `destPath`, compressed per the tier. Streaming
 * end to end: nothing close to the payload size is ever in memory.
 */
export async function writeTarArchive(
	srcDir: string,
	destPath: string,
	format: ArchiveFormat,
	compression: ArchiveCompression,
): Promise<void> {
	const tar = tarBytes(srcDir);

	if (compression === "none") {
		await pipeline(tar, createWriteStream(destPath));
		return;
	}

	if (compression === "basic") {
		// Bun's built-in CompressionStream. "zstd" is valid in Bun but missing
		// from the lib.dom CompressionFormat union (and the stream generics
		// predate Uint8Array<ArrayBuffer>), hence the casts.
		const cs = new CompressionStream(
			(format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
		) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
		const compressed = toReadable(tar).pipeThrough(cs);
		const sink = Bun.file(destPath).writer();
		const reader = compressed.getReader();
		try {
			while (true) {
				const { done, value } = await reader.read();
				if (done) break;
				sink.write(value);
			}
		} finally {
			await sink.end();
		}
		return;
	}

	// Explicit level via node:zlib streaming transforms. The zstd APIs exist in
	// Bun's node:zlib but not yet in the bundled @types, hence the cast.
	const z = zlib as unknown as {
		createGzip(opts: { level: number }): NodeJS.ReadWriteStream;
		createZstdCompress(opts: { params: Record<number, number> }): NodeJS.ReadWriteStream;
		constants: { ZSTD_c_compressionLevel: number };
	};
	const transform =
		format === "gzip"
			? z.createGzip({ level: compression })
			: z.createZstdCompress({
					params: { [z.constants.ZSTD_c_compressionLevel]: compression },
				});
	await pipeline(tar, transform, createWriteStream(destPath));
}
