/**
 * VMO Realtime Voice Session — WebRTC client for OpenAI Realtime API.
 *
 * Replaces the legacy STT (Web Speech) → text → TTS pipeline with a single
 * full-duplex audio channel. The server-side VAD on OpenAI emits
 * `input_audio_buffer.speech_started` the instant the user starts talking,
 * which we use as the barge-in trigger: we send `response.cancel` and stop
 * the inbound audio sink so the AI shuts up mid-syllable.
 *
 * Public API:
 *   const s = new VMORealtimeSession({ context: 'jarvis', voice: 'ash' });
 *   await s.start();             // mints token, opens mic, starts WebRTC
 *   s.on('transcript.user',  ({text, final}) => ...);
 *   s.on('transcript.assistant', ({text, final}) => ...);
 *   s.on('status', (s) => ...);  // 'connecting' | 'live' | 'speaking' | 'listening' | 'ended' | 'error'
 *   s.on('error', (e) => ...);
 *   s.mute();   s.unmute();
 *   s.stop();
 *
 * Marker: VMO_REALTIME_CLIENT_v1
 */
(function (global) {
    'use strict';

    // Premium Live Voice keeps all provider REST/API traffic behind the VMO master.
    // The browser only talks to WordPress admin-ajax; the client/server side then
    // routes signaling to Kinsta master, which talks to OpenAI.

    function isSupported() {
        return !!(global.RTCPeerConnection &&
                  global.navigator && global.navigator.mediaDevices &&
                  global.navigator.mediaDevices.getUserMedia);
    }

    function ajaxBase() {
        if (global.vmo_ajax && global.vmo_ajax.ajaxurl) return global.vmo_ajax;
        if (global.vmoAjax && global.vmoAjax.ajaxurl)  return global.vmoAjax;
        return { ajaxurl: '/wp-admin/admin-ajax.php', nonce: '' };
    }

    function clientProxyConfig() {
        var cfg = global.vmoClientProxy || {};
        var ax = global.vmo_ajax || global.vmoAjax || {};
        var base = String(cfg.master_proxy_url || '').replace(/\/+$/, '');
        var apiBase = String(cfg.master_api_url || '').replace(/\/+$/, '');
        if (!apiBase && base) apiBase = base.replace(/\/proxy\/?$/i, '');
        var clientMode = !!(cfg.client_mode || cfg.hosted_client || cfg.enabled || cfg.direct_master_voice || ax.is_client_mode);
        var configured = !!(base && apiBase && cfg.license_key && cfg.installation_id);
        return {
            clientMode: clientMode,
            configured: configured,
            enabled: !!(clientMode && configured),
            direct_master_voice: !!(cfg.direct_master_voice || (clientMode && configured)),
            base: base,
            apiBase: apiBase,
            license: String(cfg.license_key || ''),
            installation: String(cfg.installation_id || ''),
            siteUrl: String(cfg.site_url || '')
        };
    }

    function makeRequestId(prefix) {
        try { if (global.crypto && global.crypto.randomUUID) return prefix + '_' + global.crypto.randomUUID(); } catch (_) {}
        return prefix + '_' + Date.now() + '_' + Math.random().toString(16).slice(2);
    }


    // VMO_REALTIME_SESSION_SANITIZER_v2
    // Keep Premium Live Voice compatible across Realtime schema changes and
    // prevent one legacy/unsupported field from killing the entire session.
    function clonePlain(obj) {
        try { return obj ? JSON.parse(JSON.stringify(obj)) : {}; } catch (_) { return {}; }
    }

    function sanitizeRealtimeSessionConfig(config, voice) {
        var cfg = clonePlain(config);
        if (!cfg || typeof cfg !== 'object') cfg = {};
        cfg.type = cfg.type || 'realtime';

        // Older internal builds used max_response_output_tokens. Current Realtime
        // sessions accept max_output_tokens. If the old key is present, convert it
        // and remove the legacy key before sending session.update.
        if (cfg.max_response_output_tokens !== undefined && cfg.max_output_tokens === undefined) {
            cfg.max_output_tokens = cfg.max_response_output_tokens;
        }
        try { delete cfg.max_response_output_tokens; } catch (_) {}

        if (cfg.max_output_tokens === undefined || cfg.max_output_tokens === null || cfg.max_output_tokens === '') {
            cfg.max_output_tokens = 4096;
        }

        cfg.audio = cfg.audio && typeof cfg.audio === 'object' ? cfg.audio : {};
        cfg.audio.input = cfg.audio.input && typeof cfg.audio.input === 'object' ? cfg.audio.input : {};
        cfg.audio.output = cfg.audio.output && typeof cfg.audio.output === 'object' ? cfg.audio.output : {};
        cfg.audio.output.voice = voice || cfg.audio.output.voice || 'ash';

        var td = cfg.audio.input.turn_detection;
        if (!td || typeof td !== 'object') td = { type: 'server_vad' };
        td.type = td.type || 'server_vad';
        td.create_response = true;
        td.interrupt_response = true;
        // Faster endpointing for Premium Live Voice. Default mode can tolerate
        // longer turns; Premium should answer almost immediately after the user
        // stops speaking.
        if (!td.silence_duration_ms || td.silence_duration_ms > 420) td.silence_duration_ms = 360;
        cfg.audio.input.turn_detection = td;

        return cfg;
    }


    function appendPremiumVoiceMemoryContext(sessionUpdate, memoryContext) {
        // VMO_PREMIUM_VOICE_PARITY_CONTEXT_v1
        // Keep Realtime/WebRTC Jarvis aligned with Default Chat Voice by injecting
        // the same recent Jarvis history the dashboard already loaded from the DB.
        // This prevents the Premium voice model from claiming it has no memory or
        // that switching voice modes changed its capabilities/personality.
        if (!sessionUpdate || typeof sessionUpdate !== 'object') sessionUpdate = {};
        var ctx = String(memoryContext || '').trim();
        if (!ctx) return sessionUpdate;
        if (ctx.length > 9000) ctx = ctx.slice(ctx.length - 9000);
        var base = String(sessionUpdate.instructions || '').trim();
        var continuityRules = [
            'PREMIUM LIVE VOICE CONTINUITY RULES:',
            '- You are the same Jarvis as Default Chat Voice. Premium mode changes only latency and voice quality; it must not change identity, memory, capabilities, or personality.',
            '- Use the provided recent Jarvis conversation context as authoritative continuity for this voice session.',
            '- Never claim that you do not retain or cannot access prior VMO/Jarvis conversations for privacy reasons when the supplied VMO context contains the relevant discussion.',
            '- If context is insufficient for a specific older detail, say what is missing and ask a focused follow-up; do not make a blanket memory/privacy disclaimer.',
            '- Conversation history, consultation state, business vision, KPI goals, tasks, and progress are persistent VMO business records and may be used to help the user continue work.'
        ].join('\n');
        sessionUpdate.instructions = (base ? base + '\n\n' : '') + continuityRules + '\n\nRECENT JARVIS CONTEXT FROM VMO DATABASE / CURRENT CHAT:\n' + ctx;
        return sessionUpdate;
    }

    function VMORealtimeSession(opts) {
        opts = opts || {};
        this.context = opts.context || 'jarvis';      // jarvis | consultation | receptionist
        this.voice   = opts.voice   || (global.vmoSelectedVoice || 'ash');
        // VMO_PREMIUM_VOICE_PARITY_CONTEXT_v1
        // Premium Live Voice must be the same Jarvis as Default Chat Voice.
        // The voice transport is different, but memory/context/personality must not reset.
        this.memoryContext = typeof opts.memoryContext === 'string' ? opts.memoryContext : '';
        this.recentMessages = Array.isArray(opts.recentMessages) ? opts.recentMessages : [];
        this.actionToken    = opts.action || 'vmo_realtime_session';
        this.sdpAction      = opts.sdpAction || 'vmo_realtime_sdp';
        this.toolAction     = opts.toolAction || 'vmo_realtime_tool';
        this.usageAction    = opts.usageAction || 'vmo_realtime_usage';
        this.audioSinkId    = opts.audioSinkId || 'vmo-realtime-audio';
        this.debug          = !!opts.debug;

        this.pc = null;
        this.dc = null;
        this.localStream = null;
        this.audioSink = null;
        this.session = null;            // server response { session_id, model, master_routed, ... }
        this.status = 'idle';
        this.muted = !!opts.muted;
        this.activeResponseId = null;
        this._listeners = {};
        this._userTextBuf = '';
        this._asstTextBuf = '';
        // ── Per-minute usage metering (zero-leak billing) ──────────────────
        this._meterTimer = null;        // setInterval handle for heartbeats
        this._meterStartTs = 0;         // ms timestamp when the session went live
        this._meterReporting = false;   // re-entrancy guard
        this._meterFailures = 0;        // fail-closed heartbeat guard
        this._authorizedUntilSeconds = 60; // server-paid usage window; minute 1 is prepaid at SDP
        this._meteringMode = opts.meteringMode || 'metered';
        this._unmetered = opts.unmetered === true || this._meteringMode === 'unmetered';
        this._usageFailClosed = opts.failClosedUsage !== false;
        this._onPageHide = null;        // bound pagehide handler for final report

        // Hard privacy/duplex guards. When End Chat is clicked, this flag prevents
        // any late WebRTC/data-channel event from speaking, listening, billing, or
        // reviving the session. The speech timing guards prevent Jarvis from
        // responding to short snippets of its own speaker audio leaking into the mic.
        this._stopped = false;
        this._remoteStream = null;
        this._speechStartedAt = 0;
        this._speechDuringAssistant = false;
        this._lastAssistantStartedAt = 0;
        this._responsePending = false;
        this._lastResponseCreateAt = 0;
        this._lastSpeechStoppedAt = 0;
        this._remoteAudioHeld = false;
        // VMO_PREMIUM_AUTORESPONSE_FIX_v1
        // Let the Realtime server respond automatically on VAD stop. The prior
        // client-only manual response path could leave Premium stuck on Listening
        // if a speech_stopped event was missed or delayed.
        this._autoResponseEnabled = true;
        this._vadAutoFallbackTimer = null;
        this._instanceId = 'vmo_rt_' + Date.now() + '_' + Math.random().toString(16).slice(2);
        this._hardStopBound = false;
    }

    VMORealtimeSession.isSupported = isSupported;

    VMORealtimeSession.prototype.on = function (evt, cb) {
        (this._listeners[evt] = this._listeners[evt] || []).push(cb);
        return this;
    };
    VMORealtimeSession.prototype.off = function (evt, cb) {
        var arr = this._listeners[evt]; if (!arr) return this;
        this._listeners[evt] = arr.filter(function (f) { return f !== cb; });
        return this;
    };
    VMORealtimeSession.prototype._emit = function (evt, payload) {
        var arr = this._listeners[evt]; if (!arr) return;
        for (var i = 0; i < arr.length; i++) {
            try { arr[i](payload); } catch (e) { if (this.debug) console.warn('[VMORealtime] listener error', e); }
        }
    };
    VMORealtimeSession.prototype._setStatus = function (s) {
        if (this.status === s) return;
        this.status = s;
        this._emit('status', s);
    };
    VMORealtimeSession.prototype._log = function () {
        if (!this.debug) return;
        try { console.log.apply(console, ['[VMORealtime]'].concat([].slice.call(arguments))); } catch (_) {}
    };

    // VMO_REALTIME_CANCEL_WARNING_RECOVERY_v1
    // OpenAI Realtime can return an error event when response.cancel is sent
    // while no assistant response is currently active. That is not a transport
    // failure; it simply means there was nothing to cancel. Treating it as fatal
    // makes Premium Live Voice fall back to the slower Web Speech + TTS path.
    VMORealtimeSession.prototype._isBenignRealtimeError = function (message, code, param) {
        var haystack = String(message || '') + ' ' + String(code || '') + ' ' + String(param || '');
        return /Cancellation failed/i.test(haystack) && /no active response/i.test(haystack);
    };

    // --- 1. Ask WordPress/master for a Premium Live Voice session -------------
    VMORealtimeSession.prototype._mintToken = function () {
        var self = this;
        var cfg = clientProxyConfig();
        // VMO_PREMIUM_DIRECT_MASTER_SESSION_v1
        // Hosted QuickLaunch clients should not wait for their fresh Vultr
        // origin/admin-ajax path before Premium Live Voice can start. Mint the
        // lightweight Realtime config directly from the Kinsta master.
        if (cfg.clientMode && !cfg.enabled) {
            throw new Error('Premium Live Voice is in client mode but is not linked to the VMO master. Missing master proxy URL, license key, or installation ID. The client must not start local Realtime without master billing.');
        }
        if (cfg.enabled && cfg.apiBase) {
            return fetch(cfg.apiBase + '/realtime/session', {
                method: 'POST',
                mode: 'cors',
                credentials: 'omit',
                headers: {
                    'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'X-VMO-License': cfg.license,
                    'X-VMO-Installation': cfg.installation,
                    'X-VMO-Operation': 'realtime_audio_min',
                    'X-VMO-Request-ID': makeRequestId('rt_session_direct'),
                    'OpenAI-Safety-Identifier': 'vmo_' + String(cfg.installation || 'client').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48)
                },
                body: JSON.stringify({ context: self.context, voice: self.voice, site_url: cfg.siteUrl })
            }).then(function (r) {
                return r.text().then(function (t) {
                    var j = null;
                    try { j = t ? JSON.parse(t) : null; } catch (_) {}
                    if (!r.ok || !j || j.success === false) {
                        var msg = (j && j.data && j.data.message) || (j && j.message) || t || ('HTTP ' + r.status);
                        throw new Error('Direct master Realtime session failed: ' + r.status + ' ' + String(msg).slice(0, 400));
                    }
                    var data = j.data || j;
                    if (!data.session_id) throw new Error('Direct master Realtime session returned no session_id');
                    if (!data.model) data.model = 'gpt-realtime-mini';
                    return data;
                });
            });
        }

        var ax = ajaxBase();
        var body = new URLSearchParams();
        body.set('action', this.actionToken);
        body.set('security', ax.nonce || '');
        body.set('nonce',    ax.nonce || '');
        body.set('context',  this.context);
        body.set('voice',    this.voice);

        return fetch(ax.ajaxurl, {
            method: 'POST',
            credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
            body: body.toString(),
        }).then(function (r) {
            return r.text().then(function (t) {
                var j = null;
                try { j = t ? JSON.parse(t) : null; } catch (_) {}
                if (!r.ok) {
                    var msg = (j && j.data && j.data.message) || (j && j.message) || t || ('HTTP ' + r.status);
                    throw new Error('Realtime token endpoint failed: ' + r.status + ' ' + msg);
                }
                return j;
            });
        }).then(function (j) {
            if (!j || !j.success || !j.data || !j.data.session_id) {
                throw new Error((j && j.data && j.data.message) || 'Failed to create VMO Realtime session');
            }
            if (!j.data.model) j.data.model = 'gpt-realtime-mini';
            return j.data;
        });
    };

    // --- 1b. Exchange the SDP offer through WordPress/master, never directly
    //         from a Vultr client/browser to OpenAI. This preserves the required
    //         flow: client → Kinsta master → APIs → Kinsta master → client.
    VMORealtimeSession.prototype._exchangeSdpViaMaster = function (offerSdp) {
        var self = this;
        var ax = ajaxBase();
        var body = new URLSearchParams();
        body.set('action', this.sdpAction);
        body.set('security', ax.nonce || '');
        body.set('nonce',    ax.nonce || '');
        body.set('session_id', (this.session && this.session.session_id) || '');
        body.set('context',  this.context);
        body.set('voice',    this.voice);
        body.set('model',    (this.session && this.session.model) || '');
        body.set('offer_sdp', offerSdp || '');

        function exchangeViaLocalAjax() {
            return fetch(ax.ajaxurl, {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
                body: body.toString(),
            }).then(function (r) {
                return r.text().then(function (t) {
                    var j = null;
                    try { j = t ? JSON.parse(t) : null; } catch (_) {}
                    if (!r.ok) {
                        var msg = (j && j.data && j.data.message) || (j && j.message) || t || ('HTTP ' + r.status);
                        throw new Error('Realtime SDP proxy failed: ' + r.status + ' ' + msg);
                    }
                    if (!j || !j.success || !j.data || !j.data.answer_sdp) {
                        throw new Error((j && j.data && j.data.message) || 'Realtime SDP proxy returned no answer SDP');
                    }
                    return j.data;
                });
            });
        }

        function exchangeViaDirectMasterProxy(previousError) {
            var cfg = clientProxyConfig();
            if (!cfg.enabled || !cfg.apiBase) throw previousError;
            var sessionConfig = sanitizeRealtimeSessionConfig((self.session && self.session.session_update) || {}, self.voice);
            sessionConfig.model = (self.session && self.session.model) || sessionConfig.model || 'gpt-realtime-mini';
            // VMO_PREMIUM_DIRECT_MASTER_SDP_JSON_v1
            // Send JSON to the master; the master builds the OpenAI multipart SDP
            // request server-side. This avoids browser CORS/multipart parsing issues
            // and removes the slow local admin-ajax 502 fallback path.
            return fetch(cfg.apiBase + '/realtime/sdp', {
                method: 'POST',
                mode: 'cors',
                credentials: 'omit',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                    'X-VMO-License': cfg.license,
                    'X-VMO-Installation': cfg.installation,
                    'X-VMO-Operation': 'realtime_audio_min',
                    'X-VMO-Request-ID': makeRequestId('rt_sdp_direct'),
                    'OpenAI-Safety-Identifier': 'vmo_' + String(cfg.installation || 'client').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48)
                },
                body: JSON.stringify({
                    offer_sdp: offerSdp || '',
                    session: sessionConfig,
                    session_id: self.session && self.session.session_id,
                    model: sessionConfig.model,
                    voice: self.voice,
                    context: self.context
                })
            }).then(function (r) {
                return r.text().then(function (t) {
                    var j = null;
                    try { j = t ? JSON.parse(t) : null; } catch (_) {}
                    var data = j && (j.data || j);
                    if (!r.ok || !data || !data.answer_sdp || String(data.answer_sdp).trim().indexOf('v=0') !== 0) {
                        var msg = (j && (j.message || (j.data && j.data.message))) || t || r.statusText || '';
                        throw new Error('Direct master Realtime SDP failed: ' + r.status + ' ' + String(msg).slice(0, 500));
                    }
                    data.session_id = data.session_id || (self.session && self.session.session_id);
                    data.model = data.model || (self.session && self.session.model) || 'gpt-realtime-mini';
                    data.master_routed = true;
                    data.direct_master_proxy = true;
                    return data;
                });
            });
        }

        var directCfg = clientProxyConfig();
        if (directCfg.clientMode && !directCfg.enabled) {
            throw new Error('Premium Live Voice is in client mode but cannot reach the VMO master voice broker. Check vmo_master_proxy_url, vmo_license_key, and vmo_installation_id.');
        }
        if (directCfg.enabled) {
            return exchangeViaDirectMasterProxy(new Error('Direct master Realtime proxy unavailable'));
        }

        return exchangeViaLocalAjax().catch(function (err) {
            var msg = String(err && err.message || err || '').toLowerCase();
            var shouldBypassLocal = msg.indexOf('502') !== -1 || msg.indexOf('503') !== -1 || msg.indexOf('bad gateway') !== -1 || msg.indexOf('cloudflare') !== -1 || msg.indexOf('failed to fetch') !== -1 || msg.indexOf('connection') !== -1;
            if (!shouldBypassLocal) throw err;
            console.warn('[VMORealtime] local client SDP proxy failed; trying direct Kinsta master proxy', err && err.message ? err.message : err);
            return exchangeViaDirectMasterProxy(err);
        });
    };

    // --- 2. Open mic and ensure remote audio sink ----------------------------
    VMORealtimeSession.prototype._openMic = function () {
        return global.navigator.mediaDevices.getUserMedia({
            audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
            video: false,
        });
    };

    VMORealtimeSession.prototype._ensureSink = function () {
        var sink = global.document.getElementById(this.audioSinkId);
        if (!sink) {
            sink = global.document.createElement('audio');
            sink.id = this.audioSinkId;
            sink.autoplay = true;
            sink.playsInline = true;
            sink.setAttribute('playsinline', '');
            sink.style.display = 'none';
            global.document.body.appendChild(sink);
        }
        return sink;
    };


    VMORealtimeSession.prototype._registerInstance = function () {
        try {
            global.__vmoRealtimeSessions = global.__vmoRealtimeSessions || [];
            if (global.__vmoRealtimeSessions.indexOf(this) === -1) global.__vmoRealtimeSessions.push(this);
        } catch (_) {}
        if (this._hardStopBound) return;
        var self = this;
        this._hardStopHandler = function () { try { self.stop(); } catch (_) {} };
        this._storageStopHandler = function (e) {
            if (e && e.key === 'vmoJarvisVoiceHardStopAt' && e.newValue) {
                try { self.stop(); } catch (_) {}
            }
        };
        try { global.addEventListener('vmo:voice-force-stop', this._hardStopHandler); } catch (_) {}
        try { global.addEventListener('pagehide', this._hardStopHandler); } catch (_) {}
        try { global.addEventListener('storage', this._storageStopHandler); } catch (_) {}
        this._hardStopBound = true;
    };

    VMORealtimeSession.prototype._unregisterInstance = function () {
        try {
            if (global.__vmoRealtimeSessions) {
                global.__vmoRealtimeSessions = global.__vmoRealtimeSessions.filter(function (s) { return s !== this; }, this);
            }
        } catch (_) {}
        if (this._hardStopBound) {
            try { global.removeEventListener('vmo:voice-force-stop', this._hardStopHandler); } catch (_) {}
            try { global.removeEventListener('pagehide', this._hardStopHandler); } catch (_) {}
            try { global.removeEventListener('storage', this._storageStopHandler); } catch (_) {}
            this._hardStopBound = false;
        }
    };

    VMORealtimeSession.stopAll = function () {
        try {
            (global.__vmoRealtimeSessions || []).slice().forEach(function (s) {
                try { if (s && typeof s.stop === 'function') s.stop(); } catch (_) {}
            });
        } catch (_) {}
        try { if (VMORealtimeSession.shared && typeof VMORealtimeSession.shared.stop === 'function') VMORealtimeSession.shared.stop(); } catch (_) {}
        VMORealtimeSession.shared = null;
        try {
            var sink = global.document && global.document.getElementById('vmo-realtime-audio');
            if (sink) {
                try { sink.pause(); } catch (_) {}
                try { sink.muted = true; } catch (_) {}
                try {
                    var src = sink.srcObject;
                    if (src && src.getTracks) src.getTracks().forEach(function (t) { try { t.stop(); } catch (_) {} });
                    sink.srcObject = null;
                } catch (_) {}
                try { sink.removeAttribute('src'); } catch (_) {}
                try { if (sink.parentNode) sink.parentNode.removeChild(sink); } catch (_) {}
            }
        } catch (_) {}
    };

    // --- 3. Public start() — full lifecycle ----------------------------------
    VMORealtimeSession.prototype.start = function () {
        var self = this;
        if (!isSupported()) {
            var err = new Error('WebRTC or getUserMedia not supported in this browser');
            this._setStatus('error'); this._emit('error', err);
            return Promise.reject(err);
        }
        if (this.status === 'connecting' || this.status === 'live' ||
            this.status === 'listening' || this.status === 'speaking') {
            return Promise.resolve(this);
        }
        // Guarantee only one Premium Live Voice transport exists in this tab.
        // A second Jarvis session is the fastest way to create two voices and
        // a privacy problem, so kill any stale/shared session before starting.
        try {
            if (VMORealtimeSession.shared && VMORealtimeSession.shared !== this && typeof VMORealtimeSession.shared.stop === 'function') {
                VMORealtimeSession.shared.stop();
            }
            VMORealtimeSession.shared = this;
            this._registerInstance();
        } catch (_) {}
        this._stopped = false;
        this._responsePending = false;
        this.activeResponseId = null;
        this._remoteAudioHeld = false;
        this._setStatus('connecting');

        return this._mintToken().then(function (sess) {
            self.session = sess;
            self._applyMeteringPayload(sess);
            return self._openMic();
        }).then(function (stream) {
            self.localStream = stream;
            if (self.muted) {
                try { stream.getAudioTracks().forEach(function (t) { t.enabled = false; }); } catch (_) {}
            }
            self.audioSink = self._ensureSink();

            var pc = new global.RTCPeerConnection();
            self.pc = pc;

            // Remote audio
            pc.ontrack = function (ev) {
                if (self._stopped) return;
                var remote = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([ev.track]);
                self._remoteStream = remote;
                try { self.audioSink.srcObject = remote; } catch (_) {
                    try { self.audioSink.srcObject = new MediaStream([ev.track]); self._remoteStream = self.audioSink.srcObject; } catch(__) {}
                }
                self._resumeRemoteAudio();
            };

            // Local mic
            stream.getAudioTracks().forEach(function (t) { pc.addTrack(t, stream); });

            // Data channel for events (must be created before offer)
            var dc = pc.createDataChannel('oai-events');
            self.dc = dc;
            dc.onopen = function () {
                self._log('data channel open');
                self._setStatus('live');
                // Begin per-minute usage metering. Minute 1 was prepaid by the
                // server at mint; the heartbeat charges each additional minute.
                self._startMeter();
                // Apply the user-selected voice and keep only one intentional wait:
                // a short thinking pause so Jarvis does not cut off the user.
                var sessionUpdate = sanitizeRealtimeSessionConfig((self.session && self.session.session_update) || {
                    type: 'realtime',
                    max_output_tokens: 4096,
                    audio: {
                        output: { voice: self.voice },
                        input: {
                            turn_detection: {
                                type: 'server_vad',
                                threshold: 0.22,
                                prefix_padding_ms: 220,
                                // Premium Live Voice should allow faster, more sensitive barge-in.
                                silence_duration_ms: 360,
                            },
                        },
                    },
                }, self.voice);
                // Keep Premium Live Voice aligned with Default Chat Voice's memory/context.
                sessionUpdate = appendPremiumVoiceMemoryContext(sessionUpdate, self.memoryContext);
                // Premium Live Voice must feel instant. Let server VAD auto-create
                // the assistant response, then keep a short manual fallback only if
                // the auto response does not begin.
                try {
                    self._autoResponseEnabled = !!(sessionUpdate && sessionUpdate.audio && sessionUpdate.audio.input && sessionUpdate.audio.input.turn_detection && sessionUpdate.audio.input.turn_detection.create_response);
                } catch (_) { self._autoResponseEnabled = true; }
                self._send({ type: 'session.update', session: sessionUpdate });
                self._emit('ready', { sessionId: self.session && self.session.session_id, model: self.session && self.session.model });
            };
            dc.onmessage = function (ev) {
                if (self._stopped) return;
                try { self._handleEvent(JSON.parse(ev.data)); }
                catch (e) { self._log('event parse error', e, ev.data); }
            };
            dc.onerror = function (e) { if (!self._stopped) self._log('dc error', e); };
            dc.onclose = function () { if (!self._stopped) self._log('dc closed'); };

            // Offer/answer signaling is proxied through WordPress/master so the
            // browser/Vultr client never makes provider REST calls directly.
            return pc.createOffer().then(function (offer) {
                return pc.setLocalDescription(offer).then(function () { return offer; });
            }).then(function (offer) {
                self._log('SDP offer created; exchanging through VMO master proxy', { model: self.session && self.session.model, sessionId: self.session && self.session.session_id });
                return self._exchangeSdpViaMaster(offer.sdp);
            }).then(function (answerPayload) {
                var answerSdp = (typeof answerPayload === 'string') ? answerPayload : (answerPayload && answerPayload.answer_sdp) || '';
                if (answerPayload && answerPayload.authorized_until_seconds) {
                    self._authorizedUntilSeconds = Math.max(60, parseInt(answerPayload.authorized_until_seconds, 10) || 60);
                }
                self._applyMeteringPayload(answerPayload);
                if (!answerSdp) { throw new Error('Realtime SDP proxy returned an empty answer SDP'); }
                return pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
            });
        }).then(function () {
            return self;
        }).catch(function (err) {
            if (self._stopped) {
                self._teardown();
                return Promise.reject(err);
            }
            console.warn('[VMORealtime] start failed', err);
            self._log('start failed', err);
            self._setStatus('error');
            self._emit('error', err);
            self._teardown();
            throw err;
        });
    };

    // --- 4. Send arbitrary event over the data channel -----------------------
    VMORealtimeSession.prototype._send = function (obj) {
        if (!this.dc || this.dc.readyState !== 'open') return false;
        try { this.dc.send(JSON.stringify(obj)); return true; }
        catch (e) { this._log('send failed', e); return false; }
    };
    VMORealtimeSession.prototype._applyMeteringPayload = function (payload) {
        payload = payload || {};
        if (payload.metering_mode) this._meteringMode = String(payload.metering_mode || 'metered');
        if (payload.unmetered === true || this._meteringMode === 'unmetered') {
            this._unmetered = true;
            this._meteringMode = 'unmetered';
            this._usageFailClosed = false;
        }
        if (payload.authorized_until_seconds) {
            this._authorizedUntilSeconds = Math.max(60, parseInt(payload.authorized_until_seconds, 10) || 60);
        }
    };
    VMORealtimeSession.prototype._resumeRemoteAudio = function () {
        if (this._stopped || !this.audioSink) return;
        // After barge-in/cancel, keep the old remote audio detached until a fresh
        // response.created event clears _remoteAudioHeld. This prevents the canceled
        // response from continuing underneath the new one.
        if (this._remoteAudioHeld) return;
        try {
            if (!this.audioSink.srcObject && this._remoteStream) this.audioSink.srcObject = this._remoteStream;
            this.audioSink.muted = !!this.muted;
            var p = this.audioSink.play();
            if (p && p.catch) p.catch(function (e) { console.warn('[VMORealtime] remote audio play blocked', e); });
        } catch (e) {
            console.warn('[VMORealtime] remote audio play failed', e);
        }
    };

    VMORealtimeSession.prototype._dropRemoteAudioBriefly = function (ms) {
        var self = this;
        if (!this.audioSink) return;
        try {
            this.audioSink.pause();
            this.audioSink.srcObject = null;
        } catch (_) {}
        if (ms && ms > 0) {
            setTimeout(function () {
                if (!self._stopped && self.status !== 'ended' && self.status !== 'error') {
                    // Restore the stream only. Playback will resume when the next
                    // assistant response starts, preventing buffered self-echo.
                    try { if (self.audioSink && !self.audioSink.srcObject && self._remoteStream) self.audioSink.srcObject = self._remoteStream; } catch (_) {}
                }
            }, ms);
        }
    };

    VMORealtimeSession.prototype._holdRemoteAudioUntilNextResponse = function () {
        this._remoteAudioHeld = true;
        if (!this.audioSink) return;
        try { this.audioSink.pause(); } catch (_) {}
        try { this.audioSink.muted = true; } catch (_) {}
        try { this.audioSink.srcObject = null; } catch (_) {}
    };

    VMORealtimeSession.prototype._createSingleResponse = function (reason) {
        if (this._stopped) return false;
        var now = Date.now();
        // Deduplicate noisy/duplicate speech_stopped events from server VAD.
        if (this._responsePending || (now - this._lastResponseCreateAt) < 180) {
            return false;
        }
        // There must never be two Jarvis responses speaking at once. If a prior
        // response is still active, cancel it before asking for the next one.
        if (this.activeResponseId || this.status === 'speaking') {
            try { this._send({ type: 'response.cancel' }); } catch (_) {}
            this.activeResponseId = null;
            this._holdRemoteAudioUntilNextResponse();
        }
        this._responsePending = true;
        this._lastResponseCreateAt = now;
        this._asstTextBuf = '';
        this._setStatus('thinking');
        return this._send({ type: 'response.create', response: { modalities: ['audio', 'text'] } });
    };


    // VMO_REALTIME_TEXT_TURN_BRIDGE_v1
    // Sends a transcript-derived text turn into the active Realtime session.
    // This is a Premium-mode recovery path when browser/WebRTC transport is live
    // but server-side audio VAD misses the speech_stopped event. The assistant
    // still responds through Realtime audio, preserving Premium voice behavior.
    VMORealtimeSession.prototype.sendTextTurn = function (text, reason) {
        if (this._stopped || !this.dc || this.dc.readyState !== 'open') return false;
        var clean = String(text || '').trim();
        if (!clean) return false;
        this._userTextBuf = clean;
        this._emit('transcript.user', { text: clean, final: true, via: 'text_turn_bridge' });
        var ok = this._send({
            type: 'conversation.item.create',
            item: {
                type: 'message',
                role: 'user',
                content: [{ type: 'input_text', text: clean }]
            }
        });
        if (!ok) return false;
        this._createSingleResponse(reason || 'text-turn-bridge');
        return true;
    };

    VMORealtimeSession.prototype.cancelResponse = function (opts) {
        opts = opts || {};
        if (this._stopped) return;
        try { this._send({ type: 'response.cancel' }); } catch (_) {}
        this.activeResponseId = null;
        this._responsePending = false;
        // For real spoken barge-in, do NOT clear the input buffer. The user's
        // interruption audio is the new turn. We only clear on manual hard stop,
        // short echo, or explicit buffer discard.
        if (opts.clearBuffer !== false) {
            try { this._send({ type: 'input_audio_buffer.clear' }); } catch (_) {}
        }
        this._holdRemoteAudioUntilNextResponse();
        this._setStatus('listening');
        this._emit('barge_in');
    };

    // --- 5. Handle inbound Realtime events -----------------------------------
    VMORealtimeSession.prototype._handleEvent = function (ev) {
        if (this._stopped) return;
        var t = ev && ev.type;
        if (!t) return;
        this._log('evt', t);

        switch (t) {
            // User started speaking. If it happens while Jarvis is speaking,
            // immediately cancel output. A short burst after cancellation is usually
            // speaker echo; a longer utterance is a real user barge-in and should get
            // a response after speech_stopped.
            case 'input_audio_buffer.speech_started':
                this._speechStartedAt = Date.now();
                // Treat pending responses as assistant-owned too. This covers the
                // "hello ... can you hear me?" race where response #1 has been
                // requested but audio has not started yet. The second user turn must
                // replace, not stack on top of, the first response.
                this._speechDuringAssistant = (this.status === 'speaking' || this.status === 'thinking' || !!this.activeResponseId || !!this._responsePending);
                if (this._speechDuringAssistant) {
                    // Real barge-in: cut Jarvis output immediately but preserve the
                    // user's current audio buffer so the interruption becomes the next turn.
                    this.cancelResponse({ clearBuffer: false });
                }
                break;

            case 'input_audio_buffer.speech_stopped':
                var nowStopped = Date.now();
                if (nowStopped - this._lastSpeechStoppedAt < 220) {
                    // Duplicate VAD stop; do not spawn another Jarvis.
                    break;
                }
                this._lastSpeechStoppedAt = nowStopped;
                var dur = this._speechStartedAt ? (nowStopped - this._speechStartedAt) : 0;
                var duringAssistant = !!this._speechDuringAssistant;
                this._speechStartedAt = 0;
                this._speechDuringAssistant = false;
                // With create_response=true, the server should answer automatically; this block is only an echo/de-dup guard.
                // Ignore short mic bursts during/just after assistant audio; they are
                // usually Jarvis hearing itself. Longer speech is user barge-in.
                if (duringAssistant && dur > 0 && dur < 420) {
                    this._send({ type: 'input_audio_buffer.clear' });
                    this._setStatus('listening');
                    break;
                }
                if (!duringAssistant && dur > 0 && dur < 140) {
                    this._send({ type: 'input_audio_buffer.clear' });
                    this._setStatus('listening');
                    break;
                }
                if (this._autoResponseEnabled) {
                    this._setStatus('thinking');
                    try { if (this._vadAutoFallbackTimer) clearTimeout(this._vadAutoFallbackTimer); } catch (_) {}
                    var selfRef = this;
                    this._vadAutoFallbackTimer = setTimeout(function () {
                        if (selfRef._stopped) return;
                        if (selfRef.activeResponseId || selfRef._responsePending || selfRef.status === 'speaking') return;
                        selfRef._createSingleResponse('vad-auto-fallback');
                    }, 1100);
                    break;
                }
                this._createSingleResponse(duringAssistant ? 'barge-in' : 'turn-complete');
                break;

            case 'response.created':
                if (this._stopped) break;
                try { if (this._vadAutoFallbackTimer) { clearTimeout(this._vadAutoFallbackTimer); this._vadAutoFallbackTimer = null; } } catch (_) {}
                // A new response is the only time the held remote stream may speak again.
                this._remoteAudioHeld = false;
                this._responsePending = false;
                this.activeResponseId = (ev.response && ev.response.id) || null;
                this._lastAssistantStartedAt = Date.now();
                this._resumeRemoteAudio();
                this._setStatus('speaking');
                this._asstTextBuf = '';
                break;

            case 'response.audio_transcript.delta':          // legacy beta
            case 'response.output_audio_transcript.delta':   // GA
            case 'response.output_text.delta':               // GA text delta fallback
                if (ev.delta) {
                    this._asstTextBuf += ev.delta;
                    this._emit('transcript.assistant', { text: this._asstTextBuf, delta: ev.delta, final: false });
                }
                break;

            case 'response.audio_transcript.done':           // legacy beta
            case 'response.output_audio_transcript.done':    // GA
            case 'response.output_text.done':                // GA text fallback
                if (ev.transcript && !this._asstTextBuf) this._asstTextBuf = ev.transcript;
                if (ev.text && !this._asstTextBuf) this._asstTextBuf = ev.text;
                this._emit('transcript.assistant', { text: this._asstTextBuf, delta: '', final: true });
                this._asstTextBuf = '';
                break;

            case 'conversation.item.input_audio_transcription.delta':
                if (ev.delta) {
                    this._userTextBuf += ev.delta;
                    this._emit('transcript.user', { text: this._userTextBuf, delta: ev.delta, final: false });
                }
                break;

            case 'conversation.item.input_audio_transcription.completed':
                if (ev.transcript) {
                    this._userTextBuf = '';
                    this._emit('transcript.user', { text: ev.transcript, delta: '', final: true });
                }
                break;

            case 'response.done':
                try { if (this._vadAutoFallbackTimer) { clearTimeout(this._vadAutoFallbackTimer); this._vadAutoFallbackTimer = null; } } catch (_) {}
                this.activeResponseId = null;
                this._responsePending = false;
                if (!this._stopped) {
                    this._setStatus('live');
                    this._emit('response_done', ev.response || null);
                }
                break;

            // Function calling — the model wants to invoke a tool.
            case 'response.function_call_arguments.done':
                this._dispatchToolCall(ev);
                break;

            case 'error':
                if (this._stopped) break;
                var errMsg = (ev.error && ev.error.message) || 'Realtime error';
                var errParam = (ev.error && ev.error.param) || '';
                var errCode = (ev.error && ev.error.code) || '';
                // VMO_REALTIME_UNKNOWN_PARAM_RECOVERY_v2
                // If a stale server/browser cache sends an old Realtime field, remove
                // it and re-apply the sanitized config once instead of dropping Premium.
                if (/Unknown parameter/i.test(errMsg) && /session\.max_response_output_tokens/i.test(errMsg + ' ' + errParam) && !this._retriedLegacyMaxTokenField) {
                    this._retriedLegacyMaxTokenField = true;
                    try {
                        var cleanCfg = sanitizeRealtimeSessionConfig((this.session && this.session.session_update) || {}, this.voice);
                        this._send({ type: 'session.update', session: cleanCfg });
                        this._setStatus('live');
                        break;
                    } catch (_) {}
                }

                // response.cancel is intentionally used for barge-in and hard-stop.
                // If the server says there is no active response, keep Premium alive;
                // otherwise one harmless cancel warning knocks the user into slow fallback.
                if (this._isBenignRealtimeError(errMsg, errCode, errParam)) {
                    this._log('benign realtime cancel warning ignored; keeping Premium Live Voice connected', ev.error || ev);
                    this.activeResponseId = null;
                    this._responsePending = false;
                    if (!this._stopped && this.status !== 'speaking') this._setStatus('listening');
                    break;
                }

                // A missing dedicated realtime transcription model must not kill
                // Premium Live Voice. The assistant can still listen/reason/speak
                // in the voice-agent session; only optional user-caption streaming
                // is unavailable. Treat this as a warning, not a fatal fallback.
                if (/gpt-realtime-whisper|input_audio_transcription|transcription/i.test(errMsg + ' ' + errParam + ' ' + errCode)) {
                    this._log('optional realtime transcription unavailable; keeping Premium Live Voice connected', ev.error || ev);
                    this._emit('transcript.unavailable', { message: errMsg, error: ev.error || ev });
                    break;
                }
                this._emit('error', new Error(errMsg));
                break;
        }
    };

    // --- 6. Tool-call bridge → server → back into conversation ---------------
    VMORealtimeSession.prototype._dispatchToolCall = function (ev) {
        var self = this;
        var name = ev.name;
        var callId = ev.call_id;
        var args = {};
        try { args = JSON.parse(ev.arguments || '{}'); } catch (_) {}

        var ax = ajaxBase();
        var body = new URLSearchParams();
        body.set('action', this.toolAction);
        body.set('security', ax.nonce || '');
        body.set('nonce',    ax.nonce || '');
        body.set('tool', name);
        body.set('args', JSON.stringify(args));

        fetch(ax.ajaxurl, {
            method: 'POST', credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
            body: body.toString(),
        }).then(function (r) { return r.json(); }).then(function (j) {
            var output = (j && j.success && j.data && j.data.result !== undefined)
                ? j.data.result
                : { error: (j && j.data && j.data.message) || 'tool failed' };
            self._send({
                type: 'conversation.item.create',
                item: {
                    type: 'function_call_output',
                    call_id: callId,
                    output: JSON.stringify(output),
                },
            });
            self._createSingleResponse('tool-result');
        }).catch(function (err) {
            self._log('tool bridge error', err);
            self._send({
                type: 'conversation.item.create',
                item: {
                    type: 'function_call_output',
                    call_id: callId,
                    output: JSON.stringify({ error: String(err && err.message || err) }),
                },
            });
            self._createSingleResponse('tool-error');
        });
    };

    // --- 6b. Per-minute usage metering (zero-leak billing) -------------------
    // Reports elapsed connected time to the server so each minute beyond the
    // prepaid first minute is charged against the user's VMO Credit. The server
    // returns halt=true when the balance can no longer fund another minute, at
    // which point we end the call.
    VMORealtimeSession.prototype._startMeter = function () {
        var self = this;
        if (this._meterTimer) return;
        this._meterStartTs = Date.now();
        this._meterFailures = 0;
        this._authorizedUntilSeconds = Math.max(60, parseInt((this.session && this.session.authorized_until_seconds) || this._authorizedUntilSeconds, 10) || 60);
        this._applyMeteringPayload(this.session || {});
        // Heartbeat every 15s and prebill 30s ahead. Metered clients fail closed
        // before unpaid provider usage. Master/dev sessions still heartbeat for
        // observability, but they are explicitly unmetered and do not self-stop on
        // temporary heartbeat problems.
        this._reportUsage(false);
        this._meterTimer = global.setInterval(function () {
            var elapsed = Math.max(0, Math.round((Date.now() - self._meterStartTs) / 1000));
            if (!self._unmetered && elapsed >= Math.max(1, self._authorizedUntilSeconds - 3)) {
                self._log('authorized window exhausted; stopping premium voice', { elapsed: elapsed, authorizedUntil: self._authorizedUntilSeconds });
                self._emit('credits_exhausted', { sessionId: (self.session && self.session.session_id) || '', reason: 'authorized_window_exhausted' });
                self.stop();
                return;
            }
            self._reportUsage(false);
        }, 15000);
        // Best-effort final report if the tab is closed without stop().
        this._onPageHide = function () { self._reportUsage(true); };
        try {
            global.addEventListener('pagehide', this._onPageHide);
            global.addEventListener('beforeunload', this._onPageHide);
        } catch (_) {}
    };

    VMORealtimeSession.prototype._reportUsage = function (isFinal) {
        if (!this._meterStartTs) return;
        if (this._meterReporting && !isFinal) return;
        this._meterReporting = true;
        var self = this;
        var sessionId = (this.session && this.session.session_id) || '';
        if (!sessionId) { this._meterReporting = false; return; }
        var elapsed = Math.max(0, Math.round((Date.now() - this._meterStartTs) / 1000));
        var prebill = isFinal ? elapsed : (elapsed + 30);
        var ax = ajaxBase();
        var params = 'action=' + encodeURIComponent(this.usageAction) +
                     '&security=' + encodeURIComponent(ax.nonce || '') +
                     '&nonce=' + encodeURIComponent(ax.nonce || '') +
                     '&session_id=' + encodeURIComponent(sessionId) +
                     '&elapsed_seconds=' + encodeURIComponent(elapsed) +
                     '&prebill_seconds=' + encodeURIComponent(prebill);
        // On unload, sendBeacon survives the navigation; otherwise use fetch so
        // we can react to a halt signal.
        if (isFinal && global.navigator && global.navigator.sendBeacon) {
            try {
                global.navigator.sendBeacon(ax.ajaxurl,
                    new global.Blob([params], { type: 'application/x-www-form-urlencoded' }));
            } catch (_) {}
            this._meterReporting = false;
            return;
        }
        fetch(ax.ajaxurl, {
            method: 'POST', credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
            body: params,
        }).then(function (r) {
            return r.json().catch(function () { return null; }).then(function (j) {
                if (!r.ok) {
                    var msg = (j && j.data && j.data.message) || (j && j.message) || ('HTTP ' + r.status);
                    throw new Error(msg);
                }
                if (j && j.success && j.data) self._applyMeteringPayload(j.data);
                return j;
            });
        }).then(function (j) {
            self._meterReporting = false;
            self._meterFailures = 0;
            if (j && j.success && j.data && j.data.authorized_until_seconds) {
                self._authorizedUntilSeconds = Math.max(self._authorizedUntilSeconds || 60, parseInt(j.data.authorized_until_seconds, 10) || 60);
            }
            if (j && j.success && j.data && j.data.halt) {
                self._emit('credits_exhausted', { sessionId: sessionId, reason: j.data.code || 'insufficient_credit' });
                self.stop();
            }
        }).catch(function (err) {
            self._meterReporting = false;
            self._meterFailures += 1;
            self._log('usage heartbeat failed', err && err.message ? err.message : err);
            // Fail closed. Because we prebill 30s ahead, two missed 15s heartbeats
            // means we stop before we enter unpaid provider usage.
            if (!isFinal && self._usageFailClosed && !self._unmetered && self._meterFailures >= 2) {
                self._emit('credits_exhausted', { sessionId: sessionId, reason: 'billing_heartbeat_failed' });
                self.stop();
            }
        });
    };

    VMORealtimeSession.prototype._stopMeter = function () {
        if (this._meterTimer) { try { global.clearInterval(this._meterTimer); } catch (_) {} this._meterTimer = null; }
        if (this._onPageHide) {
            try {
                global.removeEventListener('pagehide', this._onPageHide);
                global.removeEventListener('beforeunload', this._onPageHide);
            } catch (_) {}
        }
        // Final reconciliation report for the minutes consumed this session.
        if (this._meterStartTs) { this._reportUsage(true); }
        this._meterStartTs = 0;
        this._onPageHide = null;
    };

    // --- 7. Mute / unmute ----------------------------------------------------
    VMORealtimeSession.prototype.mute = function () {
        this.muted = true;
        if (this.localStream) this.localStream.getAudioTracks().forEach(function (t) { t.enabled = false; });
    };
    VMORealtimeSession.prototype.unmute = function () {
        this.muted = false;
        if (this._stopped) return;
        if (this.localStream) this.localStream.getAudioTracks().forEach(function (t) { t.enabled = true; });
    };

    // --- 8. Stop / teardown --------------------------------------------------
    VMORealtimeSession.prototype.stop = function () {
        this._stopped = true;
        // Tell the provider to cut any active output and discard buffered mic audio
        // before closing the transport. This is the privacy guarantee behind End Chat.
        try { this._send({ type: 'response.cancel' }); } catch (_) {}
        try { this._send({ type: 'input_audio_buffer.clear' }); } catch (_) {}
        this._teardown();
        this._setStatus('ended');
    };
    VMORealtimeSession.prototype._teardown = function () {
        this._stopped = true;
        // Stop and finalize usage metering BEFORE we tear down the connection so
        // the elapsed-time report reflects the full session.
        try { this._stopMeter(); } catch (_) {}
        try {
            if (this.dc) {
                this.dc.onmessage = null; this.dc.onerror = null; this.dc.onclose = null;
                if (this.dc.readyState === 'open' || this.dc.readyState === 'connecting') this.dc.close();
            }
        } catch (_) {}
        try {
            if (this.pc) {
                this.pc.ontrack = null;
                try { this.pc.getSenders().forEach(function (s) { try { if (s.track) s.track.stop(); } catch (_) {} }); } catch (_) {}
                try { this.pc.getReceivers().forEach(function (r) { try { if (r.track) r.track.stop(); } catch (_) {} }); } catch (_) {}
                this.pc.close();
            }
        } catch (_) {}
        try {
            if (this.localStream) this.localStream.getTracks().forEach(function (t) { try { t.enabled = false; t.stop(); } catch (_) {} });
        } catch (_) {}
        try {
            if (this._remoteStream) this._remoteStream.getTracks().forEach(function (t) { try { t.stop(); } catch (_) {} });
        } catch (_) {}
        try {
            if (this.audioSink) {
                this.audioSink.pause();
                this.audioSink.muted = true;
                var src = this.audioSink.srcObject;
                if (src && src.getTracks) src.getTracks().forEach(function (t) { try { t.stop(); } catch (_) {} });
                this.audioSink.srcObject = null;
                this.audioSink.removeAttribute('src');
                if (this.audioSink.load) this.audioSink.load();
                // Remove the sink entirely so no closed-tab/stale element can keep playing.
                if (this.audioSink.parentNode) this.audioSink.parentNode.removeChild(this.audioSink);
            }
        } catch (_) {}
        this.dc = null; this.pc = null; this.localStream = null; this._remoteStream = null; this.audioSink = null;
        this.activeResponseId = null;
        this._responsePending = false;
        this._remoteAudioHeld = false;
        this._speechStartedAt = 0;
        this._speechDuringAssistant = false;
        try { this._unregisterInstance(); } catch (_) {}
        if (VMORealtimeSession.shared === this) VMORealtimeSession.shared = null;
    };

    // Allow other modules to find and reuse an active session (e.g. the
    // consultation flow piggy-backing on the Jarvis panel).
    VMORealtimeSession.shared = null;

    global.VMORealtimeSession = VMORealtimeSession;
    try { console.log('✅ VMO Realtime WebRTC client ready'); } catch (_) {}
})(window);
