/**
 * ClassroomRTC — WebRTC mesh manager for audio + screen sharing.
 *
 * Topology: full mesh (each peer ↔ each peer).
 * Audio: Opus ~30 Kbit/s per connection.
 * Screen: teacher → renegotiation adds video track to existing connections.
 *
 * Usage:
 *   const rtc = new ClassroomRTC({
 *     sessionId, userId,
 *     onSignal(targetId, payload)   — POST /signal
 *     onScreenStream(stream|null)   — remote screen arrived / stopped
 *     onMicActive(userId, active)   — visual mic indicator
 *   });
 *   await rtc.startAudio();
 *   await rtc.connectTo([uid1, uid2]);  // new joiner sends offers
 *   rtc.handleSignal(fromUid, payload); // called from SSE
 *   rtc.addPeerForScreenShare(uid);     // if teacher, offer screen to new joiner
 *   rtc.removePeer(uid);
 *   rtc.destroy();
 */
class ClassroomRTC {
  constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
    this._sid          = sessionId;
    this._uid          = userId;
    this._onSignal     = onSignal;       // fn(targetUid, payload)
    this._onScreen     = onScreenStream; // fn(stream | null)
    this._onMicActive  = onMicActive;    // fn(uid, bool) — optional
    this._onMicLevel   = onMicLevel;     // fn(uid, level 0-100) — optional, fires every tick
    this._vadThreshold = vadThreshold ?? 12;

    this._peers        = new Map();      // uid → PeerState
    this._localStream  = null;           // mic audio
    this._screenStream = null;           // outbound screen (teacher)
    this._muted        = false;

    this._vadTimers    = new Map();      // uid → {ctx, timer} for VAD
    this._iceServers   = null;           // populated lazily before first peer

    // Pre-fetch ICE servers in background so first peer creation has them.
    this._fetchIceServers().then(s => { this._iceServers = s; }).catch(() => {});
  }

  /* ── Audio ────────────────────────────────────────────��──────────────── */

  async startAudio() {
    try {
      // Make sure ICE servers are loaded before we accept incoming connections
      if (!this._iceServers) this._iceServers = await this._fetchIceServers();
      this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
      this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
      this._startVAD(this._uid, this._localStream);
      console.log('[RTC] startAudio OK, tracks:', this._localStream.getAudioTracks().map(t => `${t.label} enabled=${t.enabled}`));
      return true;
    } catch (e) {
      console.warn('[RTC] startAudio FAILED', e);
      return false;
    }
  }

  /* ── Voice Activity Detection ────────────────────────────────────────── */

  _startVAD(uid, stream) {
    if (!this._onMicActive || !window.AudioContext) return;
    this._stopVAD(uid);
    try {
      const ctx = new AudioContext();
      const src = ctx.createMediaStreamSource(stream);
      const analyser = ctx.createAnalyser();
      analyser.fftSize = 256;
      analyser.smoothingTimeConstant = 0.4;
      src.connect(analyser);
      const buf = new Uint8Array(analyser.frequencyBinCount);
      let speaking = false;
      const THRESHOLD = this._vadThreshold;
      const timer = setInterval(() => {
        analyser.getByteFrequencyData(buf);
        const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
        const level = Math.min(100, Math.round((avg / 64) * 100));
        const now = avg > THRESHOLD;
        if (now !== speaking) {
          speaking = now;
          try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
        }
        try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
      }, 120);
      this._vadTimers.set(uid, { ctx, timer });
    } catch {}
  }

  _stopVAD(uid) {
    const vad = this._vadTimers.get(uid);
    if (!vad) return;
    clearInterval(vad.timer);
    try { vad.ctx.close(); } catch {}
    this._vadTimers.delete(uid);
  }

  _stopAllVAD() {
    for (const uid of [...this._vadTimers.keys()]) this._stopVAD(uid);
  }

  stopAudio() {
    this._stopVAD(this._uid);
    if (!this._localStream) return;
    this._localStream.getTracks().forEach(t => t.stop());
    this._localStream = null;
  }

  toggleMute() {
    this._muted = !this._muted;
    if (this._localStream) {
      this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
    }
    return this._muted;
  }

  isMuted() { return this._muted; }

  forceMute() {
    this._muted = true;
    if (this._localStream) {
      this._localStream.getAudioTracks().forEach(t => { t.enabled = false; });
    }
  }

  /* ── Connections ─────────────────────────────────────────────────────── */

  /** New joiner calls this to send offers to all existing participants. */
  async connectTo(userIds) {
    for (const uid of userIds) {
      if (uid !== this._uid) await this._sendOffer(uid);
    }
  }

  /** Called when a new peer joins (existing participants call this to handle
   *  teacher screen-share renegotiation if needed). */
  async addPeerForScreenShare(uid) {
    if (!this._screenStream) return;
    // New person joined while we are sharing screen — renegotiate
    const peer = this._getPeer(uid);
    if (peer) {
      const vt = this._screenStream.getVideoTracks()[0];
      if (vt && !peer.screenSender) {
        peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
        // onnegotiationneeded will fire and renegotiate automatically
      }
    }
  }

  removePeer(uid) {
    const peer = this._peers.get(uid);
    if (!peer) return;
    this._stopVAD(uid);
    if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
    try { peer.pc.close(); } catch {}
    this._peers.delete(uid);
  }

  /**
   * Called after SSE reconnect: re-connect peers whose ICE connection failed.
   * Peers that are still connected are left alone.
   * @param {number[]} currentUserIds — list of user ids currently in session (excluding self)
   */
  async recoverPeers(currentUserIds) {
    // Close and remove peers that left during the SSE gap
    for (const uid of [...this._peers.keys()]) {
      if (!currentUserIds.includes(uid)) this.removePeer(uid);
    }

    // Re-offer peers with failed/closed ICE
    for (const uid of currentUserIds) {
      if (uid === this._uid) continue;
      const peer = this._peers.get(uid);
      if (!peer) {
        // Completely new peer (joined while SSE was down) — we act as initiator
        await this._sendOffer(uid);
      } else {
        const state = peer.pc.iceConnectionState;
        if (state === 'failed' || state === 'closed' || state === 'disconnected') {
          this.removePeer(uid);
          await this._sendOffer(uid);
        }
        // else: 'connected' or 'completed' → leave intact
      }
    }
  }

  /* ── Signaling ───────────────────────────────────────────────────────── */

  async handleSignal(fromUid, payload) {
    const { kind } = payload;
    console.log(`[RTC] signal ← ${kind} from uid=${fromUid}`, 'myUid=', this._uid);
    try {
      if (kind === 'offer')          await this._handleOffer(fromUid, payload);
      else if (kind === 'answer')    await this._handleAnswer(fromUid, payload);
      else if (kind === 'candidate') await this._handleCandidate(fromUid, payload);
    } catch (e) {
      console.warn('[RTC] handleSignal error', kind, e);
    }
  }

  async _handleOffer(fromUid, payload) {
    const peer = this._getOrCreate(fromUid);

    // Perfect Negotiation: resolve offer collision (both peers sending offers simultaneously).
    // Polite peer (higher uid) yields — rolls back its own offer and handles the incoming one.
    // Impolite peer (lower uid) ignores the collision offer and waits for the answer to its own.
    const polite = this._uid > fromUid;
    const collision = peer.pc.signalingState !== 'stable' || peer.negotiating;
    if (collision) {
      if (!polite) return;
      // Polite: explicit rollback for older browsers; modern Chrome handles it implicitly
      await peer.pc.setLocalDescription({ type: 'rollback' }).catch(() => {});
    }

    await peer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp });
    peer.remoteSet = true;
    await this._flushCandidates(peer);

    // Add own audio track if not already present
    if (this._localStream) {
      const existing = peer.pc.getSenders().map(s => s.track);
      this._localStream.getAudioTracks().forEach(t => {
        if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
      });
    }

    const answer = await peer.pc.createAnswer();
    await peer.pc.setLocalDescription(answer);
    this._onSignal(fromUid, { kind: 'answer', sdp: answer.sdp });
  }

  async _handleAnswer(fromUid, payload) {
    const peer = this._peers.get(fromUid);
    if (!peer) return;
    await peer.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp });
    peer.remoteSet = true;
    await this._flushCandidates(peer);
  }

  async _handleCandidate(fromUid, payload) {
    const peer = this._peers.get(fromUid);
    if (!peer) return;
    if (peer.remoteSet) {
      try { await peer.pc.addIceCandidate(payload.candidate); } catch {}
    } else {
      peer.pendingCandidates.push(payload.candidate);
    }
  }

  async _flushCandidates(peer) {
    for (const c of peer.pendingCandidates) {
      try { await peer.pc.addIceCandidate(c); } catch {}
    }
    peer.pendingCandidates = [];
  }

  async _sendOffer(targetUid) {
    const peer = this._getOrCreate(targetUid);
    console.log(`[RTC] _sendOffer → uid=${targetUid} localStream=${!!this._localStream}`);
    if (this._localStream) {
      const existing = peer.pc.getSenders().map(s => s.track);
      this._localStream.getAudioTracks().forEach(t => {
        if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
      });
    }
    if (this._screenStream) {
      const vt = this._screenStream.getVideoTracks()[0];
      if (vt && !peer.screenSender) {
        peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
      }
    }
    const offer = await peer.pc.createOffer();
    await peer.pc.setLocalDescription(offer);
    this._onSignal(targetUid, { kind: 'offer', sdp: offer.sdp });
  }

  _getOrCreate(uid) {
    if (this._peers.has(uid)) return this._peers.get(uid);
    return this._createPeer(uid);
  }

  _getPeer(uid) { return this._peers.get(uid) || null; }

  // Cached promise for ICE servers (fetched once per page load).
  // Server returns STUN + optional TURN from env (TURN_URL/USER/PASS).
  static _iceServersPromise = null;
  _fetchIceServers() {
    if (!ClassroomRTC._iceServersPromise) {
      ClassroomRTC._iceServersPromise = fetch('/api/ice-servers', {
        headers: { Authorization: 'Bearer ' + (localStorage.getItem('ls_token') || '') },
      })
        .then(r => r.ok ? r.json() : null)
        .then(d => d?.iceServers || [
          { urls: 'stun:stun.l.google.com:19302' },
          { urls: 'stun:stun1.l.google.com:19302' },
        ])
        .catch(() => [
          { urls: 'stun:stun.l.google.com:19302' },
          { urls: 'stun:stun1.l.google.com:19302' },
        ]);
    }
    return ClassroomRTC._iceServersPromise;
  }

  _createPeer(uid) {
    // Use cached server-side ICE config if loaded; otherwise fall back to STUN-only synchronously.
    // (RTCPeerConnection constructor needs config at create-time, so we use whatever is ready.)
    const ICE = { iceServers: this._iceServers || [
      { urls: 'stun:stun.l.google.com:19302' },
      { urls: 'stun:stun1.l.google.com:19302' },
    ]};

    const pc = new RTCPeerConnection(ICE);
    const peer = { pc, audioEl: null, screenSender: null, remoteSet: false, pendingCandidates: [], negotiating: false };
    this._peers.set(uid, peer);

    pc.onicecandidate = e => {
      if (e.candidate) this._onSignal(uid, { kind: 'candidate', candidate: e.candidate.toJSON() });
    };

    pc.oniceconnectionstatechange = () => {
      console.log(`[RTC] ICE uid=${uid} → ${pc.iceConnectionState}`);
    };
    pc.onconnectionstatechange = () => {
      console.log(`[RTC] conn uid=${uid} → ${pc.connectionState}`);
    };

    /* Renegotiation: fires when tracks are added/removed (e.g. screen share) */
    pc.onnegotiationneeded = async () => {
      if (peer.negotiating || !peer.remoteSet || pc.signalingState !== 'stable') return;
      peer.negotiating = true;
      try {
        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);
        this._onSignal(uid, { kind: 'offer', sdp: offer.sdp });
      } catch {}
      finally { peer.negotiating = false; }
    };

    pc.ontrack = e => {
      const track  = e.track;
      const stream = e.streams[0] || new MediaStream([track]);
      console.log(`[RTC] ontrack uid=${uid} kind=${track.kind} readyState=${track.readyState} streams=${e.streams.length}`);

      if (track.kind === 'audio') {
        if (!peer.audioEl) {
          peer.audioEl = document.createElement('audio');
          peer.audioEl.autoplay = true;
          peer.audioEl.style.display = 'none';
          document.body.appendChild(peer.audioEl);
        }
        peer.audioEl.srcObject = stream;
        peer.audioEl.play()
          .then(() => console.log(`[RTC] audio play() OK uid=${uid}`))
          .catch(err => console.warn(`[RTC] audio play() BLOCKED uid=${uid}`, err));
        this._startVAD(uid, stream);
      } else if (track.kind === 'video') {
        if (this._onScreen) this._onScreen(stream);
        track.onended = () => { if (this._onScreen) this._onScreen(null); };
      }
    };

    return peer;
  }

  /* ── Screen sharing (teacher) ───────────────────────���─────────────────── */

  async startScreenShare(constraints = {}) {
    try {
      this._screenStream = await navigator.mediaDevices.getDisplayMedia({
        video: { cursor: 'always', ...constraints.video },
        audio: constraints.audio ?? false,
      });
    } catch { return null; }

    const vt = this._screenStream.getVideoTracks()[0];
    vt.onended = () => this.stopScreenShare();

    // Add to all existing peer connections (onnegotiationneeded will renegotiate)
    for (const [, peer] of this._peers) {
      if (!peer.screenSender) {
        peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
      }
    }

    return this._screenStream;
  }

  async stopScreenShare() {
    if (!this._screenStream) return;
    this._screenStream.getTracks().forEach(t => t.stop());
    this._screenStream = null;

    for (const [, peer] of this._peers) {
      if (peer.screenSender) {
        try { peer.pc.removeTrack(peer.screenSender); } catch {}
        peer.screenSender = null;
      }
    }
  }

  /** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
  async useExistingScreenStream(stream) {
    if (this._screenStream) {
      this._screenStream.getTracks().forEach(t => t.stop());
      this._screenStream = null;
    }
    this._screenStream = stream;
    const vt = this._screenStream.getVideoTracks()[0];
    if (!vt) return stream;
    for (const [, peer] of this._peers) {
      if (!peer.screenSender) {
        peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
      }
    }
    return stream;
  }

  isSharing() { return !!this._screenStream; }

  /* ── Cleanup ─────────────────────────────────────────────────────────── */

  destroy() {
    this._stopAllVAD();
    for (const uid of [...this._peers.keys()]) this.removePeer(uid);
    this.stopAudio();
    if (this._screenStream) {
      this._screenStream.getTracks().forEach(t => t.stop());
      this._screenStream = null;
    }
  }
}

if (typeof module !== 'undefined') module.exports = ClassroomRTC;
