// VOICE_SHARED v2 2026-07-15 — JEDNO źródło prawdy dla WSZYSTKICH gier.
// Kanoniczny plik: /srv/_shared/voice/voicechat.js. Każda gra ma w
// public/js/voicechat.js SYMLINK tutaj — edycja TEGO pliku = zmiana na żywo
// we wszystkich grach (serwery statyczne serwują z Cache-Control:no-cache,
// więc trafia też do przeglądarki gracza przy następnym wejściu). NIE edytuj
// kopii w grach — edytuj tylko ten plik. (Znacznik wersji: zostaw jako marker.)
//
// voicechat.js — PRZENOŚNY moduł czatu głosowego (WebRTC mesh do 8 graczy).
// Napisany tak, by wpiąć go do KAŻDEJ naszej gry sieciowej (aoe, wii, cs,
// diablo, dr, h3, s2, bc, w3d, ra, rbr) kilkoma linijkami. RBR = pierwsza gra
// referencyjna. Zero zewnętrznych/płatnych usług, zero SFU — czysty WebRTC.
//
// ─────────────────────────────────────────────────────────────────────────────
// JAK WPIĄĆ DO INNEJ GRY (kontrakt — przeczytaj przed kopiowaniem):
//
// Moduł potrzebuje od gry TYLKO transportu krótkich wiadomości sygnalizacyjnych
// (offer/answer/ICE) po ISTNIEJĄCYM kanale WS gry oraz listy graczy w pokoju.
// Nic więcej. Serwer gry musi umieć przekazać JEDEN typ wiadomości między
// graczami tego samego pokoju (patrz niżej „PASSTHROUGH NA SERWERZE").
//
//   const voice = createVoiceChat({
//     selfId: myPlayerId,                 // mój stały id w pokoju (number|string)
//     signal: {
//       // wyślij surowy payload do JEDNEGO gracza (serwer przekaże po id):
//       send(toId, payload) { net.sendVoiceSignal(toId, payload); },
//       // zarejestruj odbiór: cb(fromId, payload) na każdą przychodzącą sygnalizację:
//       onSignal(cb) { voiceSignalHandler = cb; },   // gra woła cb przy 'voice-signal'
//     },
//     // opcjonalnie: filtr, kto może gadać (np. pomiń boty):
//     isEligible(id) { return !String(id).startsWith('bot'); },
//     onStatus(s) { /* odśwież HUD: s.enabled, s.muted, s.transmitting, s.peers[] */ },
//     pttKey: 'KeyT',        // klawisz push-to-talk (domyślny)
//     openMic: false,        // false = push-to-talk (bezpieczniej), true = otwarty mik
//   });
//
//   // gdy zmienia się skład pokoju (join/leave/welcome) — podaj AKTUALNĄ listę
//   // id współgraczy (bez siebie; boty pomiń albo zostaw, isEligible odfiltruje):
//   voice.setPeers([12, 15, 18]);
//
//   // gdy z WS przyjdzie wiadomość 'voice-signal' {from, payload} — przekaż:
//   //   (albo zarejestruj przez signal.onSignal — jedno z dwóch wystarczy)
//   voice.handleSignal(from, payload);
//
//   // sterowanie (podłącz do UI/HUD/klawiszy):
//   await voice.enable();      // pyta o mikrofon; brak zgody => zwraca false, gra gra dalej
//   voice.mute(true/false);    // wycisz SIEBIE (przestań nadawać)
//   voice.muteOthers(true);    // wycisz WSZYSTKICH innych (nie odtwarzaj)
//   voice.setOpenMic(true);    // otwarty mik zamiast push-to-talk
//   voice.disable();           // zwyłącz mikrofon i zamknij połączenia
//
// PASSTHROUGH NA SERWERZE (raz na grę, ~6 linijek): serwer-relay musi przy
// wiadomości od gracza `{t:'voice-signal', to:<id>, payload:<dowolny obiekt>}`
// wysłać do gracza `to` (o ile jest w tym samym pokoju) `{t:'voice-signal',
// from:<nadawca>, payload}`. Zero interpretacji payloadu — czysty przekaz.
// W rbr zrobione w game/rooms.js (case 'voice-signal') + net.js
// (sendVoiceSignal + onEvent('voice-signal')). Serwer NIE dotyka mediów —
// głos leci P2P (DTLS-SRTP, szyfrowany) bezpośrednio między przeglądarkami.
//
// MODEL SIECI: mesh — każdy łączy się z każdym (do 7 połączeń przy 8 graczach).
// Wybór świadomy: pokoje mają twardy limit 8 (MAX_PLAYERS), a mesh nie wymaga
// serwera medialnego (SFU/TURN-relay = koszt + utrzymanie). Upstream przy 8:
// ~7×Opus (~24-40 kbps) ≈ 200-280 kbps — do udźwignięcia. Powyżej ~8 mesh
// przestaje się skalować, ale tyle graczy pokój i tak nie mieści.
//
// NEGOCJACJA: „perfect negotiation" (MDN) — kolizje ofert (glare) rozwiązuje
// grzeczność wg id (wyższy id = polite, ustępuje). Kandydaci ICE przed
// setRemoteDescription są kolejkowane. STUN publiczny Google do przebicia NAT.
//
// UWAGA — GRANICE (kolejny krok, nie w rdzeniu): brak TURN => symetryczny NAT
// po obu stronach może się nie połączyć (rzadkie w praktyce, ale realne);
// spatial/3D audio, wskaźnik „kto mówi" z detekcją głosu (VAD), regulacja
// głośności per gracz — do dołożenia później.
// ─────────────────────────────────────────────────────────────────────────────

const DEFAULT_ICE = [
  { urls: 'stun:stun.l.google.com:19302' },
  { urls: 'stun:stun1.l.google.com:19302' },
];
const MAX_DEFERRED_SIGNALS_PER_PEER = 32;

const noop = () => {};

export function createVoiceChat(config = {}) {
  const {
    selfId,
    signal = {},
    isEligible = () => true,
    onStatus = noop,
    onPeerVoice = noop,      // (id, up:bool) — dołączył/odłączył się głos gracza
    iceServers = DEFAULT_ICE,
    pttKey = 'KeyT',
    log = noop,
  } = config;

  const peers = new Map();          // id → { pc, audioEl, polite, makingOffer, ignoreOffer, pending:[], connected, hasAudio }
  let wanted = new Set();           // id współgraczy, z którymi CHCEMY mieć głos
  let deferredSignals = new Map();  // id → offer/answer/ICE odebrane przed zgodą na mikrofon
  let localStream = null;
  const state = {
    enabled: false,                 // mikrofon aktywny (zgoda + start)
    permission: 'prompt',           // 'prompt' | 'granted' | 'denied' | 'unavailable'
    muted: false,                   // wyciszenie SIEBIE
    othersMuted: false,             // wyciszenie INNYCH
    openMic: !!config.openMic,      // false = push-to-talk
    pttActive: false,               // trzymany klawisz PTT
    transmitting: false,            // czy realnie nadajemy w tej chwili
    peers: [],                      // [{id, connected, hasAudio}]
  };

  const eligible = (id) => id != null && id !== selfId && isEligible(id);

  function emit() {
    state.peers = [...peers.entries()].map(([id, p]) => ({ id, connected: p.connected, hasAudio: p.hasAudio }));
    try { onStatus({ ...state, peers: state.peers }); } catch { /* HUD nie może psuć głosu */ }
  }

  // czy w tej chwili mikrofon ma nadawać (zgoda × nie-mute × (otwarty mik | PTT))
  function shouldTransmit() {
    return state.enabled && !state.muted && (state.openMic || state.pttActive);
  }
  function applyTransmit() {
    const on = shouldTransmit();
    if (localStream) for (const t of localStream.getAudioTracks()) t.enabled = on;
    if (state.transmitting !== on) { state.transmitting = on; emit(); }
  }

  // ── pojedyncze połączenie z peerem (perfect negotiation) ────────────────────
  function makePeer(id) {
    if (peers.has(id) || !eligible(id)) return peers.get(id) || null;
    let pc;
    try { pc = new RTCPeerConnection({ iceServers }); }
    catch (e) { log('voicechat: RTCPeerConnection failed', e?.message || e); return null; }
    const audioEl = new Audio();
    audioEl.autoplay = true;
    audioEl.muted = state.othersMuted;
    const p = { pc, audioEl, polite: selfId > id, makingOffer: false, ignoreOffer: false, pending: [], connected: false, hasAudio: false };
    peers.set(id, p);

    if (localStream) for (const t of localStream.getAudioTracks()) { try { pc.addTrack(t, localStream); } catch { /* */ } }

    pc.onnegotiationneeded = async () => {
      try {
        p.makingOffer = true;
        await pc.setLocalDescription(await pc.createOffer());
        signal.send?.(id, { t: 'sdp', sdp: pc.localDescription });
      } catch (e) { log('voicechat: negotiation failed', e?.message || e); }
      finally { p.makingOffer = false; }
    };
    pc.onicecandidate = (ev) => { if (ev.candidate) signal.send?.(id, { t: 'ice', candidate: ev.candidate }); };
    pc.ontrack = (ev) => {
      const stream = ev.streams[0] || new MediaStream([ev.track]);
      audioEl.srcObject = stream;
      audioEl.muted = state.othersMuted;
      audioEl.play?.().catch(() => { /* autoplay: kliknięcie użytkownika odblokuje */ });
      if (!p.hasAudio) { p.hasAudio = true; try { onPeerVoice(id, true); } catch { /* */ } emit(); }
    };
    pc.onconnectionstatechange = () => {
      const st = pc.connectionState;
      p.connected = st === 'connected';
      if (st === 'failed') { try { pc.restartIce?.(); } catch { /* */ } }
      emit();
    };
    return p;
  }

  function dropPeer(id) {
    const p = peers.get(id);
    if (!p) return;
    peers.delete(id);
    try { p.pc.ontrack = p.pc.onicecandidate = p.pc.onnegotiationneeded = p.pc.onconnectionstatechange = null; } catch { /* */ }
    try { p.pc.close(); } catch { /* */ }
    try { p.audioEl.srcObject = null; p.audioEl.pause?.(); } catch { /* */ }
    if (p.hasAudio) { try { onPeerVoice(id, false); } catch { /* */ } }
    emit();
  }

  // synchronizuj żywe połączenia z listą „wanted" (gdy mamy mikrofon)
  function reconcile() {
    if (!state.enabled) return;
    for (const id of wanted) if (eligible(id) && !peers.has(id)) makePeer(id);
    for (const id of [...peers.keys()]) if (!wanted.has(id)) dropPeer(id);
  }

  function deferSignal(from, payload) {
    if (!wanted.has(from)) return;
    let queued = deferredSignals.get(from) || [];
    const isOffer = payload.t === 'sdp' && payload.sdp?.type === 'offer';
    if (isOffer) queued = [];
    if (queued.length >= MAX_DEFERRED_SIGNALS_PER_PEER) return;
    queued.push({ from, payload });
    deferredSignals.set(from, queued);
  }

  // ── odbiór sygnalizacji (offer/answer/ICE) ──────────────────────────────────
  async function handleSignal(from, payload) {
    if (!eligible(from) || !payload) return;
    if (!state.enabled) {
      // Drugi gracz może włączyć mikrofon później. Zachowaj jego pierwszą ofertę
      // i kandydatów, bo ich utrata może pozostawić oba peery w have-local-offer.
      deferSignal(from, payload);
      return;
    }
    let p = peers.get(from) || makePeer(from);
    if (!p) return;
    const { pc } = p;
    try {
      if (payload.t === 'sdp' && payload.sdp) {
        const desc = payload.sdp;
        const collision = desc.type === 'offer' && (p.makingOffer || pc.signalingState !== 'stable');
        p.ignoreOffer = !p.polite && collision;
        if (p.ignoreOffer) return;            // niegrzeczny ignoruje kolidującą ofertę
        await pc.setRemoteDescription(desc);
        for (const c of p.pending.splice(0)) { try { await pc.addIceCandidate(c); } catch { /* */ } }
        if (desc.type === 'offer') {
          await pc.setLocalDescription(await pc.createAnswer());
          signal.send?.(from, { t: 'sdp', sdp: pc.localDescription });
        }
      } else if (payload.t === 'ice' && payload.candidate) {
        if (pc.remoteDescription && pc.remoteDescription.type) {
          try { await pc.addIceCandidate(payload.candidate); } catch (e) { if (!p.ignoreOffer) log('voicechat: addIceCandidate', e?.message || e); }
        } else {
          p.pending.push(payload.candidate);  // kandydat przed remoteDescription → kolejka
        }
      }
    } catch (e) { log('voicechat: signal handling', e?.message || e); }
  }
  signal.onSignal?.(handleSignal);

  // ── PTT: klawisz nadawania (tylko przy zamkniętym mikrofonie / PTT) ──────────
  function onKeyDown(e) {
    if (e.code === pttKey && !e.repeat && !state.openMic) { state.pttActive = true; applyTransmit(); }
  }
  function onKeyUp(e) {
    if (e.code === pttKey && !state.openMic) { state.pttActive = false; applyTransmit(); }
  }
  let keysBound = false;
  function bindKeys(on) {
    if (on === keysBound || typeof window === 'undefined') return;
    keysBound = on;
    if (on) { addEventListener('keydown', onKeyDown); addEventListener('keyup', onKeyUp); addEventListener('blur', pttUp); }
    else { removeEventListener('keydown', onKeyDown); removeEventListener('keyup', onKeyUp); removeEventListener('blur', pttUp); }
  }
  function pttDown() { if (!state.openMic) { state.pttActive = true; applyTransmit(); } }
  function pttUp() { if (!state.openMic) { state.pttActive = false; applyTransmit(); } }

  // ── API ─────────────────────────────────────────────────────────────────────
  return {
    state,
    get transmitting() { return state.transmitting; },

    // ustaw aktualny skład pokoju (id współgraczy); moduł sam dopnie/rozłączy
    setPeers(ids) {
      wanted = new Set((ids || []).filter(eligible));
      for (const id of deferredSignals.keys()) if (!wanted.has(id)) deferredSignals.delete(id);
      reconcile();
      emit();
    },
    addPeer(id) { if (eligible(id)) { wanted.add(id); reconcile(); } },
    removePeer(id) {
      wanted.delete(id);
      deferredSignals.delete(id);
      dropPeer(id);
    },

    // wejście dla gry: przekaż surową sygnalizację z WS ('voice-signal')
    handleSignal,

    // włącz mikrofon: pyta o zgodę. Brak zgody => false, gra działa normalnie.
    async enable() {
      if (state.enabled) return true;
      if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
        state.permission = 'unavailable'; emit(); return false;
      }
      try {
        localStream = await navigator.mediaDevices.getUserMedia({
          audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false,
        });
      } catch (e) {
        state.permission = (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError')) ? 'denied' : 'unavailable';
        log('voicechat: getUserMedia denied/failed', e?.name || e);
        emit();
        return false;
      }
      state.permission = 'granted';
      state.enabled = true;
      bindKeys(true);
      applyTransmit();                         // ustaw track.enabled wg mute/PTT
      const queuedByPeer = deferredSignals;
      deferredSignals = new Map();
      for (const [from, queued] of queuedByPeer) {
        if (!wanted.has(from)) continue;
        for (const { payload } of queued) await handleSignal(from, payload);
      }
      reconcile();                             // połącz z obecnymi graczami
      emit();
      return true;
    },

    disable() {
      bindKeys(false);
      deferredSignals.clear();
      for (const id of [...peers.keys()]) dropPeer(id);
      if (localStream) { for (const t of localStream.getTracks()) { try { t.stop(); } catch { /* */ } } localStream = null; }
      state.enabled = false; state.transmitting = false; state.pttActive = false;
      emit();
    },

    mute(on) { state.muted = on == null ? !state.muted : !!on; applyTransmit(); emit(); return state.muted; },
    muteOthers(on) {
      state.othersMuted = on == null ? !state.othersMuted : !!on;
      for (const p of peers.values()) p.audioEl.muted = state.othersMuted;
      emit(); return state.othersMuted;
    },
    setOpenMic(on) {
      state.openMic = !!on;
      if (state.openMic) state.pttActive = false;
      applyTransmit(); emit();
    },
    pttDown, pttUp,

    destroy() { this.disable(); },
  };
}
