/**
 * Stream Handler for Voice Module
 *
 * Provides audio stream chunking, backpressure handling, and stream coordination.
 *
 * @module voice/stream-handler
 */
import { EventEmitter } from "events";
import { logger } from "../utils/logger.js";
/**
 * Default configuration
 */
const DEFAULT_CONFIG = {
    chunkDurationMs: 100, // 100ms chunks
    sampleRate: 16000,
    bytesPerSample: 2, // 16-bit mono
    format: "wav",
    highWaterMark: 64 * 1024, // 64KB
    bufferTimeoutMs: 5000, // 5 seconds
};
/**
 * Chunked Audio Stream Handler
 *
 * Handles audio stream chunking with backpressure management.
 *
 * @example
 * ```typescript
 * const handler = new ChunkedAudioStream({
 *   chunkDurationMs: 100,
 *   sampleRate: 16000,
 * });
 *
 * handler.on('chunk', (chunk) => {
 *   // Process audio chunk
 * });
 *
 * handler.write(audioData);
 * handler.end();
 * ```
 */
export class ChunkedAudioStream extends EventEmitter {
    config;
    chunkSize;
    buffer;
    chunkIndex;
    timestampMs;
    isPaused;
    isEnded;
    pendingData;
    bufferTimeout;
    constructor(config = {}) {
        super();
        this.config = { ...DEFAULT_CONFIG, ...config };
        if (this.config.sampleRate <= 0) {
            throw new Error("Invalid stream configuration: sampleRate must be positive");
        }
        if (this.config.bytesPerSample <= 0) {
            throw new Error("Invalid stream configuration: bytesPerSample must be positive");
        }
        // Calculate chunk size based on duration
        const bytesPerMs = (this.config.sampleRate * this.config.bytesPerSample) / 1000;
        this.chunkSize = Math.round(this.config.chunkDurationMs * bytesPerMs);
        if (this.chunkSize <= 0) {
            throw new Error("Invalid stream configuration: chunkSize must be positive (check chunkDurationMs, sampleRate, bytesPerSample)");
        }
        this.buffer = Buffer.alloc(0);
        this.chunkIndex = 0;
        this.timestampMs = 0;
        this.isPaused = false;
        this.isEnded = false;
        this.pendingData = [];
        this.bufferTimeout = null;
    }
    /**
     * Write audio data to the stream
     *
     * @param data - Audio data buffer
     * @returns True if more data can be written, false if backpressure
     */
    write(data) {
        if (this.isEnded) {
            throw new Error("Cannot write to ended stream");
        }
        // Decide backpressure state BEFORE processing so the producer can rely on
        // the returned flag without depending on a follow-up `write()` to flip it.
        // Also queue the chunk into pendingData when paused — `processData()` is
        // what eventually triggers `drain` via the `resume`/`drain` path, so it
        // must run after the resume condition is met (CodeRabbit review:
        // previously, processing first could emit `drain` synchronously inside
        // `processData()` if buffer drained below high-water mid-call, and a
        // producer that waited for `drain` after seeing `false` could deadlock).
        const willOverflow = this.buffer.length + data.length > this.config.highWaterMark;
        if (willOverflow) {
            if (!this.isPaused) {
                this.isPaused = true;
                this.emit("pause");
            }
            this.pendingData.push(data);
            return false;
        }
        this.processData(data);
        return true;
    }
    /**
     * Process incoming data
     */
    processData(data) {
        // Append to buffer
        this.buffer = Buffer.concat([this.buffer, data]);
        // Reset buffer timeout
        this.resetBufferTimeout();
        // Emit chunks while we have enough data
        while (this.buffer.length >= this.chunkSize) {
            const chunkData = this.buffer.subarray(0, this.chunkSize);
            this.buffer = this.buffer.subarray(this.chunkSize);
            const chunk = {
                data: chunkData,
                index: this.chunkIndex++,
                isFinal: false,
                format: this.config.format,
                sampleRate: this.config.sampleRate,
                timestampMs: this.timestampMs,
                durationMs: this.config.chunkDurationMs,
            };
            this.timestampMs += this.config.chunkDurationMs;
            this.emit("chunk", chunk);
        }
        // Process pending data if backpressure released
        if (this.isPaused && this.buffer.length < this.config.highWaterMark / 2) {
            this.isPaused = false;
            this.emit("resume");
            this.emit("drain");
            // Process pending data. `shift()` returns `undefined` only when the
            // array is empty, which the loop guard already rules out — but check
            // explicitly so we don't carry a non-null assertion (Issue 9).
            while (this.pendingData.length > 0 && !this.isPaused) {
                const pending = this.pendingData.shift();
                if (pending === undefined) {
                    break;
                }
                if (!this.write(pending)) {
                    break;
                }
            }
        }
    }
    /**
     * End the stream
     */
    end() {
        if (this.isEnded) {
            return;
        }
        this.isEnded = true;
        this.clearBufferTimeout();
        // Drain any pending data that was buffered during backpressure
        for (const pending of this.pendingData) {
            this.buffer = Buffer.concat([this.buffer, pending]);
        }
        this.pendingData = [];
        // Emit final chunk with remaining data
        if (this.buffer.length > 0) {
            const durationMs = (this.buffer.length /
                this.config.bytesPerSample /
                this.config.sampleRate) *
                1000;
            const chunk = {
                data: this.buffer,
                index: this.chunkIndex++,
                isFinal: true,
                format: this.config.format,
                sampleRate: this.config.sampleRate,
                timestampMs: this.timestampMs,
                durationMs,
            };
            this.emit("chunk", chunk);
        }
        else {
            // Emit empty final chunk to signal end
            const chunk = {
                data: Buffer.alloc(0),
                index: this.chunkIndex,
                isFinal: true,
                format: this.config.format,
                sampleRate: this.config.sampleRate,
                timestampMs: this.timestampMs,
                durationMs: 0,
            };
            this.emit("chunk", chunk);
        }
        this.emit("end");
        this.cleanup();
    }
    /**
     * Reset buffer timeout
     */
    resetBufferTimeout() {
        this.clearBufferTimeout();
        this.bufferTimeout = setTimeout(() => {
            if (this.buffer.length > 0 && !this.isEnded) {
                logger.warn(`[ChunkedAudioStream] Buffer timeout, forcing flush of ${this.buffer.length} bytes`);
                this.end();
            }
        }, this.config.bufferTimeoutMs);
    }
    /**
     * Clear buffer timeout
     */
    clearBufferTimeout() {
        if (this.bufferTimeout) {
            clearTimeout(this.bufferTimeout);
            this.bufferTimeout = null;
        }
    }
    /**
     * Cleanup resources
     */
    cleanup() {
        this.clearBufferTimeout();
        this.buffer = Buffer.alloc(0);
        this.pendingData = [];
    }
    /**
     * Get stream statistics
     */
    getStats() {
        return {
            chunksEmitted: this.chunkIndex,
            bufferedBytes: this.buffer.length,
            pendingChunks: this.pendingData.length,
            totalDurationMs: this.timestampMs,
            isPaused: this.isPaused,
            isEnded: this.isEnded,
        };
    }
}
/**
 * Stream merger for combining multiple audio streams
 */
export class StreamMerger extends EventEmitter {
    streams;
    config;
    constructor(config = {}) {
        super();
        this.streams = new Map();
        this.config = { ...DEFAULT_CONFIG, ...config };
    }
    /**
     * Add a stream to merge
     *
     * @param id - Stream identifier
     * @returns The created stream
     */
    addStream(id) {
        if (this.streams.has(id)) {
            throw new Error(`Stream ${id} already exists`);
        }
        const stream = new ChunkedAudioStream(this.config);
        stream.on("chunk", (chunk) => {
            this.emit("chunk", { id, chunk });
        });
        stream.on("end", () => {
            this.emit("streamEnd", id);
            this.streams.delete(id);
            if (this.streams.size === 0) {
                this.emit("end");
            }
        });
        stream.on("error", (error) => {
            this.emit("error", { id, error });
        });
        this.streams.set(id, stream);
        return stream;
    }
    /**
     * Remove a stream
     *
     * @param id - Stream identifier
     */
    removeStream(id) {
        const stream = this.streams.get(id);
        if (stream) {
            stream.end();
            this.streams.delete(id);
        }
    }
    /**
     * Write to a specific stream
     *
     * @param id - Stream identifier
     * @param data - Audio data
     */
    write(id, data) {
        const stream = this.streams.get(id);
        if (!stream) {
            throw new Error(`Stream ${id} not found`);
        }
        return stream.write(data);
    }
    /**
     * End all streams
     */
    endAll() {
        for (const stream of this.streams.values()) {
            stream.end();
        }
    }
    /**
     * Get number of active streams
     */
    get activeStreams() {
        return this.streams.size;
    }
}
/**
 * Stream splitter for distributing audio to multiple consumers
 */
export class StreamSplitter extends EventEmitter {
    consumers;
    input;
    constructor(config = {}) {
        super();
        this.consumers = new Map();
        this.input = new ChunkedAudioStream(config);
        this.input.on("chunk", (chunk) => {
            for (const [id, consumer] of this.consumers) {
                try {
                    consumer(chunk);
                }
                catch (err) {
                    this.emit("error", {
                        consumerId: id,
                        error: err instanceof Error ? err : new Error(String(err)),
                    });
                }
            }
        });
        this.input.on("end", () => {
            this.emit("end");
        });
        this.input.on("error", (error) => {
            this.emit("error", { error });
        });
    }
    /**
     * Write audio data
     *
     * @param data - Audio data buffer
     */
    write(data) {
        return this.input.write(data);
    }
    /**
     * End the stream
     */
    end() {
        this.input.end();
    }
    /**
     * Add a consumer
     *
     * @param id - Consumer identifier
     * @param handler - Chunk handler function
     */
    addConsumer(id, handler) {
        if (this.consumers.has(id)) {
            throw new Error(`Consumer ${id} already exists`);
        }
        this.consumers.set(id, handler);
    }
    /**
     * Remove a consumer
     *
     * @param id - Consumer identifier
     */
    removeConsumer(id) {
        this.consumers.delete(id);
    }
    /**
     * Get number of consumers
     */
    get consumerCount() {
        return this.consumers.size;
    }
}
/**
 * Create an async iterable from a chunked audio stream
 *
 * @param stream - Chunked audio stream
 * @returns Async iterable of audio chunks
 */
export function streamToAsyncIterable(stream) {
    return {
        [Symbol.asyncIterator]() {
            const queue = [];
            let resolveNext = null;
            let rejectNext = null;
            let done = false;
            let error = null;
            const onChunk = (chunk) => {
                if (resolveNext) {
                    resolveNext({ value: chunk, done: false });
                    resolveNext = null;
                    rejectNext = null;
                }
                else {
                    queue.push(chunk);
                }
            };
            const onEnd = () => {
                done = true;
                if (resolveNext) {
                    resolveNext({
                        value: undefined,
                        done: true,
                    });
                    resolveNext = null;
                    rejectNext = null;
                }
            };
            const onError = (err) => {
                // NEW5: surface stream errors to the consumer instead of resolving
                // with `done: true` (which silently terminated the for-await loop
                // and lost the error entirely). When a `next()` is pending, reject
                // it. When no `next()` is pending, store the error so the next call
                // throws it.
                error = err;
                if (rejectNext) {
                    rejectNext(err);
                    resolveNext = null;
                    rejectNext = null;
                }
            };
            stream.on("chunk", onChunk);
            stream.on("end", onEnd);
            stream.on("error", onError);
            // M8: track listener removal so the iterator's `return()` can detach
            // them when the consumer breaks early; otherwise the three listeners
            // hang on the EventEmitter for the stream's lifetime, leaking closures.
            const cleanup = () => {
                stream.off("chunk", onChunk);
                stream.off("end", onEnd);
                stream.off("error", onError);
            };
            return {
                async next() {
                    if (error) {
                        throw error;
                    }
                    if (queue.length > 0) {
                        // The length-guard above proves `shift()` returns a value, but
                        // narrow explicitly to avoid the non-null assertion (Issue 9).
                        const next = queue.shift();
                        if (next !== undefined) {
                            return { value: next, done: false };
                        }
                    }
                    if (done) {
                        return {
                            value: undefined,
                            done: true,
                        };
                    }
                    return new Promise((resolve, reject) => {
                        resolveNext = resolve;
                        rejectNext = reject;
                    });
                },
                async return() {
                    // M8: called when the consumer breaks out of `for await` early —
                    // detach listeners to prevent leak.
                    cleanup();
                    done = true;
                    return {
                        value: undefined,
                        done: true,
                    };
                },
            };
        },
    };
}
/**
 * Create a chunked audio stream from an async iterable
 *
 * @param iterable - Async iterable of audio buffers
 * @param config - Stream configuration
 * @returns Chunked audio stream
 */
export async function asyncIterableToStream(iterable, config = {}) {
    const stream = new ChunkedAudioStream(config);
    // Process iterable in background.
    // Honour backpressure — `write()` returns false on overflow and pushes the
    // chunk into pendingData. Without awaiting 'drain' here, a slow consumer
    // would let pendingData grow unbounded for a long/live iterable.
    (async () => {
        try {
            for await (const data of iterable) {
                const ok = stream.write(data);
                if (!ok) {
                    await new Promise((resolve) => stream.once("drain", resolve));
                }
            }
            stream.end();
        }
        catch (err) {
            stream.emit("error", err instanceof Error ? err : new Error(String(err)));
        }
    })();
    return stream;
}
// Export main class with alias
export { ChunkedAudioStream as StreamHandler };
//# sourceMappingURL=stream-handler.js.map