import assert from 'node:assert/strict';
import { test } from 'node:test';

import { BrowserInputSink } from '../public/js/input.js';
import { createVoiceChat } from '../public/js/voicechat.js';
import { Q4VoiceSession, isVoiceEligible } from '../public/js/voicehud.js';

class FakeTrack {
  constructor() {
    this.enabled = true;
    this.stopped = false;
  }

  stop() { this.stopped = true; }
}

class FakeStream {
  constructor(tracks = [new FakeTrack()]) { this.tracks = tracks; }
  getAudioTracks() { return this.tracks; }
  getTracks() { return this.tracks; }
}

class FakeAudio {
  constructor() {
    this.autoplay = false;
    this.muted = false;
    this.srcObject = null;
  }

  play() { return Promise.resolve(); }
  pause() {}
}

class FakePeerConnection {
  static instances = [];

  constructor(config) {
    this.config = config;
    this.connectionState = 'new';
    this.signalingState = 'stable';
    this.localDescription = null;
    this.remoteDescription = null;
    this.tracks = [];
    this.candidates = [];
    this.closed = false;
    FakePeerConnection.instances.push(this);
  }

  addTrack(track, stream) { this.tracks.push({ track, stream }); }
  async createOffer() { return { type: 'offer', sdp: 'offer' }; }
  async createAnswer() { return { type: 'answer', sdp: 'answer' }; }
  async setLocalDescription(description) {
    this.localDescription = description;
    this.signalingState = description?.type === 'offer' ? 'have-local-offer' : 'stable';
  }
  async setRemoteDescription(description) {
    this.remoteDescription = description;
    this.signalingState = description?.type === 'offer' ? 'have-remote-offer' : 'stable';
  }
  async addIceCandidate(candidate) { this.candidates.push(candidate); }
  restartIce() {}
  close() { this.closed = true; }
}

function installBrowserVoiceFakes(getUserMedia) {
  const originals = new Map();
  const replace = (key, value) => {
    originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
    Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
  };
  const events = new EventTarget();
  replace('window', globalThis);
  replace('navigator', { mediaDevices: { getUserMedia } });
  replace('RTCPeerConnection', FakePeerConnection);
  replace('Audio', FakeAudio);
  replace('MediaStream', FakeStream);
  replace('addEventListener', events.addEventListener.bind(events));
  replace('removeEventListener', events.removeEventListener.bind(events));
  return () => {
    for (const [key, descriptor] of originals) {
      if (descriptor) Object.defineProperty(globalThis, key, descriptor);
      else delete globalThis[key];
    }
  };
}

test('canonical voice module acquires media and applies PTT, mute and peer filtering', async () => {
  FakePeerConnection.instances.length = 0;
  const track = new FakeTrack();
  const stream = new FakeStream([track]);
  const restore = installBrowserVoiceFakes(async () => stream);
  const statuses = [];
  try {
    const voice = createVoiceChat({
      selfId: 'human-a',
      isEligible: (id) => !String(id).startsWith('bot:'),
      onStatus: (state) => statuses.push(state)
    });
    voice.setPeers(['human-b', 'bot:nightmare']);
    assert.equal(await voice.enable(), true);
    assert.equal(FakePeerConnection.instances.length, 1);
    assert.equal(track.enabled, false, 'PTT starts with the capture track gated');

    voice.pttDown();
    assert.equal(track.enabled, true);
    assert.equal(voice.state.transmitting, true);
    voice.mute(true);
    assert.equal(track.enabled, false);
    voice.setOpenMic(true);
    voice.mute(false);
    assert.equal(track.enabled, true, 'open mic transmits once self-mute is cleared');
    voice.muteOthers(true);
    assert.equal(voice.state.othersMuted, true);

    voice.disable();
    assert.equal(track.stopped, true);
    assert.equal(FakePeerConnection.instances[0].closed, true);
    assert.ok(statuses.some((state) => state.transmitting));
  } finally {
    restore();
  }
});

test('canonical voice module replays offer and ICE received before microphone enable', async () => {
  FakePeerConnection.instances.length = 0;
  const restore = installBrowserVoiceFakes(async () => new FakeStream());
  const sent = [];
  try {
    const voice = createVoiceChat({
      selfId: 'human-b',
      signal: { send: (to, payload) => sent.push({ to, payload }) }
    });
    voice.setPeers(['human-a']);
    for (let index = 0; index < 32; index += 1) {
      await voice.handleSignal('human-a', { t: 'ice', candidate: { candidate: `orphan-ice-${index}` } });
    }
    await voice.handleSignal('human-a', { t: 'sdp', sdp: { type: 'offer', sdp: 'stale-offer' } });
    await voice.handleSignal('human-a', { t: 'ice', candidate: { candidate: 'stale-ice' } });
    await voice.handleSignal('human-a', { t: 'sdp', sdp: { type: 'offer', sdp: 'current-offer' } });
    await voice.handleSignal('human-a', { t: 'ice', candidate: { candidate: 'current-ice' } });
    assert.equal(FakePeerConnection.instances.length, 0, 'disabled voice must not create a peer connection');

    assert.equal(await voice.enable(), true);
    assert.equal(FakePeerConnection.instances.length, 1);
    const peer = FakePeerConnection.instances[0];
    assert.deepEqual(peer.remoteDescription, { type: 'offer', sdp: 'current-offer' });
    assert.deepEqual(peer.candidates, [{ candidate: 'current-ice' }]);
    assert.deepEqual(sent, [{
      to: 'human-a',
      payload: { t: 'sdp', sdp: { type: 'answer', sdp: 'answer' } }
    }]);
    voice.destroy();
  } finally {
    restore();
  }
});

test('canonical voice denial is non-fatal and leaves the game without capture', async () => {
  const denied = Object.assign(new Error('denied'), { name: 'NotAllowedError' });
  const restore = installBrowserVoiceFakes(async () => { throw denied; });
  try {
    const voice = createVoiceChat({ selfId: 'human-a' });
    assert.equal(await voice.enable(), false);
    assert.equal(voice.state.enabled, false);
    assert.equal(voice.state.permission, 'denied');
  } finally {
    restore();
  }
});

test('Q4 voice session buffers the early transport hook and filters bots and spectators', async () => {
  const calls = [];
  const voices = [];
  const previous = (message) => calls.push(['previous', message]);
  const globalObject = {
    Module: { q4Network: { onText: previous } },
    Q4Shell: { launch: { type: 'multiplayer' } }
  };
  const values = new Map();
  const storage = {
    getItem: (key) => values.get(key) ?? null,
    setItem: (key, value) => values.set(key, value)
  };
  const voiceFactory = (config) => {
    const peerIds = new Set();
    const voice = {
      config,
      peerIds,
      signals: [],
      state: { enabled: false, permission: 'prompt', muted: false, othersMuted: false, openMic: config.openMic, peers: [] },
      setPeers(ids) { peerIds.clear(); for (const id of ids) peerIds.add(id); },
      addPeer(id) { peerIds.add(id); },
      removePeer(id) { peerIds.delete(id); },
      handleSignal(from, payload) { this.signals.push({ from, payload }); },
      async enable() { this.state.enabled = true; config.onStatus(this.state); return true; },
      disable() { this.state.enabled = false; config.onStatus(this.state); },
      mute() {}, muteOthers() {}, pttDown() {}, pttUp() {},
      setOpenMic(value) { this.state.openMic = value; config.onStatus(this.state); },
      destroy() { this.destroyed = true; }
    };
    voices.push(voice);
    return voice;
  };
  const session = new Q4VoiceSession({ globalObject, storage, voiceFactory, log: () => {} });
  assert.equal(typeof globalObject.Module.q4Network.onText, 'function', 'hook exists before the engine opens its socket');

  globalObject.Module.q4Network.onText(JSON.stringify({
    t: 'voice-session',
    selfId: 'human-a',
    room: '28004',
    peers: ['human-b', { id: 'bot-c', bot: true }, { id: 'viewer-d', role: 'spectator' }]
  }));
  assert.equal(voices.length, 1);
  assert.deepEqual([...voices[0].peerIds], ['human-b']);
  assert.equal(voices[0].config.pttKey, 'KeyT');

  globalObject.Module.q4Network.sendText = (message) => calls.push(['send', message]);
  voices[0].config.signal.send('human-b', { t: 'ice', candidate: { candidate: 'x' } });
  assert.deepEqual(calls.at(-1), ['send', {
    t: 'voice-signal', to: 'human-b', payload: { t: 'ice', candidate: { candidate: 'x' } }
  }]);

  session.handleText({ t: 'voice-peer', id: 'human-c', state: 'joined' });
  session.handleText({ t: 'voice-peer', id: 'bot-d', state: 'joined', bot: true });
  assert.deepEqual([...voices[0].peerIds].sort(), ['human-b', 'human-c']);
  session.handleText({ t: 'voice-signal', from: 'human-b', payload: { t: 'sdp' } });
  assert.deepEqual(voices[0].signals, [{ from: 'human-b', payload: { t: 'sdp' } }]);

  session.setOpenMic(true);
  assert.equal(values.get('q4_voice_openmic'), '1');
  session.destroy();
  assert.equal(globalObject.Module.q4Network.onText, previous);
  assert.equal(voices[0].destroyed, true);

  const spectatorSession = new Q4VoiceSession({
    globalObject: { Module: {}, Q4Shell: { launch: { type: 'replay' } } },
    voiceFactory,
    log: () => {}
  });
  spectatorSession.handleText({ t: 'voice-session', selfId: 'viewer', peerIds: ['human-b'] });
  assert.equal(spectatorSession.available, false);
  assert.equal(voices.length, 1, 'a spectator session never creates media voice state');
  spectatorSession.destroy();
});

test('voice eligibility and PTT routing exclude non-human roles and engine commands', () => {
  assert.equal(isVoiceEligible({ id: 'one', role: 'player' }), true);
  assert.equal(isVoiceEligible({ id: 'two', role: 'spectator' }), false);
  assert.equal(isVoiceEligible({ id: 'three', isBot: true }), false);
  assert.equal(isVoiceEligible('bot:four'), false);

  const originalCustomEvent = globalThis.CustomEvent;
  const originalDispatch = globalThis.dispatchEvent;
  const events = [];
  let engineActions = 0;
  globalThis.CustomEvent = class CustomEvent {
    constructor(type, init) { this.type = type; this.detail = init.detail; }
  };
  globalThis.dispatchEvent = (event) => { events.push(event); return true; };
  try {
    const sink = new BrowserInputSink({ target: null, adapter: { inputAction() { engineActions += 1; } } });
    sink.action('ptt', true, 'touch:1');
    sink.action('ptt', false, 'touch:1');
    assert.deepEqual(events.map((event) => event.detail.active), [true, false]);
    assert.equal(engineActions, 0);
  } finally {
    if (originalCustomEvent === undefined) delete globalThis.CustomEvent;
    else globalThis.CustomEvent = originalCustomEvent;
    if (originalDispatch === undefined) delete globalThis.dispatchEvent;
    else globalThis.dispatchEvent = originalDispatch;
  }
});
