import toBrowserReadableStream from 'it-to-browser-readablestream';
import { InvalidRangeError } from '../errors.js';
import { calculateByteRangeIndexes, getHeader } from './request-headers.js';
import { getContentRangeHeader } from './response-headers.js';
/**
 * Gets the body size of a given body if it's possible to calculate it synchronously.
 */
function getBodySizeSync(body) {
    if (typeof body === 'string') {
        return body.length;
    }
    if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
        return body.byteLength;
    }
    if (body instanceof Blob) {
        return body.size;
    }
    if (body instanceof ReadableStream) {
        return null;
    }
    return null;
}
function getByteRangeFromHeader(rangeHeader) {
    /**
     * Range: bytes=<start>-<end> | bytes=<start2>- | bytes=-<end2> | bytes=<start1>-<end1>,<start2>-<end2>,...
     */
    if (!rangeHeader.startsWith('bytes=')) {
        throw new InvalidRangeError('Invalid range request');
    }
    const rangesStr = rangeHeader.substring(6); // Remove "bytes=" prefix
    const rangeParts = rangesStr.split(',').map(part => part.trim());
    const ranges = [];
    for (const part of rangeParts) {
        const match = part.match(/^(?<start>\d+)?-(?<end>\d+)?$/);
        if (match?.groups == null) {
            throw new InvalidRangeError(`Invalid range specification: ${part}`);
        }
        const { start, end } = match.groups;
        ranges.push({
            start: start ?? null,
            end: end ?? null
        });
    }
    if (ranges.length === 0) {
        throw new InvalidRangeError('No valid ranges found');
    }
    return { ranges };
}
export class ByteRangeContext {
    headers;
    isRangeRequest;
    /**
     * This property is purposefully only set in `set fileSize` and should not be set directly.
     */
    _fileSize;
    _body = null;
    rangeRequestHeader;
    log;
    /**
     * multiPartBoundary is required for multipart responses
     */
    multiPartBoundary;
    requestRanges = [];
    byteRanges = [];
    isMultiRangeRequest = false;
    // to be set by isValidRangeRequest so that we don't need to re-check the byteRanges
    _isValidRangeRequest = false;
    constructor(logger, headers) {
        this.headers = headers;
        this.log = logger.newScope('byte-range-context');
        this.rangeRequestHeader = getHeader(this.headers, 'Range');
        if (this.rangeRequestHeader != null) {
            this.isRangeRequest = true;
            this.log.trace('range request detected');
            try {
                const { ranges } = getByteRangeFromHeader(this.rangeRequestHeader);
                this.isMultiRangeRequest = ranges.length > 1;
                this.requestRanges = ranges.map(range => ({
                    start: range.start != null ? parseInt(range.start) : null,
                    end: range.end != null ? parseInt(range.end) : null
                }));
                this.multiPartBoundary = `multipart_byteranges_${Math.floor(Math.random() * 1000000000)}`;
            }
            catch (e) {
                this.log.error('error parsing range request header - %e', e);
                this.requestRanges = [];
            }
            this.setOffsetDetails();
        }
        else {
            this.log.trace('no range request detected');
            this.isRangeRequest = false;
        }
    }
    getByteRanges() {
        return this.byteRanges;
    }
    /**
     * You can pass a function when you need to support multi-range requests but have your own slicing logic, such as in the case of dag-pb/unixfs.
     *
     * @param bodyOrProvider - A supported body type or a function that returns a supported body type.
     * @param contentType - The content type of the body.
     */
    setBody(bodyOrProvider, contentType = 'application/octet-stream') {
        if (typeof bodyOrProvider === 'function') {
            this._body = this.createRangeStream(bodyOrProvider, contentType);
        }
        else {
            this._body = bodyOrProvider;
            // if fileSize was already set, don't recalculate it
            this.setFileSize(this._fileSize ?? getBodySizeSync(bodyOrProvider));
        }
        this.log.trace('set request body with fileSize %o', this._fileSize);
    }
    getBody(responseContentType) {
        const body = this._body;
        if (body == null) {
            this.log.trace('body is null');
            return body;
        }
        if (!this.isRangeRequest || !this.isValidRangeRequest) {
            this.log.trace('returning body unmodified for non-range, or invalid range, request');
            return body;
        }
        if (this.isMultiRangeRequest) {
            if (this._body instanceof ReadableStream) {
                return this._body;
            }
            return toBrowserReadableStream(this.getMultipartBody(responseContentType));
        }
        // Single range request handling
        if (this.byteRanges.length > 0) {
            const range = this.byteRanges[0];
            if (body instanceof ReadableStream) {
                // stream should already be spliced by `unixfs.cat`
                // TODO: if the content is not unixfs and unixfs.cat was not called, we need to slice the body here.
                return body;
            }
            if (range.start != null || range.end != null) {
                this.log.trace('returning body with byteStart=%o, byteEnd=%o, byteSize=%o', range.start, range.end, range.size);
            }
            return this.getSlicedBody(body, range);
        }
        // we should not reach this point, but return body untouched.
        this.log.error('returning unmodified body for valid range request');
        return body;
    }
    getSlicedBody(body, range) {
        const offset = range.start ?? 0;
        // Calculate the correct number of bytes to return
        // For a range like bytes=1000-2000, we want exactly 1001 bytes
        let length;
        if (range.end != null && range.start != null) {
            // Exact number of bytes is (end - start + 1) due to inclusive ranges
            length = range.end - range.start + 1;
        }
        else {
            length = undefined;
        }
        this.log.trace('slicing body with offset=%o and length=%o', offset, length);
        if (typeof body === 'string') {
            // String slicing works with start and end indices
            return body.slice(offset, length !== undefined ? offset + length : undefined);
        }
        else if (body instanceof Blob) {
            // Blob.slice takes start and end positions
            return body.slice(offset, length !== undefined ? offset + length : undefined);
        }
        else if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
            // ArrayBuffer.slice and Uint8Array.slice take start and end positions
            return body.slice(offset, length !== undefined ? offset + length : undefined);
        }
        // This should never happen due to type constraints
        return body;
    }
    /**
     * Sometimes, we need to set the fileSize explicitly because we can't calculate
     * the size of the body (e.g. for unixfs content where we call .stat).
     *
     * This fileSize should otherwise only be called from `setBody`.
     */
    setFileSize(size) {
        this._fileSize = size != null ? Number(size) : null;
        this._isValidRangeRequest = false; // body has changed, so we need to re-validate the byte ranges
        this.log.trace('set _fileSize to %o', this._fileSize);
        // when fileSize changes, we need to recalculate the offset details
        this.setOffsetDetails();
    }
    getFileSize() {
        return this._fileSize;
    }
    isValidByteStart(byteStart, byteEnd) {
        if (byteStart != null) {
            if (byteStart < 0) {
                return false;
            }
            if (this._fileSize != null && byteStart >= this._fileSize) {
                return false;
            }
            if (byteEnd != null && byteStart > byteEnd) {
                return false;
            }
        }
        return true;
    }
    isValidByteEnd(byteStart, byteEnd) {
        if (byteEnd != null) {
            if (byteEnd < 0) {
                this.log.trace('invalid range request, byteEnd is less than 0');
                return false;
            }
            if (this._fileSize != null && byteEnd >= this._fileSize) {
                this.log.trace('invalid range request, byteEnd is greater than fileSize');
                return false;
            }
            if (byteStart != null && byteEnd < byteStart) {
                this.log.trace('invalid range request, byteEnd is less than byteStart');
                return false;
            }
        }
        return true;
    }
    isValidByteRange(range) {
        this.log.trace('validating byte range: %o', range);
        if (range.start != null && !this.isValidByteStart(range.start, range.end)) {
            this.log.trace('invalid range request, byteStart is less than 0 or greater than fileSize');
            return false;
        }
        if (range.end != null && !this.isValidByteEnd(range.start, range.end)) {
            this.log.trace('invalid range request, byteEnd is less than 0 or greater than fileSize');
            return false;
        }
        return true;
    }
    /**
     * We may get the values required to determine if this is a valid range request at different times
     * so we need to calculate it when asked.
     */
    get isValidRangeRequest() {
        if (this._isValidRangeRequest) {
            // prevent unnecessary re-validation of each byte range
            return true;
        }
        if (!this.isRangeRequest) {
            return false;
        }
        if (this.byteRanges.length === 0) {
            this.log.trace('invalid range request, no valid ranges');
            return false;
        }
        const isValid = this.byteRanges.every(range => this.isValidByteRange(range));
        if (!isValid) {
            this.log.trace('invalid range request, not all ranges are valid');
            return false;
        }
        this._isValidRangeRequest = true;
        return true;
    }
    // /**
    //  * Given all the information we have, this function returns the offset that will be used when:
    //  * 1. calling unixfs.cat
    //  * 2. slicing the body
    //  */
    // public offset (range: ByteRange): number {
    //   if (this.byteRanges.length > 0) {
    //     return this.byteRanges[0].start ?? 0
    //   }
    //   return 0
    // }
    /**
     * Given all the information we have, this function returns the length that will be used when:
     * 1. calling unixfs.cat
     * 2. slicing the body
     */
    getLength(range) {
        if (!this.isValidRangeRequest) {
            this.log.error('cannot get length for invalid range request');
            return undefined;
        }
        if (this.isMultiRangeRequest && range == null) {
            /**
             * The content-length for a multi-range request is the sum of the lengths of all the ranges, plus the boundaries and part headers and newlines.
             */
            // TODO: figure out a way to calculate the correct content-length for multi-range requests' response.
            return undefined;
        }
        range ??= this.byteRanges[0];
        this.log.trace('getting length for range: %o', range);
        if (range.end != null && range.start != null) {
            // For a range like bytes=1000-2000, we want a length of 1001 bytes
            return range.end - range.start + 1;
        }
        if (range.end != null) {
            return range.end + 1;
        }
        return range.size;
    }
    /**
     * Converts a range request header into helia/unixfs supported range options
     * Note that the gateway specification says we "MAY" support multiple ranges (https://specs.ipfs.tech/http-gateways/path-gateway/#range-request-header) but we don't
     *
     * Also note that @helia/unixfs and ipfs-unixfs-exporter expect length and offset to be numbers, the range header is a string, and the size of the resource is likely a bigint.
     *
     * SUPPORTED:
     * Range: bytes=<range-start>-<range-end>
     * Range: bytes=<range-start>-
     * Range: bytes=-<suffix-length> // must pass size so we can calculate the offset. suffix-length is the number of bytes from the end of the file.
     * Range: bytes=<range-start>-<range-end>, <range-start>-<range-end>
     * Range: bytes=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
     *
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range#directives
     */
    setOffsetDetails() {
        if (this.requestRanges.length === 0) {
            this.log.trace('no request ranges defined');
            return;
        }
        try {
            // Calculate byte ranges for all requests
            this.byteRanges = this.requestRanges.map(range => {
                const { start, end, byteSize } = calculateByteRangeIndexes(range.start ?? undefined, range.end ?? undefined, this._fileSize ?? undefined);
                return { start, end, size: byteSize };
            });
            this.log.trace('set byte ranges: %o', this.byteRanges);
        }
        catch (e) {
            this.log.error('error setting offset details: %o', e);
            this.byteRanges = [];
        }
    }
    /**
     * Helper to convert a SliceableBody to a Uint8Array
     */
    async convertToUint8Array(content) {
        if (typeof content === 'string') {
            return new TextEncoder().encode(content);
        }
        if ('arrayBuffer' in content && typeof content.arrayBuffer === 'function') {
            // This is a Blob
            const buffer = await content.arrayBuffer();
            return new Uint8Array(buffer);
        }
        if ('byteLength' in content && !('buffer' in content)) {
            // This is an ArrayBuffer
            return new Uint8Array(content);
        }
        if ('buffer' in content && 'byteLength' in content && 'byteOffset' in content) {
            // This is a Uint8Array
            return content;
        }
        throw new Error('Unsupported content type for multipart response');
    }
    async *getMultipartBody(responseContentType = 'application/octet-stream') {
        const body = this._body;
        if (body instanceof ReadableStream) {
            // in the case of unixfs, the body is a readable stream, and setBody is called with a function that returns a readable stream that generates the
            // correct multipartBody.. so we just return that body.
            return body;
        }
        if (body === null) {
            throw new Error('Cannot create multipart body from null');
        }
        const encoder = new TextEncoder();
        for (const range of this.byteRanges) {
            if (range.start === undefined || range.end === undefined) {
                continue;
            }
            // Calculate part headers
            const partHeaderString = `\r\n--${this.multiPartBoundary}\r\n` +
                `Content-Type: ${responseContentType}\r\n` +
                `Content-Range: ${getContentRangeHeader({
                    byteStart: range.start,
                    byteEnd: range.end,
                    byteSize: this._fileSize ?? undefined
                })}\r\n\r\n`;
            // Convert header to Uint8Array
            yield encoder.encode(partHeaderString);
            // Get content for this range and convert to Uint8Array
            const slicedContent = this.getSlicedBodyForRange(body, range.start, range.end);
            yield await this.convertToUint8Array(slicedContent);
        }
        // Add final this.multiPartBoundary
        yield encoder.encode(`\r\n--${this.multiPartBoundary}--`);
    }
    getSlicedBodyForRange(body, start, end) {
        // Calculate the correct number of bytes to return
        // For a range like bytes=1000-2000, we want exactly 1001 bytes
        const offset = start;
        const length = end - start + 1;
        this.log.trace('slicing body with offset=%o and length=%o', offset, length);
        if (typeof body === 'string') {
            return body.slice(offset, offset + length);
        }
        else if (body instanceof Blob) {
            return body.slice(offset, offset + length);
        }
        else if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
            return body.slice(offset, offset + length);
        }
        else {
            // This should never happen due to type constraints
            return body;
        }
    }
    /**
     * Returns the content type for the response.
     * For multipart ranges, this will be multipart/byteranges with a boundary.
     */
    getContentType() {
        if (this.isMultiRangeRequest && this.isValidRangeRequest) {
            return `multipart/byteranges; boundary=${this.multiPartBoundary}`;
        }
        return undefined;
    }
    /**
     * This function returns the value of the "content-range" header.
     *
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
     *
     * Returns a string representing the following content ranges:
     *
     * @example
     * - Content-Range: <unit> <byteStart>-<byteEnd>/<byteSize>
     * - Content-Range: <unit> <byteStart>-<byteEnd>/*
     */
    // - Content-Range: <unit> */<byteSize> // this is purposefully not in jsdoc block
    get contentRangeHeaderValue() {
        // For multipart responses, this will be included in each part
        // So this method is only used for single-range responses
        if (!this.isValidRangeRequest) {
            this.log.error('cannot get contentRangeHeaderValue for invalid range request');
            throw new InvalidRangeError('Invalid range request');
        }
        if (this.isMultiRangeRequest) {
            this.log.error('contentRangeHeaderValue should not be called for multipart responses');
            throw new InvalidRangeError('Content-Range header not applicable for multipart responses');
        }
        if (this.byteRanges.length > 0) {
            const range = this.byteRanges[0];
            return getContentRangeHeader({
                byteStart: range.start,
                byteEnd: range.end,
                byteSize: this._fileSize ?? undefined
            });
        }
        throw new InvalidRangeError('No valid ranges found');
    }
    // Unified method to create a stream for either single or multi-range requests
    createRangeStream(contentProvider, contentType) {
        const encoder = new TextEncoder();
        const byteRanges = this.byteRanges;
        const multiPartBoundary = this.multiPartBoundary;
        const fileSize = this._fileSize;
        const log = this.log;
        const isMultiRangeRequest = this.isMultiRangeRequest;
        if (byteRanges.length === 0) {
            // TODO: create a stream with a range of *
            log.error('Cannot create range stream with no byte ranges');
            throw new InvalidRangeError('No valid ranges found');
        }
        return new ReadableStream({
            async start(controller) {
                try {
                    // For multi-range requests, we need to handle multiple parts with headers
                    for (const range of byteRanges) {
                        // Write part header for multipart responses
                        if (isMultiRangeRequest) {
                            const partHeader = `\r\n--${multiPartBoundary}\r\n` +
                                `Content-Type: ${contentType}\r\n` +
                                `Content-Range: ${getContentRangeHeader({
                                    byteStart: range.start,
                                    byteEnd: range.end,
                                    byteSize: fileSize ?? undefined
                                })}\r\n\r\n`;
                            controller.enqueue(encoder.encode(partHeader));
                        }
                        // Get and stream content for this range
                        try {
                            // Get content for this range
                            const rangeContent = contentProvider(range);
                            for await (const chunk of rangeContent) {
                                controller.enqueue(chunk);
                            }
                        }
                        catch (err) {
                            log.error('Error processing range %o: %o', range, err);
                            throw err; // Re-throw to be caught by the outer try/catch
                        }
                    }
                    if (isMultiRangeRequest) {
                        // Write final boundary for multipart
                        controller.enqueue(encoder.encode(`\r\n--${multiPartBoundary}--`));
                    }
                    controller.close();
                }
                catch (err) {
                    log.error('Error processing range(s) - %e', err);
                    controller.error(err);
                }
            }
        });
    }
}
//# sourceMappingURL=byte-range-context.js.map