import { createReconnectManager } from './reconnect';
// =============================================================================
// Track Sources
// =============================================================================
/**
 * User media (camera/microphone) track source.
 */
export class UserMediaSource {
    constraints;
    type = 'user-media';
    stream = null;
    constructor(constraints = { video: true, audio: true }) {
        this.constraints = constraints;
    }
    async getStream() {
        this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
        return this.stream;
    }
    stop() {
        if (this.stream) {
            for (const track of this.stream.getTracks()) {
                track.stop();
            }
            this.stream = null;
        }
    }
}
/**
 * Display media (screen share) track source.
 */
export class DisplayMediaSource {
    constraints;
    type = 'display-media';
    stream = null;
    constructor(constraints = { video: true, audio: false }) {
        this.constraints = constraints;
    }
    async getStream() {
        this.stream = await navigator.mediaDevices.getDisplayMedia(this.constraints);
        return this.stream;
    }
    stop() {
        if (this.stream) {
            for (const track of this.stream.getTracks()) {
                track.stop();
            }
            this.stream = null;
        }
    }
}
/**
 * Custom stream track source - wraps a user-provided MediaStream.
 */
export class CustomStreamSource {
    stream;
    type = 'custom';
    constructor(stream) {
        this.stream = stream;
    }
    async getStream() {
        return this.stream;
    }
    stop() {
        for (const track of this.stream.getTracks()) {
            track.stop();
        }
    }
}
/**
 * Default ICE servers (public STUN servers)
 */
const DEFAULT_ICE_SERVERS = [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' },
];
// =============================================================================
// WebRTCManager
// =============================================================================
/**
 * Framework-agnostic WebRTC connection manager with multi-peer mesh networking,
 * perfect negotiation, media/data channel handling, and screen sharing.
 *
 * Uses an explicit state machine for connection lifecycle:
 * - idle: No resources allocated, ready to connect
 * - connecting: Acquiring media + opening WebSocket
 * - signaling: In room, waiting for peer(s)
 * - negotiating: SDP/ICE exchange in progress with at least one peer
 * - connected: At least one peer is connected
 *
 * @example
 * ```ts
 * const manager = new WebRTCManager({
 *   signalUrl: 'wss://example.com/call/signal',
 *   roomId: 'my-room',
 *   callbacks: {
 *     onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason),
 *     onConnect: () => console.log('Connected!'),
 *     onRemoteStream: (peerId, stream) => { remoteVideos[peerId].srcObject = stream; },
 *   },
 * });
 *
 * await manager.connect();
 * ```
 */
export class WebRTCManager {
    ws = null;
    localStream = null;
    trackSource = null;
    previousVideoTrack = null;
    peerId = null;
    peers = new Map();
    isAudioMuted = false;
    isVideoMuted = false;
    isScreenSharing = false;
    _state = 'idle';
    isConnecting = false;
    basePolite;
    options;
    callbacks;
    reconnectManager;
    isReconnecting = false;
    intentionalClose = false;
    connectionTimeoutId = null;
    recordings = new Map();
    constructor(options) {
        this.options = {
            ...options,
            autoReconnect: options.autoReconnect ?? true,
            maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
            connectionTimeout: options.connectionTimeout ?? 30000,
            iceGatheringTimeout: options.iceGatheringTimeout ?? 10000,
        };
        this.basePolite = options.polite;
        this.callbacks = options.callbacks ?? {};
        this.reconnectManager = createReconnectManager({
            onReconnect: () => {
                void this.reconnect();
            },
            baseDelay: 1000,
            factor: 2,
            maxDelay: 30000,
            jitter: 0,
            enabled: () => this.shouldAutoReconnect(),
        });
    }
    /**
     * Current connection state
     */
    get state() {
        return this._state;
    }
    /**
     * Get current manager state
     */
    getState() {
        return {
            state: this._state,
            peerId: this.peerId,
            remotePeerIds: Array.from(this.peers.keys()),
            isAudioMuted: this.isAudioMuted,
            isVideoMuted: this.isVideoMuted,
            isScreenSharing: this.isScreenSharing,
        };
    }
    /**
     * Get local media stream
     */
    getLocalStream() {
        return this.localStream;
    }
    /**
     * Get remote media streams keyed by peer ID
     */
    getRemoteStreams() {
        const streams = new Map();
        for (const [peerId, session] of this.peers) {
            if (session.remoteStream) {
                streams.set(peerId, session.remoteStream);
            }
        }
        return streams;
    }
    /**
     * Get a specific peer's remote stream
     */
    getRemoteStream(peerId) {
        return this.peers.get(peerId)?.remoteStream ?? null;
    }
    /**
     * Whether this manager is in data-only mode (no media streams).
     */
    get isDataOnly() {
        return this.options.media === false;
    }
    /**
     * Get connected peer count
     */
    get peerCount() {
        return this.peers.size;
    }
    // =========================================================================
    // State Machine
    // =========================================================================
    setState(newState, reason) {
        const prevState = this._state;
        if (prevState === newState)
            return;
        this._state = newState;
        this.handleStateTimeouts(newState);
        this.callbacks.onStateChange?.(prevState, newState, reason);
        if (newState === 'connected' && prevState !== 'connected') {
            this.callbacks.onConnect?.();
        }
        if (newState === 'idle' && prevState !== 'idle') {
            const disconnectReason = this.mapToDisconnectReason(reason);
            this.callbacks.onDisconnect?.(disconnectReason);
        }
    }
    mapToDisconnectReason(reason) {
        if (reason === 'hangup')
            return 'hangup';
        if (reason === 'peer-left')
            return 'peer-left';
        if (reason?.includes('timeout'))
            return 'timeout';
        return 'error';
    }
    handleStateTimeouts(state) {
        if (state === 'connecting' || state === 'negotiating') {
            this.startConnectionTimeout();
            return;
        }
        this.clearConnectionTimeout();
    }
    startConnectionTimeout() {
        this.clearConnectionTimeout();
        const timeoutMs = this.options.connectionTimeout ?? 30000;
        this.connectionTimeoutId = setTimeout(() => {
            if (this._state === 'connecting' || this._state === 'negotiating') {
                const error = new Error('WebRTC connection timed out');
                this.callbacks.onError?.(error, this._state);
                this.handleTimeout('connection-timeout');
            }
        }, timeoutMs);
    }
    clearConnectionTimeout() {
        if (this.connectionTimeoutId) {
            clearTimeout(this.connectionTimeoutId);
            this.connectionTimeoutId = null;
        }
    }
    handleTimeout(reason) {
        this.intentionalClose = true;
        this.cleanupPeerSessions();
        if (this.ws) {
            this.ws.close();
            this.ws = null;
        }
        this.peerId = null;
        this.setState('idle', reason);
        this.intentionalClose = false;
    }
    shouldAutoReconnect() {
        return (this.options.autoReconnect ?? true) && !this.intentionalClose;
    }
    updateConnectionState() {
        const connectedPeers = Array.from(this.peers.values()).filter((p) => p.pc.iceConnectionState === 'connected' || p.pc.iceConnectionState === 'completed');
        if (connectedPeers.length > 0) {
            if (this._state !== 'connected') {
                this.setState('connected', 'peer connected');
            }
        }
        else if (this.peers.size > 0) {
            if (this._state === 'connected') {
                this.setState('negotiating', 'no connected peers');
            }
        }
        else if (this._state === 'connected' || this._state === 'negotiating') {
            this.setState('signaling', 'all peers left');
        }
    }
    send(msg) {
        if (this.ws?.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(msg));
        }
    }
    // =========================================================================
    // Connection
    // =========================================================================
    /**
     * Connect to the signaling server and start the call
     */
    async connect() {
        if (this._state !== 'idle' || this.isConnecting)
            return;
        this.isConnecting = true;
        this.intentionalClose = false;
        this.reconnectManager.reset();
        this.setState('connecting', 'connect() called');
        try {
            await this.ensureLocalStream();
            this.openWebSocket();
        }
        catch (err) {
            // Clean up local media on failure
            if (this.localStream) {
                for (const track of this.localStream.getTracks()) {
                    track.stop();
                }
                this.localStream = null;
            }
            if (this.trackSource) {
                this.trackSource.stop();
                this.trackSource = null;
            }
            const error = err instanceof Error ? err : new Error(String(err));
            this.callbacks.onError?.(error, this._state);
            this.isConnecting = false;
            this.setState('idle', 'error');
        }
        finally {
            this.isConnecting = false;
        }
    }
    async ensureLocalStream() {
        if (this.options.media === false || this.localStream)
            return;
        if (this.options.media &&
            typeof this.options.media === 'object' &&
            'getStream' in this.options.media) {
            this.trackSource = this.options.media;
        }
        else {
            const constraints = this.options.media ?? {
                video: true,
                audio: true,
            };
            this.trackSource = new UserMediaSource(constraints);
        }
        this.localStream = await this.trackSource.getStream();
        this.callbacks.onLocalStream?.(this.localStream);
    }
    openWebSocket() {
        if (this.ws) {
            const previous = this.ws;
            this.ws = null;
            previous.onclose = null;
            previous.onerror = null;
            previous.onmessage = null;
            previous.onopen = null;
            previous.close();
        }
        this.ws = new WebSocket(this.options.signalUrl);
        this.ws.onopen = () => {
            this.setState('signaling', 'WebSocket opened');
            this.send({ t: 'join', roomId: this.options.roomId });
            if (this.isReconnecting) {
                this.isReconnecting = false;
                this.reconnectManager.recordSuccess();
                this.callbacks.onReconnected?.();
            }
        };
        this.ws.onmessage = (event) => {
            try {
                const msg = JSON.parse(event.data);
                void this.handleSignalingMessage(msg).catch((err) => {
                    this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)), this._state);
                });
            }
            catch (_err) {
                this.callbacks.onError?.(new Error('Invalid signaling message'), this._state);
            }
        };
        this.ws.onerror = () => {
            const error = new Error('WebSocket connection error');
            this.callbacks.onError?.(error, this._state);
        };
        this.ws.onclose = () => {
            if (this._state === 'idle')
                return;
            if (this.intentionalClose) {
                this.setState('idle', 'WebSocket closed');
                return;
            }
            this.handleConnectionLoss('WebSocket closed');
        };
    }
    handleConnectionLoss(reason) {
        this.cleanupPeerSessions();
        this.peerId = null;
        if (this.shouldAutoReconnect()) {
            this.scheduleReconnect(reason);
        }
        else {
            this.setState('idle', reason);
        }
    }
    scheduleReconnect(reason) {
        const nextAttempt = this.reconnectManager.getAttempts() + 1;
        const maxAttempts = this.options.maxReconnectAttempts ?? 5;
        if (nextAttempt > maxAttempts) {
            this.callbacks.onReconnectFailed?.();
            this.setState('idle', 'reconnect-failed');
            return;
        }
        this.isReconnecting = true;
        this.callbacks.onReconnecting?.(nextAttempt);
        this.setState('connecting', `reconnecting:${reason}`);
        this.reconnectManager.recordFailure();
    }
    async reconnect() {
        if (!this.shouldAutoReconnect())
            return;
        this.cleanupPeerSessions();
        this.peerId = null;
        try {
            await this.ensureLocalStream();
            this.openWebSocket();
        }
        catch (err) {
            const error = err instanceof Error ? err : new Error(String(err));
            this.callbacks.onError?.(error, this._state);
            this.scheduleReconnect('reconnect-error');
        }
    }
    async handleSignalingMessage(msg) {
        switch (msg.t) {
            case 'joined':
                this.peerId = msg.peerId;
                for (const existingPeerId of msg.peers) {
                    await this.createPeerSession(existingPeerId, true);
                }
                break;
            case 'peer-joined':
                this.callbacks.onPeerJoined?.(msg.peerId);
                await this.createPeerSession(msg.peerId, false);
                break;
            case 'peer-left':
                this.callbacks.onPeerLeft?.(msg.peerId);
                this.closePeerSession(msg.peerId);
                this.updateConnectionState();
                break;
            case 'sdp':
                await this.handleRemoteSDP(msg.from, msg.description);
                break;
            case 'ice':
                await this.handleRemoteICE(msg.from, msg.candidate);
                break;
            case 'error': {
                const error = new Error(msg.message);
                this.callbacks.onError?.(error, this._state);
                break;
            }
        }
    }
    // =========================================================================
    // Peer Session Management
    // =========================================================================
    async createPeerSession(remotePeerId, isOfferer) {
        if (this.peers.has(remotePeerId)) {
            return this.peers.get(remotePeerId);
        }
        const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS;
        const pc = new RTCPeerConnection({ iceServers });
        const session = {
            peerId: remotePeerId,
            pc,
            remoteStream: null,
            dataChannels: new Map(),
            makingOffer: false,
            ignoreOffer: false,
            hasRemoteDescription: false,
            pendingCandidates: [],
            isOfferer,
            negotiationStarted: false,
            hasIceCandidate: false,
            iceGatheringTimer: null,
        };
        this.peers.set(remotePeerId, session);
        if (this.localStream) {
            for (const track of this.localStream.getTracks()) {
                pc.addTrack(track, this.localStream);
            }
        }
        pc.ontrack = (event) => {
            if (event.streams?.[0]) {
                if (session.remoteStream !== event.streams[0]) {
                    session.remoteStream = event.streams[0];
                    this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
                }
            }
            else {
                if (!session.remoteStream) {
                    session.remoteStream = new MediaStream([event.track]);
                    this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
                }
                else {
                    session.remoteStream.addTrack(event.track);
                    this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
                }
            }
            this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream);
            this.updateConnectionState();
        };
        pc.ondatachannel = (event) => {
            this.setupDataChannel(session, event.channel);
        };
        pc.onicecandidate = (event) => {
            if (event.candidate) {
                session.hasIceCandidate = true;
                if (session.iceGatheringTimer) {
                    clearTimeout(session.iceGatheringTimer);
                    session.iceGatheringTimer = null;
                }
                this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON());
                this.send({
                    t: 'ice',
                    from: this.peerId,
                    to: remotePeerId,
                    candidate: event.candidate.toJSON(),
                });
            }
        };
        this.scheduleIceGatheringTimeout(session);
        pc.onnegotiationneeded = async () => {
            // If we're not the offerer and haven't received a remote description yet,
            // don't send an offer - wait for the other peer's offer
            if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) {
                return;
            }
            // Serialize local offer generation so negotiationneeded does not race
            // against the explicit initial offer path or another in-flight renegotiation.
            if (this.hasOfferCollision(session)) {
                return;
            }
            try {
                await this.createOffer(session);
            }
            catch (err) {
                const error = err instanceof Error ? err : new Error(String(err));
                this.callbacks.onError?.(error, this._state);
            }
        };
        pc.oniceconnectionstatechange = () => {
            const iceState = pc.iceConnectionState;
            this.callbacks.onIceStateChange?.(remotePeerId, iceState);
            this.updateConnectionState();
            if (iceState === 'failed') {
                const error = new Error(`ICE connection failed for peer ${remotePeerId}`);
                this.callbacks.onError?.(error, this._state);
                this.handleConnectionLoss('ice-failed');
            }
        };
        if (isOfferer) {
            if (this.options.dataChannels) {
                for (const config of this.options.dataChannels) {
                    const channel = pc.createDataChannel(config.label, {
                        ordered: config.ordered ?? true,
                        maxPacketLifeTime: config.maxPacketLifeTime,
                        maxRetransmits: config.maxRetransmits,
                        protocol: config.protocol,
                    });
                    this.setupDataChannel(session, channel);
                }
            }
            this.setState('negotiating', 'creating offer');
            this.callbacks.onNegotiationStart?.(remotePeerId);
            await this.createOffer(session);
        }
        return session;
    }
    async createOffer(session) {
        try {
            session.makingOffer = true;
            session.negotiationStarted = true;
            const offer = await session.pc.createOffer();
            await session.pc.setLocalDescription(offer);
            this.send({
                t: 'sdp',
                from: this.peerId,
                to: session.peerId,
                description: session.pc.localDescription,
            });
        }
        finally {
            session.makingOffer = false;
        }
    }
    async handleRemoteSDP(fromPeerId, description) {
        let session = this.peers.get(fromPeerId);
        if (!session) {
            session = await this.createPeerSession(fromPeerId, false);
        }
        const pc = session.pc;
        const isOffer = description.type === 'offer';
        const polite = this.basePolite ?? !this.isOffererFor(fromPeerId);
        const offerCollision = isOffer && this.hasOfferCollision(session);
        session.ignoreOffer = !polite && offerCollision;
        if (session.ignoreOffer)
            return;
        if (this._state === 'signaling') {
            this.setState('negotiating', 'received SDP');
            this.callbacks.onNegotiationStart?.(fromPeerId);
        }
        await pc.setRemoteDescription(description);
        session.hasRemoteDescription = true;
        for (const candidate of session.pendingCandidates) {
            try {
                await pc.addIceCandidate(candidate);
            }
            catch (err) {
                if (!session.ignoreOffer) {
                    console.warn('Failed to add buffered ICE candidate:', err);
                }
            }
        }
        session.pendingCandidates = [];
        if (isOffer) {
            session.negotiationStarted = true;
            await pc.setLocalDescription();
            this.send({
                t: 'sdp',
                from: this.peerId,
                to: fromPeerId,
                description: pc.localDescription,
            });
        }
        this.callbacks.onNegotiationComplete?.(fromPeerId);
    }
    hasOfferCollision(session) {
        return session.makingOffer || session.pc.signalingState !== 'stable';
    }
    isOffererFor(remotePeerId) {
        return this.peerId > remotePeerId;
    }
    async handleRemoteICE(fromPeerId, candidate) {
        const session = this.peers.get(fromPeerId);
        if (!session || !session.hasRemoteDescription) {
            if (session) {
                session.pendingCandidates.push(candidate);
            }
            return;
        }
        try {
            await session.pc.addIceCandidate(candidate);
        }
        catch (err) {
            if (!session.ignoreOffer) {
                console.warn('Failed to add ICE candidate:', err);
            }
        }
    }
    closePeerSession(peerId) {
        const session = this.peers.get(peerId);
        if (!session)
            return;
        // Clear ICE gathering timer if exists
        if (session.iceGatheringTimer) {
            clearTimeout(session.iceGatheringTimer);
            session.iceGatheringTimer = null;
        }
        // Close data channels
        for (const channel of session.dataChannels.values()) {
            channel.close();
        }
        session.dataChannels.clear();
        // Clear all event handlers before closing to prevent memory leaks
        const pc = session.pc;
        pc.ontrack = null;
        pc.ondatachannel = null;
        pc.onicecandidate = null;
        pc.onnegotiationneeded = null;
        pc.oniceconnectionstatechange = null;
        pc.close();
        this.peers.delete(peerId);
    }
    cleanupPeerSessions() {
        for (const peerId of this.peers.keys()) {
            this.closePeerSession(peerId);
        }
        this.peers.clear();
    }
    scheduleIceGatheringTimeout(session) {
        const timeoutMs = this.options.iceGatheringTimeout ?? 10000;
        if (timeoutMs <= 0)
            return;
        if (session.iceGatheringTimer) {
            clearTimeout(session.iceGatheringTimer);
        }
        session.iceGatheringTimer = setTimeout(() => {
            if (!session.hasIceCandidate) {
                console.warn(`ICE gathering timeout for peer ${session.peerId}`);
            }
        }, timeoutMs);
    }
    // =========================================================================
    // Data Channel
    // =========================================================================
    setupDataChannel(session, channel) {
        const label = channel.label;
        const peerId = session.peerId;
        session.dataChannels.set(label, channel);
        channel.onopen = () => {
            this.callbacks.onDataChannelOpen?.(peerId, label);
            if (this.isDataOnly && this._state !== 'connected') {
                this.updateConnectionState();
            }
        };
        channel.onclose = () => {
            session.dataChannels.delete(label);
            this.callbacks.onDataChannelClose?.(peerId, label);
        };
        channel.onmessage = (event) => {
            const data = event.data;
            if (typeof data === 'string') {
                try {
                    const parsed = JSON.parse(data);
                    this.callbacks.onDataChannelMessage?.(peerId, label, parsed);
                }
                catch {
                    this.callbacks.onDataChannelMessage?.(peerId, label, data);
                }
            }
            else {
                this.callbacks.onDataChannelMessage?.(peerId, label, data);
            }
        };
        channel.onerror = (event) => {
            const error = event instanceof ErrorEvent
                ? new Error(event.message)
                : new Error('Data channel error');
            this.callbacks.onDataChannelError?.(peerId, label, error);
        };
    }
    /**
     * Create a new data channel to all connected peers.
     */
    createDataChannel(config) {
        const channels = new Map();
        for (const [peerId, session] of this.peers) {
            const channel = session.pc.createDataChannel(config.label, {
                ordered: config.ordered ?? true,
                maxPacketLifeTime: config.maxPacketLifeTime,
                maxRetransmits: config.maxRetransmits,
                protocol: config.protocol,
            });
            this.setupDataChannel(session, channel);
            channels.set(peerId, channel);
        }
        return channels;
    }
    /**
     * Get a data channel by label from a specific peer.
     */
    getDataChannel(peerId, label) {
        return this.peers.get(peerId)?.dataChannels.get(label);
    }
    /**
     * Get all open data channel labels.
     */
    getDataChannelLabels() {
        const labels = new Set();
        for (const session of this.peers.values()) {
            for (const label of session.dataChannels.keys()) {
                labels.add(label);
            }
        }
        return Array.from(labels);
    }
    /**
     * Get the state of a data channel for a specific peer.
     */
    getDataChannelState(peerId, label) {
        const channel = this.peers.get(peerId)?.dataChannels.get(label);
        return channel ? channel.readyState : null;
    }
    /**
     * Send a string message to all peers on a data channel.
     */
    sendString(label, data) {
        let sent = false;
        for (const session of this.peers.values()) {
            const channel = session.dataChannels.get(label);
            if (channel?.readyState === 'open') {
                channel.send(data);
                sent = true;
            }
        }
        return sent;
    }
    /**
     * Send a string message to a specific peer.
     */
    sendStringTo(peerId, label, data) {
        const channel = this.peers.get(peerId)?.dataChannels.get(label);
        if (!channel || channel.readyState !== 'open')
            return false;
        channel.send(data);
        return true;
    }
    /**
     * Send binary data to all peers on a data channel.
     */
    sendBinary(label, data) {
        let sent = false;
        const buffer = data instanceof ArrayBuffer
            ? data
            : (() => {
                const buf = new ArrayBuffer(data.byteLength);
                new Uint8Array(buf).set(data);
                return buf;
            })();
        for (const session of this.peers.values()) {
            const channel = session.dataChannels.get(label);
            if (channel?.readyState === 'open') {
                channel.send(buffer);
                sent = true;
            }
        }
        return sent;
    }
    /**
     * Send binary data to a specific peer.
     */
    sendBinaryTo(peerId, label, data) {
        const channel = this.peers.get(peerId)?.dataChannels.get(label);
        if (!channel || channel.readyState !== 'open')
            return false;
        if (data instanceof ArrayBuffer) {
            channel.send(data);
        }
        else {
            const buffer = new ArrayBuffer(data.byteLength);
            new Uint8Array(buffer).set(data);
            channel.send(buffer);
        }
        return true;
    }
    /**
     * Send JSON data to all peers on a data channel.
     */
    sendJSON(label, data) {
        return this.sendString(label, JSON.stringify(data));
    }
    /**
     * Send JSON data to a specific peer.
     */
    sendJSONTo(peerId, label, data) {
        return this.sendStringTo(peerId, label, JSON.stringify(data));
    }
    /**
     * Close a specific data channel on all peers.
     */
    closeDataChannel(label) {
        let closed = false;
        for (const session of this.peers.values()) {
            const channel = session.dataChannels.get(label);
            if (channel) {
                channel.close();
                session.dataChannels.delete(label);
                closed = true;
            }
        }
        return closed;
    }
    // =========================================================================
    // Media Controls
    // =========================================================================
    /**
     * Mute or unmute audio
     */
    muteAudio(muted) {
        if (this.localStream) {
            for (const track of this.localStream.getAudioTracks()) {
                track.enabled = !muted;
            }
        }
        this.isAudioMuted = muted;
    }
    /**
     * Mute or unmute video
     */
    muteVideo(muted) {
        if (this.localStream) {
            for (const track of this.localStream.getVideoTracks()) {
                track.enabled = !muted;
            }
        }
        this.isVideoMuted = muted;
    }
    // =========================================================================
    // Screen Sharing
    // =========================================================================
    /**
     * Start screen sharing, replacing the current video track.
     * @param options - Display media constraints
     */
    async startScreenShare(options = { video: true, audio: false }) {
        if (this.isScreenSharing || this.isDataOnly)
            return;
        const screenStream = await navigator.mediaDevices.getDisplayMedia(options);
        const screenTrack = screenStream.getVideoTracks()[0];
        if (!screenTrack) {
            throw new Error('Failed to get screen video track');
        }
        if (this.localStream) {
            const currentVideoTrack = this.localStream.getVideoTracks()[0];
            if (currentVideoTrack) {
                this.previousVideoTrack = currentVideoTrack;
                this.localStream.removeTrack(currentVideoTrack);
            }
            this.localStream.addTrack(screenTrack);
        }
        for (const session of this.peers.values()) {
            const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
            if (sender) {
                await sender.replaceTrack(screenTrack);
            }
            else {
                session.pc.addTrack(screenTrack, this.localStream);
            }
        }
        screenTrack.onended = () => {
            this.stopScreenShare();
        };
        this.isScreenSharing = true;
        this.callbacks.onScreenShareStart?.();
    }
    /**
     * Stop screen sharing and restore the previous video track.
     */
    async stopScreenShare() {
        if (!this.isScreenSharing)
            return;
        const screenTrack = this.localStream?.getVideoTracks()[0];
        if (screenTrack) {
            screenTrack.stop();
            this.localStream?.removeTrack(screenTrack);
        }
        if (this.previousVideoTrack && this.localStream) {
            this.localStream.addTrack(this.previousVideoTrack);
            for (const session of this.peers.values()) {
                const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
                if (sender) {
                    await sender.replaceTrack(this.previousVideoTrack);
                }
            }
        }
        this.previousVideoTrack = null;
        this.isScreenSharing = false;
        this.callbacks.onScreenShareStop?.();
    }
    // =========================================================================
    // Connection Stats
    // =========================================================================
    /**
     * Get raw stats for a peer connection.
     */
    async getRawStats(peerId) {
        const session = this.peers.get(peerId);
        if (!session)
            return null;
        return session.pc.getStats();
    }
    /**
     * Get raw stats for all peer connections.
     */
    async getAllRawStats() {
        const stats = new Map();
        for (const [peerId, session] of this.peers) {
            stats.set(peerId, await session.pc.getStats());
        }
        return stats;
    }
    /**
     * Get a normalized quality summary for a peer connection.
     */
    async getQualitySummary(peerId) {
        const session = this.peers.get(peerId);
        if (!session)
            return null;
        const stats = await session.pc.getStats();
        return this.parseStatsToSummary(stats, session);
    }
    /**
     * Get quality summaries for all peer connections.
     */
    async getAllQualitySummaries() {
        const summaries = new Map();
        for (const [peerId, session] of this.peers) {
            const stats = await session.pc.getStats();
            summaries.set(peerId, this.parseStatsToSummary(stats, session));
        }
        return summaries;
    }
    parseStatsToSummary(stats, session) {
        const summary = { timestamp: Date.now() };
        const now = Date.now();
        const prevStats = session.lastStats;
        const prevTime = session.lastStatsTime ?? now;
        const timeDelta = (now - prevTime) / 1000;
        const bitrate = {};
        stats.forEach((report) => {
            if (report.type === 'candidate-pair' && report.state === 'succeeded') {
                summary.rtt = report.currentRoundTripTime
                    ? report.currentRoundTripTime * 1000
                    : undefined;
                const localCandidateId = report.localCandidateId;
                const remoteCandidateId = report.remoteCandidateId;
                const localCandidate = this.getStatReport(stats, localCandidateId);
                const remoteCandidate = this.getStatReport(stats, remoteCandidateId);
                summary.candidatePair = {
                    localType: localCandidate?.candidateType,
                    remoteType: remoteCandidate?.candidateType,
                    protocol: localCandidate?.protocol,
                    usingRelay: localCandidate?.candidateType === 'relay' ||
                        remoteCandidate?.candidateType === 'relay',
                };
            }
            if (report.type === 'inbound-rtp' && report.kind === 'audio') {
                summary.jitter = report.jitter ? report.jitter * 1000 : undefined;
                if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
                    const total = report.packetsLost + report.packetsReceived;
                    if (total > 0) {
                        summary.packetLossPercent = (report.packetsLost / total) * 100;
                    }
                }
                if (prevStats && timeDelta > 0) {
                    const prev = this.findMatchingReport(prevStats, report.id);
                    if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
                        bitrate.audio = bitrate.audio ?? {};
                        bitrate.audio.inbound =
                            ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
                    }
                }
            }
            if (report.type === 'inbound-rtp' && report.kind === 'video') {
                summary.video = {
                    framesPerSecond: report.framesPerSecond,
                    framesDropped: report.framesDropped,
                    frameWidth: report.frameWidth,
                    frameHeight: report.frameHeight,
                };
                if (prevStats && timeDelta > 0) {
                    const prev = this.findMatchingReport(prevStats, report.id);
                    if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
                        bitrate.video = bitrate.video ?? {};
                        bitrate.video.inbound =
                            ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
                    }
                }
            }
            if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) {
                const prev = this.findMatchingReport(prevStats, report.id);
                if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) {
                    const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta;
                    if (report.kind === 'audio') {
                        bitrate.audio = bitrate.audio ?? {};
                        bitrate.audio.outbound = bps;
                    }
                    else if (report.kind === 'video') {
                        bitrate.video = bitrate.video ?? {};
                        bitrate.video.outbound = bps;
                    }
                }
            }
        });
        if (Object.keys(bitrate).length > 0) {
            summary.bitrate = bitrate;
        }
        session.lastStats = stats;
        session.lastStatsTime = now;
        return summary;
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    findMatchingReport(stats, id) {
        return this.getStatReport(stats, id);
    }
    // RTCStatsReport extends Map but bun-types may not expose .get() properly
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    getStatReport(stats, id) {
        // Use Map.prototype.get via cast
        const mapLike = stats;
        return mapLike.get(id);
    }
    // =========================================================================
    // Recording
    // =========================================================================
    /**
     * Start recording a stream.
     * @param streamId - 'local' for local stream, or a peer ID for remote stream
     * @param options - Recording options
     */
    startRecording(streamId, options) {
        const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId);
        if (!stream)
            return null;
        const mimeType = this.selectMimeType(stream, options?.mimeType);
        if (!mimeType)
            return null;
        const recorder = new MediaRecorder(stream, {
            mimeType,
            audioBitsPerSecond: options?.audioBitsPerSecond,
            videoBitsPerSecond: options?.videoBitsPerSecond,
        });
        const chunks = [];
        recorder.ondataavailable = (event) => {
            if (event.data.size > 0) {
                chunks.push(event.data);
            }
        };
        this.recordings.set(streamId, { recorder, chunks });
        recorder.start(1000);
        return {
            stop: () => new Promise((resolve) => {
                recorder.onstop = () => {
                    this.recordings.delete(streamId);
                    resolve(new Blob(chunks, { type: mimeType }));
                };
                recorder.stop();
            }),
            pause: () => recorder.pause(),
            resume: () => recorder.resume(),
            get state() {
                return recorder.state;
            },
        };
    }
    /**
     * Check if a stream is being recorded.
     */
    isRecording(streamId) {
        const recording = this.recordings.get(streamId);
        return recording?.recorder.state === 'recording';
    }
    /**
     * Stop all recordings and return the blobs.
     */
    async stopAllRecordings() {
        const blobs = new Map();
        const promises = [];
        for (const [streamId, { recorder, chunks }] of this.recordings) {
            const mimeType = recorder.mimeType;
            promises.push(new Promise((resolve) => {
                const timeout = setTimeout(() => {
                    console.warn(`Recording stop timeout for stream ${streamId}`);
                    resolve(); // Don't block other recordings
                }, 5000);
                recorder.onstop = () => {
                    clearTimeout(timeout);
                    blobs.set(streamId, new Blob(chunks, { type: mimeType }));
                    resolve();
                };
                recorder.stop();
            }));
        }
        await Promise.all(promises);
        this.recordings.clear();
        return blobs;
    }
    selectMimeType(stream, preferred) {
        if (preferred && MediaRecorder.isTypeSupported(preferred)) {
            return preferred;
        }
        const hasVideo = stream.getVideoTracks().length > 0;
        const hasAudio = stream.getAudioTracks().length > 0;
        const videoTypes = [
            'video/webm;codecs=vp9,opus',
            'video/webm;codecs=vp8,opus',
            'video/webm',
            'video/mp4',
        ];
        const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'];
        const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : [];
        for (const type of candidates) {
            if (MediaRecorder.isTypeSupported(type)) {
                return type;
            }
        }
        return null;
    }
    // =========================================================================
    // Cleanup
    // =========================================================================
    /**
     * End the call and disconnect from all peers
     */
    hangup() {
        this.intentionalClose = true;
        this.reconnectManager.cancel();
        this.clearConnectionTimeout();
        this.cleanupPeerSessions();
        if (this.isScreenSharing) {
            const screenTrack = this.localStream?.getVideoTracks()[0];
            screenTrack?.stop();
        }
        if (this.trackSource) {
            this.trackSource.stop();
            this.trackSource = null;
        }
        this.localStream = null;
        this.previousVideoTrack = null;
        if (this.ws) {
            this.ws.close();
            this.ws = null;
        }
        this.peerId = null;
        this.isScreenSharing = false;
        this.setState('idle', 'hangup');
        this.intentionalClose = false;
    }
    /**
     * Clean up all resources
     */
    dispose() {
        this.stopAllRecordings();
        this.hangup();
        this.reconnectManager.dispose();
    }
}
//# sourceMappingURL=webrtc-manager.js.map