import { EventEmitter } from 'eventemitter3';
import log from 'loglevel';
import uuid from 'uuid-random';
import type { CallOptions } from './call-options';
import { Connection } from './connection';
import type { AnswerEvent, ByeEvent, RingingEvent } from './messages/call';
import {
  createAnswerMessage,
  createCandidateMessage,
  createDTMFRequest,
  createEndOfCandidatesMessage,
  createHangupRequest,
  createInviteMessage,
  createModifyCallRequest,
  isAnswerEvent,
  isByeEvent,
  isDTMFResponse,
  isInviteACKMessage,
  isModifyCallAnswer,
  isRingingEvent,
} from './messages/call';
import { createAttachMessage } from './messages/attach';
import { Peer } from './peer';
import type { TrickleIceCandidate } from './peer';
import {
  prepareTrickleSdp,
  normalizeRemoteCandidateString,
  cleanCandidateString,
} from './sdp-utils';
import { WebRTCReporter } from './webrtc-reporter';
import { CallReportCollector } from './call-report-collector';
import type { CallReportConfig, CallReportSummary } from './call-report-models';
import { DEFAULT_CALL_REPORT_CONFIG } from './call-report-models';
import { CALL_REPORT_ID, VOICE_SDK_ID } from './global';
import { PROD_HOST, SDK_VERSION } from './env';

type CallEvents = {
  'telnyx.call.state': (call: Call, state: CallState) => void;
};

/**
 * Possible states a call can be in.
 * - 'new': Initial state when a call object is first created, before any signaling.
 * - 'ringing': The call is ringing on the remote side, waiting for the callee to answer.
 * - 'connecting': The call is being set up (SDP negotiation / ICE gathering in progress).
 * - 'active': The call is fully established with media flowing between peers.
 * - 'held': The call has been placed on hold by either party.
 * - 'dropped': The call lost network connectivity but is NOT terminated.
 *   A dropped call remains tracked because it may be reattached when
 *   the network recovers via the reconnection flow.
 * - 'ended': The call is fully terminated and will be removed from tracking.
 */
export type CallState = 'new' | 'ringing' | 'connecting' | 'active' | 'ended' | 'held' | 'dropped';

type CallConstructorParams = {
  connection: Connection;
  options: CallOptions;
  sessionId: string;
  direction: CallDirection;
  telnyxSessionId: string | null;
  telnyxLegId: string | null;
  telnyxCallControlId?: string | null;
  callId: string | null;
  callState?: CallState;
  debug?: boolean;
  callReportConfig?: CallReportConfig;
};

export type CallDirection = 'inbound' | 'outbound';

export type CreateInboundCall = {
  connection: Connection;
  options: CallOptions;
  sessionId: string;
  telnyxSessionId: string;
  telnyxLegId: string;
  telnyxCallControlId?: string | null;
  remoteSDP: string;
  callId: string;
  inviteCustomHeaders?: { name: string; value: string }[] | null;
  initialState?: CallState;
  debug?: boolean;
  callReportConfig?: CallReportConfig;
};

// TODO persist customHeaders and clientState
export class Call extends EventEmitter<CallEvents> {
  public direction: CallDirection;
  public callId: string;
  public telnyxSessionId: string | null;
  public telnyxCallControlId: string | null;
  public telnyxLegId: string | null;
  public state: CallState;

  /**
   * Custom headers received from the WebRTC INVITE message.
   * These headers are passed during call initiation and can contain application-specific information.
   * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
   */
  public inviteCustomHeaders: { name: string; value: string }[] | null = null;

  /**
   * Custom headers received from the WebRTC ANSWER message.
   * These headers are passed during call acceptance and can contain application-specific information.
   * Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
   */
  public answerCustomHeaders: { name: string; value: string }[] | null = null;

  static async createInboundCall({
    connection,
    options,
    remoteSDP,
    sessionId,
    telnyxLegId,
    telnyxSessionId,
    telnyxCallControlId,
    callId,
    inviteCustomHeaders = null,
    initialState = 'ringing',
    debug = false,
    callReportConfig,
  }: CreateInboundCall) {
    const call = new Call({
      connection,
      options,
      sessionId,
      direction: 'inbound',
      telnyxLegId,
      telnyxSessionId,
      telnyxCallControlId,
      callId,
      callState: initialState,
      debug,
      callReportConfig,
    });

    // Store the custom headers from the INVITE message
    call.inviteCustomHeaders = inviteCustomHeaders;

    // Check if the connection has a reference to the client with stored push notification CallKit UUID
    // The client should store the UUID when CallKitHandler processes the push notification
    const connectionClient = (connection as any)._client;
    if (connectionClient && typeof connectionClient.getPushNotificationCallKitUUID === 'function') {
      const storedCallKitUUID = connectionClient.getPushNotificationCallKitUUID();
      if (storedCallKitUUID) {
        log.debug('[Call] Automatically assigning stored CallKit UUID to new inbound call:', {
          callId,
          storedCallKitUUID,
        });

        // Set multiple CallKit UUID properties on the call object during creation
        (call as any)._callKitUUID = storedCallKitUUID;
        (call as any)._isPushNotificationCall = true;
        (call as any)._pushCallKitUUID = storedCallKitUUID;
        (call as any)._originalCallKitUUID = storedCallKitUUID;

        // Make these properties non-configurable to prevent accidental deletion
        try {
          Object.defineProperty(call, '_callKitUUID', {
            value: storedCallKitUUID,
            writable: false,
            enumerable: false,
            configurable: false,
          });
          Object.defineProperty(call, '_isPushNotificationCall', {
            value: true,
            writable: false,
            enumerable: false,
            configurable: false,
          });
        } catch (error) {
          log.warn('[Call] Could not make UUID properties non-configurable:', error);
        }

        // Clear the stored UUID since we've used it
        connectionClient.setPushNotificationCallKitUUID(null);

        log.debug('[Call] CallKit UUID properties set during call creation:', {
          _callKitUUID: (call as any)._callKitUUID,
          _isPushNotificationCall: (call as any)._isPushNotificationCall,
          _pushCallKitUUID: (call as any)._pushCallKitUUID,
          _originalCallKitUUID: (call as any)._originalCallKitUUID,
        });
      }
    }

    call.peer = new Peer(options);
    call.setupTrickleIceCallbacks();
    call.peer.createPeerConnection();
    call.peer.setRemoteDescription({ type: 'offer', sdp: remoteSDP });

    // Initialize WebRTC reporter if debug mode is enabled
    if (debug) {
      call.initializeReporter();
    }

    call.setState(initialState);

    return call;
  }

  private connection: Connection;
  private options: CallOptions;
  private peer: Peer | null;
  private sessionId: string;
  private reporter: WebRTCReporter | null = null;
  private debugEnabled: boolean = false;
  private callReportCollector: CallReportCollector | null = null;
  private callReportConfig: CallReportConfig;
  private callReportPostingStarted = false;
  private callStartTimestamp: string;

  constructor({
    connection,
    options,
    sessionId,
    direction,
    callId,
    telnyxLegId,
    telnyxSessionId,
    telnyxCallControlId = null,
    callState = 'new',
    debug = false,
    callReportConfig,
  }: CallConstructorParams) {
    super();

    this.connection = connection;
    this.options = options;
    this.sessionId = sessionId;
    this.callId = callId ?? uuid();
    this.direction = direction;
    this.state = callState;
    this.telnyxLegId = telnyxLegId;
    this.telnyxSessionId = telnyxSessionId;
    this.telnyxCallControlId = telnyxCallControlId;
    this.peer = null;
    this.debugEnabled = debug;
    this.callReportConfig = callReportConfig ?? DEFAULT_CALL_REPORT_CONFIG;
    this.callStartTimestamp = new Date().toISOString();

    // Initialize call report collector if enabled
    if (this.callReportConfig.enableCallReports) {
      this.callReportCollector = new CallReportCollector(this.callReportConfig);
      this.callReportCollector.log('info', 'Call started', {
        callId: this.callId,
        direction: this.direction,
      });
    }

    this.connection.addListener('telnyx.socket.message', this.onSocketMessage);
  }

  /**
   * Initialize the WebRTC reporter for debug stats collection
   */
  private initializeReporter(): void {
    if (!this.peer || !this.debugEnabled) {
      return;
    }

    const peerConnection = this.peer.getPeerConnection();
    if (!peerConnection) {
      log.warn('[Call] Cannot initialize reporter: no peer connection');
      return;
    }

    log.debug('[Call] Initializing WebRTC reporter for debug stats');

    this.reporter = new WebRTCReporter({
      connection: this.connection,
      peerId: this.callId,
      connectionId: this.telnyxLegId || this.callId,
      peerConnection: peerConnection as any,
      iceServers: this.peer.getIceServers() as any,
    });

    // Set the reporter on the peer for event forwarding
    this.peer.setReporter(this.reporter);

    // Start stats collection
    this.reporter.startStats();
  }

  /**
   * Start debug stats collection manually
   * This can be called after the call is established if debug mode was not enabled initially
   */
  public startDebugStats(): void {
    if (this.reporter) {
      log.debug('[Call] Debug stats already started');
      return;
    }

    if (!this.peer) {
      log.warn('[Call] Cannot start debug stats: no peer connection');
      return;
    }

    this.debugEnabled = true;
    this.initializeReporter();
  }

  /**
   * Stop debug stats collection
   */
  public stopDebugStats(): void {
    if (this.reporter) {
      log.debug('[Call] Stopping debug stats collection');
      this.reporter.stopStats();
      this.reporter = null;
    }

    if (this.peer && typeof this.peer.setReporter === 'function') {
      this.peer.setReporter(null);
    }
  }

  /**
   * Check if debug stats collection is active
   */
  public isDebugStatsActive(): boolean {
    return this.reporter?.isActive() ?? false;
  }

  /**
   * Get the debug stats ID
   */
  public getDebugStatsId(): string | null {
    return this.reporter?.getDebugStatsId() ?? null;
  }

  public get remoteStream() {
    return this.options.remoteStream;
  }

  public get localStream() {
    return this.options.localStream;
  }

  /**
   * Accept an incoming call
   * This method will attach the local audio stream, create an answer,
   * and send the answer message to the Telnyx platform.
   * It will also set the call state to 'active'.
   * @param customHeaders Optional custom headers to include with the answer
   * @throws {Error} If the peer connection is not created
   * @returns {Promise<void>} A promise that resolves when the call is accepted
   */
  public answer = async (customHeaders?: { name: string; value: string }[]) => {
    if (!this.peer) {
      throw new Error('[Call] Peer is not created');
    }

    // Set state to connecting when starting the answer process
    log.debug('[Call] Setting state to connecting before answering');
    this.setState('connecting');

    await this.peer
      .attachLocalStream({ audio: true, video: false })
      .then((peer) => peer.createAnswer());

    if (!this.isTrickleIceEnabled()) {
      await this.peer.waitForIceGatheringComplete();
    }

    await this.connection.sendAndWait(
      createAnswerMessage({
        callId: this.callId,
        dialogParams: {},
        sdp: this.getLocalSdpForSignaling(),
        telnyxLegId: this.telnyxLegId!,
        telnyxSessionId: this.telnyxSessionId!,
        sessionId: this.sessionId,
        customHeaders,
      })
    );

    if (this.isTrickleIceEnabled()) {
      this.peer.flushPendingLocalCandidates();
    }

    this.setState('active');
  };

  /**
   * Accept an incoming call for attachment
   * This method will attach the local audio stream, create an answer,
   * and send the answer message to the Telnyx platform for call reattachment.
   * It will also set the call state to 'active'.
   * @throws {Error} If the peer connection is not created
   * @returns {Promise<void>} A promise that resolves when the call is attached
   */
  public answerAttach = async () => {
    log.debug('[Call] Starting answerAttach process');

    if (!this.peer) {
      log.error('[Call] No peer connection available for answerAttach');
      throw new Error('[Call] Peer is not created');
    }

    try {
      log.debug('[Call] Attaching local stream and creating answer for reattachment');
      await this.peer.attachLocalStream({ audio: true, video: false }).then((peer) => {
        log.debug('[Call] Local stream attached, creating answer');
        return peer.createAnswer();
      });

      if (!this.isTrickleIceEnabled()) {
        log.debug('[Call] Answer created, waiting for ICE gathering');
        await this.peer.waitForIceGatheringComplete();
      }

      log.debug('[Call] Preparing answer attach message');

      if (!this.peer.localDescription?.sdp) {
        log.error('[Call] No local SDP available for answer message');
        throw new Error('[Call] Local SDP not available');
      }

      const attachMessage = createAttachMessage({
        callId: this.callId,
        sessionId: this.sessionId,
        sdp: this.getLocalSdpForSignaling(),
        customHeaders: this.inviteCustomHeaders || [],
        destinationNumber: this.options.destinationNumber || '',
        callerIdName: this.options.callerIdName || '',
        callerIdNumber: this.options.callerIdNumber || '',
        clientState: this.options.clientState || '',
        userVariables: [], // Not available in CallOptions, using empty array
      });

      log.debug('[Call] Sending attach message for reattachment:', {
        callId: this.callId,
        sessionId: this.sessionId,
        hasCustomHeaders: !!(this.inviteCustomHeaders && this.inviteCustomHeaders.length > 0),
      });

      // Send attach message for call reattachment (critical for WebRTC media flow)
      await this.connection.sendAndWait(attachMessage);

      if (this.isTrickleIceEnabled()) {
        this.peer.flushPendingLocalCandidates();
      }

      log.debug('[Call] Attach message sent successfully, setting call to active');
      this.setState('active');
      log.debug('[Call] answerAttach completed successfully');
    } catch (error) {
      log.error('[Call] answerAttach failed:', error);
      throw error;
    }
  };

  /**
   * Hang up the call
   * This method will send a hangup request to the Telnyx platform,
   * close the peer connection, and set the call state to 'ended'.
   * @param customHeaders Optional custom headers to include with the hangup request
   */
  public hangup = (customHeaders?: { name: string; value: string }[]) => {
    // Log local hangup to call report
    this.callReportCollector?.log('info', 'Call ended', {
      cause: 'USER_BUSY',
      causeCode: 17,
    });

    // Stop debug stats collection before ending the call
    this.stopDebugStats();

    this.connection.send(
      createHangupRequest({
        callId: this.callId,
        telnyxLegId: this.telnyxLegId!,
        telnyxSessionId: this.telnyxSessionId!,
        cause: 'USER_BUSY',
        causeCode: 17,
        sessionId: this.sessionId,
        customHeaders,
      })
    );

    this.closePeerAfterCallReport('done');
    this.setState('ended');
  };

  /**
   * Hold the call
   * This method will send a hold request to the Telnyx platform,
   * and set the call state to 'held'.
   * @throws {Error} If the hold action fails or if the response is invalid
   * @return {Promise<void>} A promise that resolves when the call is held
   */

  public hold = async () => {
    const holdRequest = createModifyCallRequest({
      action: 'hold',
      callId: this.callId,
      sessionId: this.sessionId,
    });

    const result = await this.connection.sendAndWait(holdRequest);
    if (!isModifyCallAnswer(result)) {
      throw new Error(`[Call] Invalid hold response received: ${JSON.stringify(result)}`);
    }
    if (result.result.holdState !== 'held') {
      throw new Error(`[Call] Hold action failed: ${JSON.stringify(result)}`);
    }
    this.setState('held');
  };

  /**
   * Unhold the call
   * This method will send an unhold request to the Telnyx platform,
   * and set the call state to 'active'.
   * @throws {Error} If the unhold action fails or if the response is invalid
   * @return {Promise<void>} A promise that resolves when the call is unheld
   */
  public unhold = async () => {
    const unholdRequest = createModifyCallRequest({
      action: 'unhold',
      sessionId: this.sessionId,
      callId: this.callId,
    });
    const result = await this.connection.sendAndWait(unholdRequest);
    if (!isModifyCallAnswer(result)) {
      throw new Error(`[Call] Invalid hold response received: ${JSON.stringify(result)}`);
    }
    if (result.result.holdState !== 'held') {
      throw new Error(`[Call] Hold action failed: ${JSON.stringify(result)}`);
    }

    this.setState('active');
  };

  /**
   * Send DTMF tones on this call as a Verto `telnyx_rtc.info` message.
   *
   * @param digits One or more DTMF characters. Valid characters are `0-9`, `A-D`,
   *   `*`, and `#`; anything else is silently dropped by the platform.
   * @returns A promise that resolves with the gateway ack object (`{ sessid, message? }`)
   *   once the INFO request has been acknowledged.
   * @throws If the socket returns an error frame or a malformed response (no `result.sessid`).
   * @example
   * ```typescript
   * await call.dtmf('1234#');
   * ```
   */
  public dtmf = async (digits: string) => {
    const DTMFRequest = createDTMFRequest({
      digits,
      sessionId: this.sessionId,
      callId: this.callId,
      callerIdName: this.options.callerIdName,
      callerIdNumber: this.options.callerIdNumber,
      clientState: this.options.clientState,
      customHeaders: this.options.customHeaders,
      destinationNumber: this.options.destinationNumber,
      remoteCallerIdName: this.options.remoteCallerIdName,
    });

    const response = await this.connection.sendAndWait(DTMFRequest);
    if (!isDTMFResponse(response)) {
      throw new Error(`[Call] Invalid DTMF response received: ${JSON.stringify(response)}`);
    }
    return response.result;
  };

  /**
   * Mute the local audio stream
   * This method will set the local audio stream state to false,
   * effectively muting the audio.
   * @throws {Error} If the peer connection or local stream is not set
   * @returns {void}
   * @example
   * ```typescript
   * call.mute();
   * console.log('Call muted');
   * ```
   * @see {@link Call.unmute} for unmuting the call.
   * @see {@link Call.deaf} for deafening the call.
   * @see {@link Call.undeaf} for undeafening the call.
   *
   */

  public mute = () => {
    if (!this.peer) {
      throw new Error('[Call] Peer not created');
    }
    if (!this.options.localStream) {
      throw new Error('[Call] Local stream not set');
    }
    this.peer.setMediaStreamState(this.options.localStream, false);
  };

  /**
   * Unmute the local audio stream
   * This method will set the local audio stream state to true,
   * effectively unmuting the audio.
   * @throws {Error} If the peer connection or local stream is not set
   * @returns {void}
   * @example
   * ```typescript
   * call.unmute();
   * console.log('Call unmuted');
   * ```
   * @see {@link Call.mute} for muting the call.
   * @see {@link Call.deaf} for deafening the call.
   * @see {@link Call.undeaf} for undeafening the call.
   */
  public unmute = () => {
    if (!this.peer) {
      throw new Error('[Call] Peer not created');
    }
    if (!this.options.localStream) {
      throw new Error('[Call] Local stream not set');
    }
    this.peer.setMediaStreamState(this.options.localStream, true);
  };

  /**
   * Deafen the remote audio stream
   * This method will set the remote audio stream state to false,
   * effectively deafening the audio.
   * @throws {Error} If the peer connection or remote stream is not set
   * @returns {void}
   * @example
   * ```typescript
   * call.deaf();
   * console.log('Call deafened');
   * ```
   * @see {@link Call.undeaf} for undeafening the call.
   * @see {@link Call.mute} for muting the call.
   * @see {@link Call.unmute} for unmuting the call.
   *
   */
  public deaf = () => {
    if (!this.peer) {
      throw new Error('[Call] Peer not created');
    }
    if (!this.options.remoteStream) {
      throw new Error('[Call] Remote stream not set');
    }
    this.peer.setMediaStreamState(this.options.remoteStream, false);
  };

  /**
   * Undeafen the remote audio stream
   * This method will set the remote audio stream state to true,
   * effectively undeafening the audio.
   * @throws {Error} If the peer connection or remote stream is not set
   * @returns {void}
   * @example
   * ```typescript
   * call.undeaf();
   * console.log('Call undeafened');
   * ```
   * @see {@link Call.deaf} for deafening the call.
   * @see {@link Call.mute} for muting the call.
   *  @see {@link Call.unmute} for unmuting the call.
   *
   */
  public undeaf = () => {
    if (!this.peer) {
      throw new Error('[Call] Peer not created');
    }
    if (!this.options.remoteStream) {
      throw new Error('[Call] Remote stream not set');
    }
    this.peer.setMediaStreamState(this.options.remoteStream, true);
  };

  /**
   * Handle early media/ringback from media events with SDP
   * This method sets up early media playback for ringback tones or announcements
   * received before the call is answered.
   * @param sdp - The SDP from the media event for early media setup
   */
  public handleEarlyMedia = (sdp: string) => {
    log.debug('[Call] Handling early media with SDP');

    if (!this.peer) {
      log.warn('[Call] No peer connection available for early media setup');
      return;
    }

    try {
      // For outbound calls, the media SDP is an answer to our local offer
      // For inbound calls, the media SDP would be an offer (but inbound calls typically get SDP in invite)
      const sdpType = this.direction === 'outbound' ? 'answer' : 'offer';

      log.debug(`[Call] Setting early media SDP as ${sdpType} for ${this.direction} call`);
      this.peer.setRemoteDescription({ type: sdpType, sdp });
      log.debug('[Call] Early media SDP set successfully');
    } catch (error) {
      log.error('[Call] Failed to set early media SDP:', error);
    }
  };

  /**
   * Handle media update events (audio/video enable/disable)
   * @param mediaUpdate - Object containing audio/video states and target
   */
  public handleMediaUpdate = (mediaUpdate: {
    audio?: boolean;
    video?: boolean;
    target?: 'local' | 'remote';
  }) => {
    log.debug('[Call] Handling media update:', mediaUpdate);

    const { audio, video, target = 'remote' } = mediaUpdate;

    // Handle audio changes
    if (audio !== undefined) {
      try {
        if (target === 'local' && this.options.localStream) {
          this.peer?.setMediaStreamState(this.options.localStream, audio);
          log.debug(`[Call] ${audio ? 'Enabled' : 'Disabled'} local audio`);
        } else if (target === 'remote' && this.options.remoteStream) {
          this.peer?.setMediaStreamState(this.options.remoteStream, audio);
          log.debug(`[Call] ${audio ? 'Enabled' : 'Disabled'} remote audio`);
        }
      } catch (error) {
        log.error('[Call] Error handling audio media update:', error);
      }
    }

    // Handle video changes (if supported in the future)
    if (video !== undefined) {
      log.debug(
        `[Call] Video media update received but not yet implemented: ${target} video ${video ? 'enabled' : 'disabled'}`
      );
      // TODO: Implement video handling when video calling is supported
    }
  };

  /**
   * Get the Telnyx IDs associated with the call
   * This method returns an object containing the Telnyx session ID, leg ID, and call control ID.
   * @returns {Object} An object containing the Telnyx IDs
   */
  public get telnyxIds() {
    return {
      telnyxSessionId: this.telnyxSessionId,
      telnyxLegId: this.telnyxLegId,
      telnyxCallControlId: this.telnyxCallControlId,
    };
  }
  public invite = async () => {
    this.peer = await Peer.createOffer(this.options);
    this.setupTrickleIceCallbacks();

    // Initialize WebRTC reporter if debug mode is enabled
    if (this.debugEnabled) {
      this.initializeReporter();
    }

    // Store the custom headers we're sending with the invite
    this.inviteCustomHeaders = this.options.customHeaders || null;

    try {
      const msg = await this.connection.sendAndWait(
        createInviteMessage({
          attach: false,
          callOptions: this.options,
          sessionId: this.sessionId,
          callId: this.callId,
          sdp: this.getLocalSdpForSignaling(),
        })
      );

      if (!isInviteACKMessage(msg)) {
        throw new Error(`[Call] Invalid invite ACK message received: ${JSON.stringify(msg)}`);
      }
      this.callId = msg.result.callID;

      if (this.isTrickleIceEnabled()) {
        this.peer.flushPendingLocalCandidates();
      }
    } catch (error) {
      this.peer.clearPendingLocalCandidates();
      throw error;
    }
  };

  private isTrickleIceEnabled = () => Boolean(this.options.peerConnectionOptions?.useTrickleIce);

  private getLocalSdpForSignaling = () => {
    const sdp = this.peer?.localDescription?.sdp;
    if (!sdp) {
      throw new Error('[Call] Local SDP not available');
    }
    return this.isTrickleIceEnabled() ? prepareTrickleSdp(sdp) : sdp;
  };

  private setupTrickleIceCallbacks = () => {
    if (!this.peer || !this.isTrickleIceEnabled()) {
      return;
    }

    this.peer.onLocalCandidate = (candidate) => {
      this.sendLocalCandidate(candidate);
    };
    this.peer.onLocalEndOfCandidates = () => {
      this.sendLocalEndOfCandidates();
    };
  };

  private sendLocalCandidate = (candidate: TrickleIceCandidate) => {
    if (!this.isTrickleIceEnabled()) {
      return;
    }
    this.connection.send(
      createCandidateMessage({
        sessionId: this.sessionId,
        callId: this.callId,
        candidate: cleanCandidateString(candidate.candidate),
        sdpMid: candidate.sdpMid,
        sdpMLineIndex: candidate.sdpMLineIndex,
      })
    );
  };

  private sendLocalEndOfCandidates = () => {
    if (!this.isTrickleIceEnabled()) {
      return;
    }
    this.connection.send(
      createEndOfCandidatesMessage({
        sessionId: this.sessionId,
        callId: this.callId,
      })
    );
  };

  public handleRemoteCandidate = (candidate: TrickleIceCandidate) => {
    if (!this.peer) {
      log.warn('[Call] No peer connection available for remote ICE candidate');
      return;
    }
    this.peer.addRemoteCandidate({
      ...candidate,
      candidate: normalizeRemoteCandidateString(candidate.candidate),
    });
  };

  public handleRemoteEndOfCandidates = () => {
    if (!this.peer) {
      log.warn('[Call] No peer connection available for remote end-of-candidates');
      return;
    }
    this.peer.addRemoteEndOfCandidates();
  };

  private setState = (state: CallState) => {
    const prevState = this.state;
    this.state = state;

    // Log state change to call report collector
    this.callReportCollector?.log('info', 'Call state changed', {
      state,
      previousState: prevState,
    });

    // Start stats collection when call becomes active
    if (state === 'active') {
      this.startCallReportCollector();
    }

    if (state === 'dropped') {
      this.stopAndPostCallReport('dropped').catch((err) => {
        log.error('[Call] Failed to stop and post call report:', err);
      });
    }

    this.emit('telnyx.call.state', this, state);
  };

  /**
   * Set the call to connecting state (used for push notification calls when answered via CallKit)
   */
  public setConnecting = () => {
    log.debug('[Call] Setting state to connecting');
    this.setState('connecting');
  };

  /**
   * Set the call to dropped state (used when network connection is lost).
   * A dropped call is NOT terminated — it remains tracked and may be
   * reattached when the network recovers via the reconnection flow.
   */
  public setDropped = () => {
    log.debug('[Call] Setting state to dropped due to network loss');
    this.setState('dropped');
  };

  /**
   * Set the call to active state (used when answer event is received)
   */
  public setActive = () => {
    log.debug('[Call] Setting state to active');
    this.setState('active');
  };

  /**
   * Handle remote answer SDP from answer events
   * @param sdp - The SDP from the answer event
   */
  public handleRemoteAnswer = (sdp: string) => {
    log.debug('[Call] Handling remote answer SDP');

    if (!this.peer) {
      log.warn('[Call] No peer connection available for remote answer SDP');
      return;
    }

    try {
      this.peer.setRemoteDescription({ type: 'answer', sdp });
      log.debug('[Call] Remote answer SDP set successfully');
    } catch (error) {
      log.error('[Call] Failed to set remote answer SDP:', error);
    }
  };

  /**
   * Dispose of the peer connection and clean up resources
   * This should be called before creating a new peer for the same call
   */
  public disposePeer = () => {
    if (this.peer) {
      log.debug('[Call] Disposing of existing peer connection');
      this.peer.close();
      this.peer = null;
      log.debug('[Call] Peer connection disposed successfully');
    } else {
      log.debug('[Call] No peer connection to dispose');
    }
  };

  private onSocketMessage = (msg: unknown) => {
    if (isRingingEvent(msg) && msg.params.callID === this.callId) {
      return this.handleRingingEvent(msg);
    }

    // Answer events are now handled by the TelnyxRTC client to avoid duplication
    // The client will call handleRemoteAnswer() when needed

    if (isByeEvent(msg) && msg.params.callID === this.callId) {
      return this.handleHangupEvent(msg);
    }
  };

  private handleRingingEvent = (msg: RingingEvent) => {
    log.debug('[Call] Ringing event received', msg);
    this.setState('ringing');
    this.telnyxLegId = msg.params.telnyx_leg_id;
    this.telnyxSessionId = msg.params.telnyx_session_id;
  };

  private handleHangupEvent = (msg: ByeEvent) => {
    log.debug('[Call] Hangup event received', msg);

    // Log call end details to report collector
    this.callReportCollector?.log('info', 'Call ended', {
      cause: (msg.params as any)?.cause,
      causeCode: (msg.params as any)?.causeCode,
      sipCode: (msg.params as any)?.sipCode,
      sipReason: (msg.params as any)?.sipReason,
    });

    // Stop debug stats collection before ending the call
    this.stopDebugStats();

    this.closePeerAfterCallReport('done');
    this.setState('ended');
  };

  // --- Call Report Collector Integration ---

  private startCallReportCollector(): void {
    if (!this.callReportCollector) return;

    const pc = this.peer?.getPeerConnection();
    if (!pc) {
      log.warn('[Call] Cannot start call report collector: no peer connection');
      return;
    }

    // Wire up peer event logging to collector
    if (this.peer) {
      this.peer.onPeerEventLog = (event, context) => {
        this.callReportCollector?.log('info', event, context);
      };
    }

    this.callReportCollector.start(pc as any);

    // Set up intermediate flush for long calls
    this.callReportCollector.onFlushNeeded = () => {
      this.flushIntermediateReport();
    };
  }

  private closePeerAfterCallReport(finalState: 'done' | 'dropped'): void {
    if (!this.callReportCollector) {
      this.peer?.close();
      return;
    }

    this.stopAndPostCallReport(finalState)
      .catch((err) => {
        log.error('[Call] Failed to stop and post call report:', err);
      })
      .finally(() => {
        this.peer?.close();
      });
  }

  private async stopAndPostCallReport(finalState: 'done' | 'dropped'): Promise<void> {
    if (!this.callReportCollector) return;
    if (this.callReportPostingStarted) return;
    this.callReportPostingStarted = true;

    await this.callReportCollector.stop();

    const callReportId = CALL_REPORT_ID;
    if (!callReportId) {
      log.warn('[Call] Cannot post call report: missing call_report_id');
      return;
    }

    const summary = this.buildReportSummary(finalState);
    const payload = this.callReportCollector.buildPayload(summary);

    this.callReportCollector
      .sendPayload(payload, callReportId, PROD_HOST, VOICE_SDK_ID)
      .then(() => {
        log.debug('[Call] Posted call report');
      })
      .catch((err) => {
        log.error('[Call] Failed to send call report:', err);
      });
  }

  private flushIntermediateReport(): void {
    if (!this.callReportCollector) return;

    const callReportId = CALL_REPORT_ID;
    if (!callReportId) return;

    const summary = this.buildReportSummary('active');
    const payload = this.callReportCollector.flush(summary);

    this.callReportCollector
      .sendPayload(payload, callReportId, PROD_HOST, VOICE_SDK_ID)
      .catch((err) => {
        log.error('[Call] Failed to send intermediate call report:', err);
      });

    log.debug(`[Call] Flushed intermediate call report segment ${payload.segment}`);
  }

  private buildReportSummary(state: 'active' | 'done' | 'dropped'): CallReportSummary {
    const endTimestamp = state === 'active' ? null : new Date().toISOString();
    let durationSeconds: number | null = null;
    if (endTimestamp) {
      durationSeconds =
        (new Date(endTimestamp).getTime() - new Date(this.callStartTimestamp).getTime()) / 1000;
      durationSeconds = Math.round(durationSeconds * 100) / 100;
    }

    return {
      callId: this.callId,
      destinationNumber: this.options.destinationNumber || null,
      callerNumber: this.options.callerIdNumber || null,
      direction: this.direction === 'inbound' ? 'INCOMING' : 'OUTGOING',
      state,
      durationSeconds,
      telnyxSessionId: this.telnyxSessionId,
      telnyxLegId: this.telnyxLegId,
      voiceSdkSessionId: this.sessionId,
      sdkVersion: SDK_VERSION,
      startTimestamp: this.callStartTimestamp,
      endTimestamp,
    };
  }
}
