/**
 * SipEngine.ts — the core SIP/WebRTC client.
 *
 * Responsibilities (Step 3 scope):
 *   1. REGISTRATION  — open the WSS transport to Asterisk and REGISTER the 6xxx AOR
 *   2. ICE HANDLING  — feed STUN/TURN servers into every RTCPeerConnection and
 *                      surface connection-state so the UI can show "connecting…"
 *   3. SESSION MGMT  — place/receive calls, expose mute / hold / speaker / hangup,
 *                      and emit lifecycle events the CallManager maps onto CallKeep.
 *
 * jssip drives signalling; react-native-webrtc provides the RTCPeerConnection,
 * MediaStream and getUserMedia globals that jssip expects to find on the JS runtime.
 */
import {
  registerGlobals,
  mediaDevices,
  MediaStream,
} from 'react-native-webrtc';
import JsSIP from 'jssip';
import { EventEmitter } from 'events';
import { SipConfig, formatOutbound } from '../config';

// Make RTCPeerConnection / navigator.mediaDevices etc. available to jssip.
registerGlobals();

// jssip is chatty; keep 'error' in prod, bump to '*' when debugging registration.
JsSIP.debug.enable('JsSIP:*');

export type CallDirection = 'incoming' | 'outgoing';

export interface ActiveCall {
  id: string;                 // uuid we share with CallKeep
  direction: CallDirection;
  remoteNumber: string;
  session: any;               // jssip RTCSession
  muted: boolean;
  held: boolean;
  localStream?: MediaStream;
  remoteStream?: MediaStream;
}

/**
 * Events emitted:
 *   'registered'            ()                       — REGISTER succeeded
 *   'registrationFailed'    (cause)                  — REGISTER rejected / transport down
 *   'unregistered'          ()
 *   'incoming'              (call: ActiveCall)       — INVITE arrived; ring native UI
 *   'progress'              (callId)                 — 18x, remote is ringing
 *   'accepted'/'confirmed'  (callId)                 — media is up
 *   'iceState'              (callId, state)          — 'connected'|'failed'|...
 *   'remoteStream'          (callId, stream)         — attach to audio sink
 *   'ended'                 (callId, cause)          — bye / cancel / failure
 */
class SipEngine extends EventEmitter {
  private ua: any | null = null;
  private cfg: SipConfig | null = null;
  private calls = new Map<string, ActiveCall>();

  /** True once the UA has an open WSS transport AND a live registration. */
  isRegistered = false;

  // ---------------------------------------------------------------------------
  // 1. REGISTRATION
  // ---------------------------------------------------------------------------
  async start(cfg: SipConfig): Promise<void> {
    this.cfg = cfg;

    // Reuse an existing UA if we're just re-waking from push and cfg is unchanged.
    if (this.ua) {
      this.register();
      return;
    }

    const socket = new JsSIP.WebSocketInterface(cfg.wsServer);
    this.ua = new JsSIP.UA({
      sockets: [socket],
      uri: `sip:${cfg.extension}@${cfg.domain}`,
      password: cfg.password,
      display_name: cfg.displayName,
      // Let jssip auto-REGISTER as soon as the socket connects.
      register: true,
      // Re-REGISTER well before Asterisk's default 60s expiry to survive naps.
      register_expires: 120,
      session_timers: false,
      // Fast reconnect when the phone flips wifi<->cellular.
      connection_recovery_min_interval: 2,
      connection_recovery_max_interval: 30,
    });

    this.wireUaEvents();
    this.ua.start(); // opens the WSS transport; REGISTER fires on 'connected'
  }

  /** Force a fresh REGISTER — called after a VoIP push wakes the app. */
  register(): void {
    if (this.ua && !this.ua.isRegistered()) this.ua.register();
  }

  stop(): void {
    this.ua?.stop();
    this.isRegistered = false;
  }

  private wireUaEvents(): void {
    this.ua.on('connected', () => console.log('[SIP] WSS transport up'));
    this.ua.on('disconnected', (e: any) =>
      console.warn('[SIP] WSS transport down', e?.reason),
    );

    this.ua.on('registered', () => {
      this.isRegistered = true;
      this.emit('registered');
    });
    this.ua.on('unregistered', () => {
      this.isRegistered = false;
      this.emit('unregistered');
    });
    this.ua.on('registrationFailed', (e: any) => {
      this.isRegistered = false;
      this.emit('registrationFailed', e?.cause);
    });

    // Inbound INVITE from Asterisk [ring-engineer] context.
    this.ua.on('newRTCSession', (data: any) => {
      if (data.originator === 'remote') this.onIncoming(data.session);
      // outgoing sessions are wired up in placeCall() where we own the callId
    });
  }

  // ---------------------------------------------------------------------------
  // Shared PeerConnection / ICE config handed to jssip on every call
  // ---------------------------------------------------------------------------
  private get callOptions() {
    return {
      mediaConstraints: { audio: true, video: false },
      pcConfig: {
        iceServers: this.cfg!.iceServers,
        // 'all' lets host+srflx+relay candidates race; ICE picks the best pair.
        iceTransportPolicy: 'all' as const,
        // Trickle ICE: jssip/react-native-webrtc gather candidates async and
        // send them as they arrive — faster call setup than waiting for all.
        rtcpMuxPolicy: 'require' as const,
      },
    };
  }

  // ---------------------------------------------------------------------------
  // 3a. OUTGOING call (app dialpad -> Asterisk [from-mobile])
  // ---------------------------------------------------------------------------
  placeCall(number: string, callId: string): ActiveCall {
    // Format to the dialplan's expectation (10-digit mobile -> "80…" => trunk2).
    const dialed = formatOutbound(number, this.cfg!.dialPrefix);
    const target = `sip:${dialed}@${this.cfg!.domain}`;
    // [trunk-out] reads the outbound caller ID from the X-CLI header.
    const options = {
      ...this.callOptions,
      extraHeaders: [`X-CLI: ${this.cfg!.outboundCli}`],
    };
    const session = this.ua.call(target, options);

    const call: ActiveCall = {
      id: callId,
      direction: 'outgoing',
      remoteNumber: number,
      session,
      muted: false,
      held: false,
    };
    this.calls.set(callId, call);
    this.wireSessionEvents(call);
    return call;
  }

  // ---------------------------------------------------------------------------
  // 3b. INCOMING call
  // ---------------------------------------------------------------------------
  private onIncoming(session: any): void {
    const callId = this.newUuid();
    const remoteNumber =
      session.remote_identity?.uri?.user ||
      session.remote_identity?.display_name ||
      'Unknown';

    const call: ActiveCall = {
      id: callId,
      direction: 'incoming',
      remoteNumber,
      session,
      muted: false,
      held: false,
    };
    this.calls.set(callId, call);
    this.wireSessionEvents(call);

    // Hand to CallManager -> CallKeep shows the native incoming-call screen.
    this.emit('incoming', call);
  }

  /** Answer an inbound call (invoked from CallKeep's 'answerCall' action). */
  answer(callId: string): void {
    const call = this.calls.get(callId);
    if (!call) return;
    call.session.answer(this.callOptions);
  }

  // ---------------------------------------------------------------------------
  // 2. ICE + media event wiring (per RTCSession)
  // ---------------------------------------------------------------------------
  private wireSessionEvents(call: ActiveCall): void {
    const { session, id } = call;

    // Grab the RTCPeerConnection as soon as jssip creates it, so we can watch ICE.
    session.on('peerconnection', (e: any) => {
      const pc: RTCPeerConnection = e.peerconnection;

      pc.addEventListener('iceconnectionstatechange', () => {
        const state = (pc as any).iceConnectionState;
        this.emit('iceState', id, state);
        // 'failed' usually means no reachable candidate pair -> TURN missing.
        if (state === 'failed') console.error('[ICE] failed — check TURN relay');
      });

      // Remote audio track -> stream the UI attaches to an <RTCView>/audio sink.
      pc.addEventListener('track', (ev: any) => {
        const [stream] = ev.streams;
        if (stream) {
          call.remoteStream = stream as MediaStream;
          this.emit('remoteStream', id, stream);
        }
      });
    });

    session.on('sending', () =>
      console.log(`[SIP] ${call.direction} INVITE sending -> ${call.remoteNumber}`),
    );
    session.on('progress', () => this.emit('progress', id));      // 180 Ringing
    session.on('accepted', () => this.emit('accepted', id));      // 200 OK
    session.on('confirmed', () => {                               // ACK, media up
      call.localStream = session.connection
        ?.getLocalStreams?.()[0] as MediaStream;
      this.emit('confirmed', id);
    });

    session.on('ended', (e: any) => this.finish(id, e?.cause || 'ended'));
    session.on('failed', (e: any) => this.finish(id, e?.cause || 'failed'));
  }

  // ---------------------------------------------------------------------------
  // 3c. In-call controls
  // ---------------------------------------------------------------------------
  hangup(callId: string): void {
    const call = this.calls.get(callId);
    if (!call) return;
    // terminate() handles the right verb: CANCEL if early, BYE if answered.
    try {
      call.session.terminate();
    } catch {
      /* already gone */
    }
    this.calls.delete(callId);
  }

  setMute(callId: string, muted: boolean): void {
    const call = this.calls.get(callId);
    if (!call) return;
    muted ? call.session.mute({ audio: true }) : call.session.unmute({ audio: true });
    call.muted = muted;
  }

  setHold(callId: string, held: boolean): void {
    const call = this.calls.get(callId);
    if (!call) return;
    held ? call.session.hold() : call.session.unhold();
    call.held = held;
  }

  /** DTMF for IVRs (e.g. "press 1"). */
  sendDtmf(callId: string, tone: string): void {
    this.calls.get(callId)?.session.sendDTMF(tone);
  }

  getCall(callId: string): ActiveCall | undefined {
    return this.calls.get(callId);
  }

  private finish(callId: string, cause: string): void {
    this.calls.delete(callId);
    this.emit('ended', callId, cause);
  }

  private newUuid(): string {
    // RFC4122-ish; jssip also ships a uuid but this keeps deps minimal here.
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
      const r = (Math.random() * 16) | 0;
      const v = c === 'x' ? r : (r & 0x3) | 0x8;
      return v.toString(16);
    });
  }
}

// Singleton — one UA per app process.
export const sipEngine = new SipEngine();
