/**
 * In-process WebRTC factory for tests.
 *
 * No real ICE/DTLS — peer connections are linked by `sessionId` and the
 * "data channel" is a direct in-memory pipe between the two paired
 * endpoints. The result is a deterministic harness for the offer/answer/
 * datachannel flow without a `node-datachannel` / `wrtc` install in CI.
 *
 * Test code uses {@link MemoryRtcFactory} where production code would
 * inject {@link nativeRtcFactory}. Both implement the same
 * {@link IRtcFactory} contract.
 */

import type {
  IDataChannel,
  IPeerConnection,
  IRtcFactory,
  ShadeIceCandidate,
  ShadeRtcConfig,
  ShadeRtcConnectionState,
  ShadeSessionDescription,
} from './types.js';

type Listener = (...args: unknown[]) => void;

class EventBus {
  private readonly map = new Map<string, Set<Listener>>();
  on(event: string, cb: Listener): void {
    let set = this.map.get(event);
    if (set === undefined) {
      set = new Set();
      this.map.set(event, set);
    }
    set.add(cb);
  }
  off(event: string, cb: Listener): void {
    this.map.get(event)?.delete(cb);
  }
  emit(event: string, ...args: unknown[]): void {
    const set = this.map.get(event);
    if (set === undefined) return;
    for (const cb of [...set]) {
      try {
        cb(...args);
      } catch (err) {
        console.warn(`[MemoryRTC] listener for ${event} threw:`, err);
      }
    }
  }
}

class MemoryDataChannel implements IDataChannel {
  readyState: 'connecting' | 'open' | 'closing' | 'closed' = 'connecting';
  binaryType: 'arraybuffer' | 'blob' = 'arraybuffer';
  bufferedAmount = 0;
  /** The DataChannel on the other side of the wire. */
  peer: MemoryDataChannel | null = null;
  private readonly bus = new EventBus();
  private readonly deliveryQueue: ArrayBuffer[] = [];
  private deliveryDraining = false;

  constructor(public readonly label: string) {}

  send(data: ArrayBuffer | Uint8Array): void {
    if (this.readyState !== 'open') {
      throw new Error(`MemoryDataChannel.send: not open (state=${this.readyState})`);
    }
    if (this.peer === null) throw new Error('MemoryDataChannel.send: no peer');
    const buf = data instanceof Uint8Array
      ? data.slice().buffer
      : data.slice(0);
    this.peer.enqueueDelivery(buf as ArrayBuffer);
  }

  close(): void {
    if (this.readyState === 'closed' || this.readyState === 'closing') return;
    this.readyState = 'closing';
    this.bus.emit('close');
    this.readyState = 'closed';
    if (this.peer !== null && this.peer.readyState !== 'closed') {
      this.peer.close();
    }
  }

  addEventListener(event: 'open' | 'close' | 'error' | 'message', cb: (ev?: any) => void): void {
    this.bus.on(event, cb);
  }
  removeEventListener(event: string, cb: Listener): void {
    this.bus.off(event, cb);
  }

  // Internal — used by the paired PC to "open" both sides simultaneously.
  open(): void {
    if (this.readyState === 'open') return;
    this.readyState = 'open';
    this.bus.emit('open');
  }

  enqueueDelivery(buf: ArrayBuffer): void {
    this.deliveryQueue.push(buf);
    if (this.deliveryDraining) return;
    this.deliveryDraining = true;
    queueMicrotask(() => this.drainDelivery());
  }

  private drainDelivery(): void {
    while (this.deliveryQueue.length > 0) {
      const buf = this.deliveryQueue.shift()!;
      try {
        this.bus.emit('message', { data: buf });
      } catch (err) {
        console.warn('[MemoryDataChannel] message handler threw:', err);
      }
    }
    this.deliveryDraining = false;
  }
}

class MemoryPeerConnection implements IPeerConnection {
  connectionState: ShadeRtcConnectionState = 'new';
  iceConnectionState = 'new';

  /** Keyed by sessionId encoded into the SDP. */
  static readonly registry = new Map<string, MemoryPeerConnection>();

  private readonly bus = new EventBus();
  private dc: MemoryDataChannel | null = null;
  private incomingDcCallback: ((channel: MemoryDataChannel) => void) | null = null;

  /** Last SDP we sent — sessionId is embedded in it. */
  private localSessionId: string | null = null;

  constructor(public readonly config: ShadeRtcConfig) {}

  createDataChannel(label: string): IDataChannel {
    if (this.dc !== null) return this.dc;
    this.dc = new MemoryDataChannel(label);
    return this.dc;
  }

  async createOffer(): Promise<ShadeSessionDescription> {
    this.localSessionId = randomSessionId();
    return { type: 'offer', sdp: encodeMemorySdp('offer', this.localSessionId) };
  }

  async createAnswer(): Promise<ShadeSessionDescription> {
    if (this.localSessionId === null) this.localSessionId = randomSessionId();
    return { type: 'answer', sdp: encodeMemorySdp('answer', this.localSessionId) };
  }

  async setLocalDescription(desc: ShadeSessionDescription): Promise<void> {
    const decoded = decodeMemorySdp(desc.sdp);
    this.localSessionId = decoded.sessionId;
    if (decoded.type === 'offer') {
      MemoryPeerConnection.registry.set(`offer:${decoded.sessionId}`, this);
    } else {
      MemoryPeerConnection.registry.set(`answer:${decoded.sessionId}`, this);
    }
  }

  async setRemoteDescription(desc: ShadeSessionDescription): Promise<void> {
    const decoded = decodeMemorySdp(desc.sdp);

    // Look up the paired PC.
    let peer: MemoryPeerConnection | undefined;
    if (decoded.type === 'offer') {
      peer = MemoryPeerConnection.registry.get(`offer:${decoded.sessionId}`);
    } else {
      peer = MemoryPeerConnection.registry.get(`answer:${decoded.sessionId}`);
    }
    // The peer has set their *local* description with this sessionId, so
    // we connect our DCs.
    if (peer !== undefined && peer !== this) {
      this.linkDataChannels(peer);
    }
  }

  async addIceCandidate(_candidate: ShadeIceCandidate | null): Promise<void> {
    // No-op in the memory factory — the linking happens on
    // setRemoteDescription via the registry lookup.
  }

  close(): void {
    this.connectionState = 'closed';
    this.iceConnectionState = 'closed';
    this.bus.emit('connectionstatechange');
    if (this.dc !== null) {
      try {
        this.dc.close();
      } catch {
        /* swallow */
      }
    }
    if (this.localSessionId !== null) {
      MemoryPeerConnection.registry.delete(`offer:${this.localSessionId}`);
      MemoryPeerConnection.registry.delete(`answer:${this.localSessionId}`);
    }
  }

  addEventListener(event: string, cb: (...args: any[]) => void): void {
    if (event === 'datachannel') {
      this.incomingDcCallback = cb as (channel: MemoryDataChannel) => void;
      // If a DC is already linked at the time the listener registers,
      // fire immediately.
      if (this.dc !== null && this.linkedPeer !== null) {
        cb({ channel: this.dc });
      }
      return;
    }
    this.bus.on(event, cb);
  }
  removeEventListener(event: string, cb: Listener): void {
    if (event === 'datachannel') {
      if (this.incomingDcCallback === (cb as unknown)) this.incomingDcCallback = null;
      return;
    }
    this.bus.off(event, cb);
  }

  // ─── Internals ────────────────────────────────────────

  private linkedPeer: MemoryPeerConnection | null = null;

  private linkDataChannels(peer: MemoryPeerConnection): void {
    if (this.linkedPeer !== null) return;
    this.linkedPeer = peer;
    peer.linkedPeer = this;

    // Caller created its DC up-front; callee creates it lazily here.
    if (this.dc === null) {
      this.dc = new MemoryDataChannel('shade-transfer/v1');
      // Notify the callee's 'datachannel' listener.
      const cb = this.incomingDcCallback;
      if (cb !== null) {
        const dc = this.dc;
        queueMicrotask(() => cb({ channel: dc } as never));
      }
    }
    if (peer.dc === null) {
      peer.dc = new MemoryDataChannel('shade-transfer/v1');
      const cb = peer.incomingDcCallback;
      if (cb !== null) {
        const dc = peer.dc;
        queueMicrotask(() => cb({ channel: dc } as never));
      }
    }
    this.dc.peer = peer.dc;
    peer.dc.peer = this.dc;

    // Open both sides asynchronously to mimic real WebRTC connect timing.
    queueMicrotask(() => {
      this.connectionState = 'connected';
      this.iceConnectionState = 'connected';
      this.bus.emit('connectionstatechange');
      this.dc!.open();

      peer.connectionState = 'connected';
      peer.iceConnectionState = 'connected';
      peer.bus.emit('connectionstatechange');
      peer.dc!.open();
    });
  }
}

export class MemoryRtcFactory implements IRtcFactory {
  createPeerConnection(config: ShadeRtcConfig): IPeerConnection {
    return new MemoryPeerConnection(config);
  }
  /** Tests can call this between cases to nuke the registry. */
  static reset(): void {
    MemoryPeerConnection.registry.clear();
  }
}

function randomSessionId(): string {
  const b = new Uint8Array(8);
  globalThis.crypto.getRandomValues(b);
  return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}

function encodeMemorySdp(type: 'offer' | 'answer', sessionId: string): string {
  // Deliberately not real SDP — just a tagged string the registry indexes.
  return `v=0\no=memory-rtc 0 0 IN IP4 0.0.0.0\ns=shade-memory\na=type:${type}\na=session-id:${sessionId}\n`;
}

function decodeMemorySdp(sdp: string): { type: 'offer' | 'answer'; sessionId: string } {
  const typeLine = sdp.match(/a=type:(offer|answer)/);
  const sessLine = sdp.match(/a=session-id:([0-9a-f]+)/);
  if (typeLine === null || sessLine === null) {
    throw new Error(`MemoryRTC: cannot decode SDP: ${sdp.slice(0, 80)}`);
  }
  return { type: typeLine[1] as 'offer' | 'answer', sessionId: sessLine[1]! };
}
