// voice-mesh.js — self-hosted WebRTC mesh voice client (Phase C of the my-sea
// invite/voice sprint, per disco-voice-mesh.pdf). Full-mesh: every peer holds
// a direct RTCPeerConnection to every other peer; the Django Channels
// `RoomVoiceConsumer` only relays signaling (offer/answer/ice). TURN creds
// come from /api/voice/turn-credentials/.
//
// Exposes `window.VoiceRoom` { join(roomId), leave(), toggleMute() } and the
// pure `window.tuneOpus(sdp)` helper (Jasmine-tested).
(function () {
    'use strict';

    // Munge an SDP offer/answer so the Opus payload runs in-band FEC (packet-
    // loss resilience), DTX (silence suppression), and a ~40 kbps cap — voice,
    // not music. Pure string transform so it's unit-testable without WebRTC.
    function tuneOpus(sdp) {
        if (!sdp || sdp.indexOf('opus') === -1) return sdp;
        var m = sdp.match(/a=rtpmap:(\d+)\s+opus\/48000/i);
        if (!m) return sdp;
        var pt = m[1];
        var fmtpRe = new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)');
        var extra = 'useinbandfec=1;usedtx=1;maxaveragebitrate=40000';
        if (fmtpRe.test(sdp)) {
            return sdp.replace(fmtpRe, function (line, params) {
                return 'a=fmtp:' + pt + ' ' + params + ';' + extra;
            });
        }
        // No existing fmtp line for opus — append one after its rtpmap.
        return sdp.replace(
            m[0], m[0] + '\r\na=fmtp:' + pt + ' ' + extra
        );
    }
    window.tuneOpus = tuneOpus;

    var ICE_FALLBACK = [{ urls: 'stun:stun.l.google.com:19302' }];

    function VoiceRoom() {
        this.ws = null;
        this.localStream = null;
        this.peers = {};        // peerId → RTCPeerConnection
        this.iceServers = ICE_FALLBACK;
        this.selfId = null;
        this.muted = false;
        // Subscriber for connection-state changes (voice-glow.js drives the
        // burger/voice-btn glow machine off it). Fired on join, every peer
        // add/drop, mute toggle, and teardown.
        this._onState = null;
        // Web Audio analysers per peer — power the volume-reactive equalizer
        // glow (item 5). One AnalyserNode taps each incoming peer stream;
        // `inputLevel()` returns the loudest current RMS across them.
        this._audioCtx = null;
        this._analysers = {};   // peerId → AnalyserNode
    }

    // ── Incoming-volume equalizer (drives voice-glow's --voice-level) ─────────

    VoiceRoom.prototype._ensureAudioCtx = function () {
        if (this._audioCtx) return this._audioCtx;
        var Ctx = window.AudioContext || window.webkitAudioContext;
        if (!Ctx) return null;
        try {
            this._audioCtx = new Ctx();
            if (this._audioCtx.resume) this._audioCtx.resume();
        } catch (e) { this._audioCtx = null; }
        return this._audioCtx;
    };

    VoiceRoom.prototype._attachAnalyser = function (peerId, stream) {
        try {
            var ctx = this._ensureAudioCtx();
            if (!ctx) return;
            var src = ctx.createMediaStreamSource(stream);
            var an = ctx.createAnalyser();
            an.fftSize = 256;
            src.connect(an);   // analyser only — playback stays on the <audio> el
            this._analysers[peerId] = an;
        } catch (e) { /* analyser is best-effort; glow falls back to a pulse */ }
    };

    // Loudest current RMS across all peer analysers, scaled to ~0..1 for the
    // glow (speech RMS is small, so ×3 then clamp). 0 when no analyser/audio.
    VoiceRoom.prototype.inputLevel = function () {
        var ids = Object.keys(this._analysers);
        var max = 0;
        for (var i = 0; i < ids.length; i++) {
            var an = this._analysers[ids[i]];
            if (!an) continue;
            var buf = new Uint8Array(an.fftSize);
            an.getByteTimeDomainData(buf);
            var sum = 0;
            for (var j = 0; j < buf.length; j++) {
                var v = (buf[j] - 128) / 128;
                sum += v * v;
            }
            var rms = Math.sqrt(sum / buf.length);
            if (rms > max) max = rms;
        }
        return Math.max(0, Math.min(1, max * 3));
    };

    // ── State-change notification (drives the glow machine) ───────────────────

    VoiceRoom.prototype.peerCount = function () {
        return Object.keys(this.peers).length;
    };

    VoiceRoom.prototype.setOnStateChange = function (cb) {
        this._onState = cb;
        this._notify();   // push current state immediately on subscribe
    };

    VoiceRoom.prototype._notify = function () {
        if (typeof this._onState !== 'function') return;
        this._onState({
            inCall: !!this.localStream,
            peerCount: this.peerCount(),
            muted: this.muted,
        });
    };

    VoiceRoom.prototype._fetchTurn = function () {
        return fetch('/api/voice/turn-credentials/', {
            headers: { 'Accept': 'application/json' },
            credentials: 'same-origin',
        }).then(function (r) {
            return r.ok ? r.json() : null;
        }).catch(function () { return null; });
    };

    VoiceRoom.prototype._mkPeer = function (peerId) {
        var self = this;
        var pc = new RTCPeerConnection({ iceServers: this.iceServers });
        this.localStream.getTracks().forEach(function (t) {
            pc.addTrack(t, self.localStream);
        });
        pc.onicecandidate = function (e) {
            if (e.candidate) {
                self._send({ type: 'ice', target: peerId, payload: e.candidate });
            }
        };
        pc.ontrack = function (e) {
            var el = document.getElementById('voice-audio-' + peerId);
            if (!el) {
                el = document.createElement('audio');
                el.id = 'voice-audio-' + peerId;
                el.autoplay = true;
                document.body.appendChild(el);
            }
            el.srcObject = e.streams[0];
            self._attachAnalyser(peerId, e.streams[0]);
        };
        this.peers[peerId] = pc;
        this._notify();   // a peer joined the mesh
        return pc;
    };

    VoiceRoom.prototype._offerTo = function (peerId) {
        var self = this;
        var pc = this._mkPeer(peerId);
        return pc.createOffer().then(function (offer) {
            offer.sdp = tuneOpus(offer.sdp);
            return pc.setLocalDescription(offer);
        }).then(function () {
            self._send({ type: 'offer', target: peerId, payload: pc.localDescription });
        });
    };

    VoiceRoom.prototype._onOffer = function (src, payload) {
        var self = this;
        var pc = this.peers[src] || this._mkPeer(src);
        return pc.setRemoteDescription(payload).then(function () {
            return pc.createAnswer();
        }).then(function (answer) {
            answer.sdp = tuneOpus(answer.sdp);
            return pc.setLocalDescription(answer);
        }).then(function () {
            self._send({ type: 'answer', target: src, payload: pc.localDescription });
        });
    };

    VoiceRoom.prototype._dropPeer = function (peerId) {
        var pc = this.peers[peerId];
        if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
        var el = document.getElementById('voice-audio-' + peerId);
        if (el && el.parentNode) el.parentNode.removeChild(el);
        delete this._analysers[peerId];
        this._notify();   // a peer left the mesh
    };

    VoiceRoom.prototype._send = function (obj) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(obj));
        }
    };

    VoiceRoom.prototype._onMessage = function (msg) {
        var self = this;
        switch (msg.type) {
            case 'welcome':
                this.selfId = msg.peer_id;
                this._send({ type: 'hello' });
                break;
            case 'present':            // an existing peer → we offer to them
                this._offerTo(msg.source);
                break;
            case 'hello':              // a newcomer arrived → await their offer
                break;
            case 'offer':
                this._onOffer(msg.source, msg.payload);
                break;
            case 'answer':
                var pc = this.peers[msg.source];
                if (pc) pc.setRemoteDescription(msg.payload);
                break;
            case 'ice':
                var p = this.peers[msg.source];
                if (p && msg.payload) {
                    p.addIceCandidate(msg.payload).catch(function () {});
                }
                break;
            case 'left':
                this._dropPeer(msg.source);
                break;
        }
    };

    // getUserMedia is only exposed in a secure context — HTTPS, or the
    // localhost/127.0.0.1 exemption. Over plain HTTP on a LAN IP (e.g. the dev
    // server reached from a phone at http://192.168.x.x:8000) iOS/Android leave
    // `navigator.mediaDevices` undefined, so the mic call would throw with no
    // permission prompt. Pulled into a method so it's a clean test seam.
    VoiceRoom.prototype._micSupported = function () {
        return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
    };

    VoiceRoom.prototype.join = function (roomId) {
        var self = this;
        // Fail fast + LOUD when the mic can't be reached — otherwise join would
        // fetch TURN creds (a confusing 200) and then silently reject deep in a
        // .then() with no prompt. Callers surface `err.code` to the user.
        if (!this._micSupported()) {
            var err = new Error(
                'Voice needs a secure context (HTTPS, or localhost) — your ' +
                'browser blocked the mic here. Open the HTTPS site to talk.');
            err.code = 'INSECURE_CONTEXT';
            return Promise.reject(err);
        }
        return this._fetchTurn().then(function (creds) {
            if (creds && creds.iceServers) self.iceServers = creds.iceServers;
            return navigator.mediaDevices.getUserMedia({
                audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
                video: false,
            });
        }).then(function (stream) {
            self.localStream = stream;
            self._applyMute();   // honor a mute toggled during the permission prompt
            var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
            self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
            self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
            self.ws.onclose = function () { self._teardown(); };
            self._notify();   // mic live (in-call), still alone
            return self;
        });
    };

    // Push the current `muted` flag onto the live audio tracks. Extracted so
    // BOTH toggleMute AND join (post-getUserMedia) call it: on desktop the
    // first join pops a mic-permission prompt, and a mute toggled while that
    // prompt is open used to be lost because the stream didn't exist yet —
    // the fresh tracks then came up enabled regardless. Re-applying after the
    // stream resolves makes the mute stick (Bug B, 2026-05-29).
    VoiceRoom.prototype._applyMute = function () {
        if (!this.localStream) return;
        var enabled = !this.muted;
        this.localStream.getAudioTracks().forEach(function (t) {
            t.enabled = enabled;
        });
    };

    VoiceRoom.prototype.toggleMute = function () {
        this.muted = !this.muted;
        this._applyMute();
        this._notify();
        return this.muted;
    };

    // Set the mute flag to an explicit value (vs. toggleMute's flip). Used to
    // re-apply a PERSISTED mute when the mesh auto-rejoins after in-sea nav (the
    // server-stored `User.voice_muted_at` says "still muted"). Safe before join:
    // `this.muted` is honoured by `join`'s post-getUserMedia `_applyMute`.
    VoiceRoom.prototype.setMuted = function (m) {
        this.muted = !!m;
        this._applyMute();   // no-op until localStream exists; join re-applies
        this._notify();
        return this.muted;
    };

    VoiceRoom.prototype._teardown = function () {
        Object.keys(this.peers).forEach(this._dropPeer.bind(this));
        if (this.localStream) {
            this.localStream.getTracks().forEach(function (t) { t.stop(); });
            this.localStream = null;
        }
        this._analysers = {};
        if (this._audioCtx) {
            try { this._audioCtx.close(); } catch (e) {}
            this._audioCtx = null;
        }
        this.muted = false;   // a rejoin starts unmuted
        this._notify();       // call ended — back to not-in-call
    };

    VoiceRoom.prototype.leave = function () {
        if (this.ws) { try { this.ws.close(); } catch (e) {} this.ws = null; }
        this._teardown();
    };

    // Singleton for the page — burger-btn.js drives join/toggle/leave.
    window.VoiceRoom = window.VoiceRoom || new VoiceRoom();
    // Announce readiness so late subscribers (voice-glow.js) bind reliably even
    // when this script is lazy-loaded by an AUTO-REJOIN — there's no voice-btn
    // click then to trigger their fallback poll (2026-05-30).
    try { document.dispatchEvent(new Event('voiceroom:ready')); } catch (e) {}
}());
