import { Buffer } from "node:buffer";
import * as fs from "node:fs";
import { Readable, ReadableOptions } from "node:stream";

interface ByteRange {
	start: bigint;
	end: bigint;
	size: bigint;
}

const BOUNDARY_CHARS =
	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const BYTERANGE_SPEC_REGEX = /^bytes=(.+)$/;
const BYTERANGE_REGEX = /(\d*)-(\d*)/;

const BIGINT_0 = BigInt(0);
const BIGINT_1 = BigInt(1);
const BOUNDARY_SIZE = 40;

function extractRanges(
	fileSize: bigint,
	maxByteRanges: number,
	rangeHeaderValue: string,
): ByteRange[] {
	const ranges: ByteRange[] = [];

	if (!rangeHeaderValue) return ranges;

	const rangeSpecMatch = rangeHeaderValue.match(BYTERANGE_SPEC_REGEX);
	if (!rangeSpecMatch) return [];

	const rangeSpecs = rangeSpecMatch[1].split(",");
	for (let i = 0; i < rangeSpecs.length; i = i + 1) {
		const byteRange = rangeSpecs[i].match(BYTERANGE_REGEX);
		if (!byteRange) return [];

		let start: bigint;
		let end: bigint;
		let size: bigint;

		if (byteRange[1]) {
			start = BigInt(byteRange[1]);
		}

		if (byteRange[2]) {
			end = BigInt(byteRange[2]);
		}

		if (start === undefined && end === undefined) {
			/* some invalid range like bytes=- */
			return [];
		}

		if (start === undefined) {
			/* end-of-file range like -500 */
			start = fileSize - end;
			end = fileSize - BIGINT_1;
			if (start < BIGINT_0) return []; /* range larger than file, return */
		}

		if (end === undefined) {
			/* range like 0- */
			end = fileSize - BIGINT_1;
		}

		if (start > end || end >= fileSize) {
			/* return empty range to issue regular 200 */
			return [];
		}
		size = end - start + BIGINT_1;

		if (1 > maxByteRanges - ranges.length) return [];

		ranges.push({
			start: start,
			end: end,
			size: size,
		});
	}

	return ranges;
}

function createBoundary(len: number): string {
	const chars = [];
	for (let i = 0; i < len; i = i + 1) {
		chars[i] = BOUNDARY_CHARS.charAt(
			Math.floor(Math.random() * BOUNDARY_CHARS.length),
		);
	}
	return chars.join("");
}

class ByteRangeReadable extends Readable {
	size: bigint; /* the total size in bytes */
	boundary: string; /* boundary marker to use in multipart headers */

	private fd: number;
	private ranges: ByteRange[];
	private index: number; /* index within ranges */
	private position: bigint;
	private end: bigint;
	private contentType: string;
	private fileSize: bigint;
	private headers: Buffer[];
	private trailer: Buffer;

	static parseByteRanges(
		fileSize: bigint,
		maxByteRanges: number,
		rangeHeaderValue?: string,
	): ByteRange[] {
		return extractRanges(fileSize, maxByteRanges, rangeHeaderValue);
	}

	private createPartHeader(range: ByteRange): Buffer {
		return Buffer.from(
			[
				"",
				`--${this.boundary}`,
				`Content-Type: ${this.contentType}`,
				`Content-Range: bytes ${range.start}-${range.end}/${this.fileSize}`,
				"",
				"",
			].join("\r\n"),
		);
	}

	constructor(
		fd: number,
		fileSize: bigint,
		ranges: ByteRange[],
		contentType: string,
		opts?: ReadableOptions,
	) {
		super(opts);

		if (ranges.length === 0) {
			throw Error("this requires at least 1 byte range");
		}

		this.fd = fd;
		this.ranges = ranges;
		this.fileSize = fileSize;
		this.contentType = contentType;

		this.position = BIGINT_1;
		this.end = BIGINT_0;
		this.index = -1;
		this.headers = [];

		this.size = BIGINT_0;

		if (this.ranges.length === 1) {
			this.size = this.ranges[0].size;
		} else {
			this.boundary = createBoundary(BOUNDARY_SIZE);
			this.ranges.forEach((r) => {
				const header = this.createPartHeader(r);
				this.headers.push(header);

				this.size += BigInt(header.length) + r.size;
			});
			this.trailer = Buffer.from(`\r\n--${this.boundary}--\r\n`);
			this.size += BigInt(this.trailer.length);
		}
	}

	_read(n) {
		if (this.index == this.ranges.length) {
			this.push(null);
			return;
		}

		if (this.position > this.end) {
			/* move ahead to the next index */
			this.index++;

			if (this.index === this.ranges.length) {
				if (this.trailer) {
					this.push(this.trailer);
					return;
				}
				this.push(null);
				return;
			}

			this.position = this.ranges[this.index].start;
			this.end = this.ranges[this.index].end;

			if (this.ranges.length > 1) {
				this.push(this.headers[this.index]);
				return;
			}
		}

		const max = this.end - this.position + BIGINT_1;

		if (n > max) n = Number(max);
		const buf = Buffer.alloc(n);

		fs.read(this.fd, buf, 0, n, this.position, (err, bytesRead) => {
			if (err) {
				this.destroy(err);
				return;
			}
			if (bytesRead == 0) {
				/* something seems to have gone wrong? */
				this.push(null);
				return;
			}

			if (bytesRead > n) bytesRead = n;

			this.position += BigInt(bytesRead);
			this.push(buf.slice(0, bytesRead));
		});
	}
}

export { ByteRange, ByteRangeReadable };
