/**
 * Bounded JSON body parser for App Router route handlers.
 *
 * Next.js 16's req.json() is unbounded; an attacker can POST a multi-MB
 * blob into any route and force the server to allocate memory before
 * Zod validation runs. parseJsonBounded enforces a wall before
 * deserialization.
 *
 * Usage:
 *   const body = await parseJsonBounded(req);                     // 1 MiB default
 *   const body = await parseJsonBounded(req, { maxBytes: 5_000_000 });
 *
 * Throws BodyTooLargeError on overflow — handler should return 413.
 * Throws BodyParseError on malformed JSON — handler should return 400.
 */

export class BodyTooLargeError extends Error {
    readonly status = 413;
    constructor(public readonly limit: number, public readonly actual: number) {
        super(`Request body exceeds ${limit} bytes (got ${actual}).`);
        this.name = 'BodyTooLargeError';
    }
}

export class BodyParseError extends Error {
    readonly status = 400;
    constructor(message: string) {
        super(message);
        this.name = 'BodyParseError';
    }
}

export interface ParseOptions {
    /** Hard ceiling in bytes. Default: 1 MiB. */
    maxBytes?: number;
}

const DEFAULT_MAX_BYTES = 1_048_576;

export async function parseJsonBounded<T = unknown>(
    req: Request,
    opts: ParseOptions = {}
): Promise<T> {
    const max = opts.maxBytes ?? DEFAULT_MAX_BYTES;

    // Fast-path: trust Content-Length when present.
    const cl = req.headers.get('content-length');
    if (cl) {
        const n = parseInt(cl, 10);
        if (Number.isFinite(n) && n > max) throw new BodyTooLargeError(max, n);
    }

    // Slow-path: stream and count bytes. Defends against missing /
    // chunked Content-Length where the attacker keeps the connection
    // open and writes indefinitely.
    const reader = req.body?.getReader();
    if (!reader) {
        // No body — return parsed `null`-ish value as Object.
        return {} as T;
    }

    const chunks: Uint8Array[] = [];
    let total = 0;
    while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        if (value) {
            total += value.byteLength;
            if (total > max) {
                try { await reader.cancel(); } catch {}
                throw new BodyTooLargeError(max, total);
            }
            chunks.push(value);
        }
    }

    if (total === 0) return {} as T;

    const buf = Buffer.concat(chunks.map(c => Buffer.from(c)), total);
    let text: string;
    try {
        text = buf.toString('utf8');
    } catch (err) {
        throw new BodyParseError(`Body is not valid UTF-8: ${String(err)}`);
    }

    try {
        return JSON.parse(text) as T;
    } catch (err) {
        throw new BodyParseError(`Body is not valid JSON: ${String(err)}`);
    }
}
