import { Mutex } from '@livekit/mutex';
import { EventEmitter } from 'events';
import { parse, write } from 'sdp-transform';
import type { MediaAttributes, MediaDescription, SessionDescription } from 'sdp-transform';
import type TypedEmitter from 'typed-emitter';
import log, { LoggerNames, getLogger } from '../logger';
import { debounce } from './debounce';
import { NegotiationError, UnexpectedConnectionState } from './errors';
import type { LoggerOptions } from './types';
import { ddExtensionURI, isSVCCodec, isSafari } from './utils';

/** @internal */
interface TrackBitrateInfo {
  cid?: string;
  transceiver?: RTCRtpTransceiver;
  codec: string;
  maxbr: number;
  isScreenShare?: boolean;
}

/*
 * Video codecs use a very low bitrate at the beginning and increase slowly by
 * the bandwidth estimator until they reach the target bitrate. The process commonly
 * costs more than 10 seconds causing subscribers to get blurry video at the first
 * few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
 *
 * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
 * Why same for all codecs: Target bitrate already accounts for codec efficiency
 * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
 * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
 */
const startBitrateMultiplier = 0.9;

/** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
const maxStartBitrateKbps = 1000;

const debounceInterval = 20;

/**
 * Applies the configured start bitrate when this media section belongs to `cid`.
 * This SDP munging is used for a bitrate setting that cannot be applied through
 * `RTCRtpEncodingParameters`.
 *
 * Returns `undefined` when the section does not belong to the track, `0` when
 * it does but does not offer the requested codec, and the codec payload when the
 * requested codec is present (whether the bitrate was added or already set).
 *
 * @internal
 */
export function applyVideoStartBitrate(
  media: MediaDescription,
  cid: string,
  codec: string,
  maxbr: number,
  isScreenShare = false,
): number | undefined {
  if (!media.msid?.includes(cid)) {
    return undefined;
  }

  const codecPayload =
    media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
  if (codecPayload === 0) {
    return 0;
  }

  // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE
  // from starting too aggressively. Screen share is not capped since text/UI
  // clarity requires high bitrate from the start.
  // TODO: dynamically adjust start bitrate based on network conditions (e.g., previous BWE estimate)
  const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
  const startBitrate = isScreenShare
    ? calculatedStartBitrate
    : Math.min(calculatedStartBitrate, maxStartBitrateKbps);

  const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
  if (fmtp) {
    // If another track's fmtp already has a start bitrate, it cannot be
    // overridden here because the payload type is shared across the bundle.
    // This forces every track sharing that payload to use the initial track's
    // start bitrate.
    if (!fmtp.config.includes('x-google-start-bitrate')) {
      fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
    }
  } else {
    // VP8 and some codecs may not have an existing fmtp line.
    media.fmtp.push({
      payload: codecPayload,
      config: `x-google-start-bitrate=${startBitrate}`,
    });
  }

  return codecPayload;
}

export const PCEvents = {
  NegotiationStarted: 'negotiationStarted',
  NegotiationComplete: 'negotiationComplete',
  // Fired with the offerId for every successful publisher answer application,
  // including answers that immediately recurse into another offer via
  // `renegotiate`. Use this rather than NegotiationComplete to know that a
  // specific offer has been negotiated end-to-end.
  OfferAnswered: 'offerAnswered',
  RTPVideoPayloadTypes: 'rtpVideoPayloadTypes',
} as const;

/** @internal */
export default class PCTransport extends (EventEmitter as new () => TypedEmitter<PCTransportEventCallbacks>) {
  private _pc: RTCPeerConnection | null;

  private get pc() {
    if (!this._pc) {
      this._pc = this.createPC();
    }
    return this._pc;
  }

  private config?: RTCConfiguration;

  private log = log;

  private iceLog = log;

  private loggerOptions: LoggerOptions;

  private ddExtID = 0;

  latestOfferId: number = 0;

  latestAcknowledgedOfferId: number = 0;

  private offerLock: Mutex;

  private pendingInitialOffer?: RTCSessionDescriptionInit;

  pendingCandidates: RTCIceCandidateInit[] = [];

  restartingIce: boolean = false;

  renegotiate: boolean = false;

  trackBitrates: TrackBitrateInfo[] = [];

  remoteStereoMids: string[] = [];

  remoteNackMids: string[] = [];

  onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void;

  onIceCandidate?: (candidate: RTCIceCandidate) => void;

  onIceCandidateError?: (ev: Event) => void;

  onConnectionStateChange?: (state: RTCPeerConnectionState) => void;

  onIceConnectionStateChange?: (state: RTCIceConnectionState) => void;

  onSignalingStatechange?: (state: RTCSignalingState) => void;

  onDataChannel?: (ev: RTCDataChannelEvent) => void;

  onTrack?: (ev: RTCTrackEvent) => void;

  constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
    super();
    this.loggerOptions = loggerOptions;
    this.log = getLogger(
      loggerOptions.loggerName ?? LoggerNames.PCTransport,
      () => this.logContext,
    );
    this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
    this.config = config;
    this._pc = this.createPC();
    this.offerLock = new Mutex();
  }

  private createPC() {
    const pc = new RTCPeerConnection(this.config);

    pc.onicecandidate = (ev) => {
      if (!ev.candidate) return;
      this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate });
      this.onIceCandidate?.(ev.candidate);
    };
    pc.onicecandidateerror = (ev) => {
      this.iceLog.debug('ICE candidate error', { event: ev });
      this.onIceCandidateError?.(ev);
    };

    pc.oniceconnectionstatechange = () => {
      this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`);
      this.onIceConnectionStateChange?.(pc.iceConnectionState);
    };

    pc.onsignalingstatechange = () => {
      this.log.debug(`signaling state: ${pc.signalingState}`);
      this.onSignalingStatechange?.(pc.signalingState);
    };

    pc.onconnectionstatechange = () => {
      this.log.debug(`connection state: ${pc.connectionState}`);
      this.onConnectionStateChange?.(pc.connectionState);
    };
    pc.ondatachannel = (ev) => {
      this.log.debug('data channel opened by peer', {
        label: ev.channel.label,
        id: ev.channel.id,
      });
      this.onDataChannel?.(ev);
    };
    pc.ontrack = (ev) => {
      this.onTrack?.(ev);
    };
    return pc;
  }

  private get logContext() {
    return {
      ...this.loggerOptions.loggerContextCb?.(),
    };
  }

  get isICEConnected(): boolean {
    return (
      this._pc !== null &&
      (this.pc.iceConnectionState === 'connected' || this.pc.iceConnectionState === 'completed')
    );
  }

  async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
    if (this.pc.remoteDescription && !this.restartingIce) {
      return this.pc.addIceCandidate(candidate);
    }
    this.iceLog.debug('queuing remote ICE candidate until remote description applied', {
      pendingCount: this.pendingCandidates.length + 1,
    });
    this.pendingCandidates.push(candidate);
  }

  async setRemoteDescription(sd: RTCSessionDescriptionInit, offerId: number): Promise<boolean> {
    if (
      sd.type === 'answer' &&
      this.latestOfferId > 0 &&
      offerId > 0 &&
      offerId !== this.latestOfferId
    ) {
      this.log.warn('ignoring answer for old offer', {
        offerId,
        latestOfferId: this.latestOfferId,
      });
      return false;
    }
    let mungedSDP: string | undefined = undefined;
    if (sd.type === 'offer') {
      let { stereoMids, nackMids } = extractStereoAndNackAudioFromOffer(sd);
      this.remoteStereoMids = stereoMids;
      this.remoteNackMids = nackMids;
    } else if (sd.type === 'answer') {
      if (this.pendingInitialOffer && this._pc) {
        const initialOffer = this.pendingInitialOffer;
        this.pendingInitialOffer = undefined;
        const sdpParsed = parse(initialOffer.sdp ?? '');
        sdpParsed.media.forEach((media) => {
          ensureIPAddrMatchVersion(media);
        });
        this.log.debug('setting pending initial offer before processing answer');
        await this.setMungedSDP(initialOffer, write(sdpParsed));
      }
      const sdpParsed = parse(sd.sdp ?? '');
      sdpParsed.media.forEach((media) => {
        const mid = getMidString(media.mid!);
        if (media.type === 'audio') {
          // munge sdp for opus bitrate settings
          this.trackBitrates.some((trackbr): boolean => {
            if (!trackbr.transceiver || mid != trackbr.transceiver.mid) {
              return false;
            }

            let codecPayload = 0;
            media.rtp.some((rtp): boolean => {
              if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) {
                codecPayload = rtp.payload;
                return true;
              }
              return false;
            });

            if (codecPayload === 0) {
              return true;
            }

            let fmtpFound = false;
            for (const fmtp of media.fmtp) {
              if (fmtp.payload === codecPayload) {
                fmtp.config = fmtp.config
                  .split(';')
                  .filter((attr) => !attr.includes('maxaveragebitrate'))
                  .join(';');
                if (trackbr.maxbr > 0) {
                  fmtp.config += `;maxaveragebitrate=${trackbr.maxbr * 1000}`;
                }
                fmtpFound = true;
                break;
              }
            }

            if (!fmtpFound) {
              if (trackbr.maxbr > 0) {
                media.fmtp.push({
                  payload: codecPayload,
                  config: `maxaveragebitrate=${trackbr.maxbr * 1000}`,
                });
              }
            }

            return true;
          });
        }
      });
      // The server's answer sets per-track codec params (e.g. opus `usedtx`) on
      // the sections mapped to a published track, but leaves the pre-populated
      // recvonly placeholder sections with defaults. Conform the placeholders so
      // each shared payload type is consistent across the bundle, otherwise
      // libwebrtc flags a "bundled payload type collision".
      const placeholderMids = this.getPlaceholderMids();
      if (placeholderMids.size > 0) {
        conformBundledCodecFmtp(sdpParsed.media, (media) =>
          placeholderMids.has(getMidString(media.mid!)),
        );
      }
      mungedSDP = write(sdpParsed);
    }
    await this.setMungedSDP(sd, mungedSDP, true);

    if (this.pendingCandidates.length > 0) {
      this.iceLog.debug('flushing queued ICE candidates', {
        count: this.pendingCandidates.length,
      });
    }
    this.pendingCandidates.forEach((candidate) => {
      this.pc.addIceCandidate(candidate);
    });
    this.pendingCandidates = [];
    this.restartingIce = false;

    // Fire OfferAnswered for every successfully applied answer, including the
    // ones that recurse into another offer via `renegotiate`. Callers waiting
    // on a specific offerId can resolve as soon as their offer's answer is in.
    if (sd.type === 'answer') {
      this.latestAcknowledgedOfferId = offerId;
      this.emit(PCEvents.OfferAnswered, offerId);
    }

    if (this.renegotiate) {
      this.renegotiate = false;
      await this.createAndSendOffer();
    } else if (sd.type === 'answer') {
      this.emit(PCEvents.NegotiationComplete);
      if (sd.sdp) {
        const sdpParsed = parse(sd.sdp);
        sdpParsed.media.forEach((media) => {
          if (media.type === 'video') {
            this.emit(PCEvents.RTPVideoPayloadTypes, media.rtp);
          }
        });
      }
    }
    return true;
  }

  // debounced negotiate interface
  negotiate = debounce(async (onError?: (e: Error) => void) => {
    this.emit(PCEvents.NegotiationStarted);
    try {
      await this.createAndSendOffer();
    } catch (e) {
      if (onError) {
        onError(e as Error);
      } else {
        throw e;
      }
    }
  }, debounceInterval);

  async createInitialOffer() {
    const unlock = await this.offerLock.lock();
    try {
      if (this.pc.signalingState !== 'stable') {
        this.log.warn('signaling state is not stable, cannot create initial offer');
        return;
      }
      const offerId = this.latestOfferId + 1;
      this.latestOfferId = offerId;
      const offer = await this.pc.createOffer();
      this.pendingInitialOffer = { sdp: offer.sdp, type: offer.type };
      const sdpParsed = parse(offer.sdp ?? '');
      sdpParsed.media.forEach((media) => {
        ensureIPAddrMatchVersion(media);
      });
      offer.sdp = write(sdpParsed);
      return { offer, offerId };
    } finally {
      unlock();
    }
  }

  async createAndSendOffer(options?: RTCOfferOptions) {
    const unlock = await this.offerLock.lock();

    try {
      if (this.onOffer === undefined) {
        return;
      }

      if (options?.iceRestart) {
        this.iceLog.debug('restarting ICE');
        this.restartingIce = true;
      }

      if (
        this._pc &&
        (this._pc.signalingState === 'have-local-offer' || this.pendingInitialOffer)
      ) {
        // we're waiting for the peer to accept our offer, so we'll just wait
        // the only exception to this is when ICE restart is needed
        const currentSD = this._pc.remoteDescription;
        if (options?.iceRestart && currentSD) {
          // roll the remote description back in so createOffer produces a valid
          // ICE-restart offer on top of the already-negotiated state
          await this._pc.setRemoteDescription(currentSD);
        } else if (options?.iceRestart) {
          // ICE restart with no remote description to restart on: `renegotiate` would stall
          // (the pending offer is never answered), so throw for the caller to recreate the PC.
          throw new NegotiationError(
            'ICE restart requested without a remote description, peer connection must be recreated',
          );
        } else {
          this.renegotiate = true;
          this.log.debug('requesting renegotiation');
          return;
        }
      } else if (!this._pc || this._pc.signalingState === 'closed') {
        this.log.warn('could not createOffer with closed peer connection');
        return;
      }

      // actually negotiate
      this.log.debug('starting to negotiate');
      // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
      const offerId = this.latestOfferId + 1;
      this.latestOfferId = offerId;
      const offer = await this.pc.createOffer(options);
      this.log.debug('original offer', { sdp: offer.sdp });

      const sdpParsed = parse(offer.sdp ?? '');
      sdpParsed.media.forEach((media) => {
        ensureIPAddrMatchVersion(media);
        if (media.type === 'audio') {
          ensureAudioNackAndStereo(media, ['all'], []);
        } else if (media.type === 'video') {
          this.trackBitrates.some((trackbr): boolean => {
            if (!trackbr.cid) {
              return false;
            }

            const codecPayload = applyVideoStartBitrate(
              media,
              trackbr.cid,
              trackbr.codec,
              trackbr.maxbr,
              trackbr.isScreenShare,
            );
            if (codecPayload === undefined) {
              return false;
            }

            if (codecPayload > 0 && isSVCCodec(trackbr.codec) && !isSafari()) {
              this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
            }

            return true;
          });
        }
      });
      // Conform the placeholder sections (pre-populated, or reverted after an
      // unpublish) so every shared payload type carries identical fmtp across the
      // bundle, otherwise libwebrtc flags a "bundled payload type collision".
      // Detection is by transceiver (mids are stable across renegotiations) since
      // an unpublished section keeps its `a=msid`.
      const placeholderMids = this.getPlaceholderMids();
      if (placeholderMids.size > 0) {
        conformBundledCodecFmtp(sdpParsed.media, (media) =>
          placeholderMids.has(getMidString(media.mid!)),
        );
      }
      if (this.latestOfferId > offerId) {
        this.log.warn('latestOfferId mismatch', {
          latestOfferId: this.latestOfferId,
          offerId,
        });
        return;
      }
      await this.setMungedSDP(offer, write(sdpParsed));
      this.onOffer(offer, this.latestOfferId);
    } finally {
      unlock();
    }
  }

  async createAndSetAnswer(): Promise<RTCSessionDescriptionInit> {
    const answer = await this.pc.createAnswer();
    const sdpParsed = parse(answer.sdp ?? '');
    sdpParsed.media.forEach((media) => {
      ensureIPAddrMatchVersion(media);
      if (media.type === 'audio') {
        ensureAudioNackAndStereo(media, this.remoteStereoMids, this.remoteNackMids);
      }
    });
    await this.setMungedSDP(answer, write(sdpParsed));
    return answer;
  }

  /**
   * Returns the mids of transceivers that carry no outgoing track on this
   * (publisher) connection: the pre-populated placeholders added by
   * `RTCEngine.applyInitialPublisherLayout`, plus any transceiver that was used
   * for a track and reverted on unpublish. Their codec fmtp is conformed to the
   * published tracks so a shared payload type stays consistent across the bundle.
   */
  private getPlaceholderMids(): Set<string> {
    return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []);
  }

  createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
    return this.pc.createDataChannel(label, dataChannelDict);
  }

  addTransceiver(mediaStreamTrack: MediaStreamTrack, transceiverInit: RTCRtpTransceiverInit) {
    return this.pc.addTransceiver(mediaStreamTrack, transceiverInit);
  }

  addTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) {
    return this.pc.addTransceiver(kind, transceiverInit);
  }

  addTrack(track: MediaStreamTrack) {
    if (!this._pc) {
      throw new UnexpectedConnectionState('PC closed, cannot add track');
    }
    return this._pc.addTrack(track);
  }

  setTrackCodecBitrate(info: TrackBitrateInfo) {
    this.trackBitrates.push(info);
  }

  setConfiguration(rtcConfig: RTCConfiguration) {
    if (!this._pc) {
      throw new UnexpectedConnectionState('PC closed, cannot configure');
    }
    return this._pc?.setConfiguration(rtcConfig);
  }

  canRemoveTrack(): boolean {
    return !!this._pc?.removeTrack;
  }

  removeTrack(sender: RTCRtpSender) {
    return this._pc?.removeTrack(sender);
  }

  getConnectionState() {
    return this._pc?.connectionState ?? 'closed';
  }

  getICEConnectionState() {
    return this._pc?.iceConnectionState ?? 'closed';
  }

  getSignallingState() {
    return this._pc?.signalingState ?? 'closed';
  }

  getTransceivers() {
    return this._pc?.getTransceivers() ?? [];
  }

  getSenders() {
    return this._pc?.getSenders() ?? [];
  }

  getLocalDescription() {
    return this._pc?.localDescription;
  }

  getRemoteDescription() {
    return this.pc?.remoteDescription;
  }

  /** stats of the underlying connection, `undefined` when there is none */
  getStats() {
    return this._pc?.getStats();
  }

  getMaxMessageSize() {
    return this._pc?.sctp?.maxMessageSize;
  }

  async getConnectedAddress(): Promise<string | undefined> {
    if (!this._pc) {
      return;
    }
    let selectedCandidatePairId = '';
    const candidatePairs = new Map<string, RTCIceCandidatePairStats>();
    // id -> candidate ip
    const candidates = new Map<string, string>();
    const stats: RTCStatsReport = await this._pc.getStats();
    stats.forEach((v) => {
      switch (v.type) {
        case 'transport':
          selectedCandidatePairId = v.selectedCandidatePairId;
          break;
        case 'candidate-pair':
          if (selectedCandidatePairId === '' && v.selected) {
            selectedCandidatePairId = v.id;
          }
          candidatePairs.set(v.id, v);
          break;
        case 'remote-candidate':
          candidates.set(v.id, `${v.address}:${v.port}`);
          break;
        default:
      }
    });

    if (selectedCandidatePairId === '') {
      return undefined;
    }
    const selectedID = candidatePairs.get(selectedCandidatePairId)?.remoteCandidateId;
    if (selectedID === undefined) {
      return undefined;
    }
    return candidates.get(selectedID);
  }

  close = () => {
    if (!this._pc) {
      return;
    }
    this.log.debug('closing peer connection');
    this.pendingInitialOffer = undefined;
    this._pc.close();
    this._pc.onconnectionstatechange = null;
    this._pc.oniceconnectionstatechange = null;
    this._pc.onicegatheringstatechange = null;
    this._pc.ondatachannel = null;
    this._pc.onnegotiationneeded = null;
    this._pc.onsignalingstatechange = null;
    this._pc.onicecandidate = null;
    this._pc.ondatachannel = null;
    this._pc.ontrack = null;
    this._pc.onconnectionstatechange = null;
    this._pc.oniceconnectionstatechange = null;
    this._pc = null;
  };

  private async setMungedSDP(sd: RTCSessionDescriptionInit, munged?: string, remote?: boolean) {
    const originalSdp = sd.sdp;
    if (munged) {
      sd.sdp = munged;
      try {
        this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`);
        if (remote) {
          await this.pc.setRemoteDescription(sd);
        } else {
          await this.pc.setLocalDescription(sd);
        }
        return;
      } catch (e) {
        this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, {
          error: e,
          mungedSdp: munged,
          originalSdp,
        });
        sd.sdp = originalSdp;
      }
    }

    try {
      if (remote) {
        await this._pc?.setRemoteDescription(sd);
      } else {
        await this._pc?.setLocalDescription(sd);
      }
    } catch (e) {
      let msg = 'unknown error';
      if (e instanceof Error) {
        msg = e.message;
      } else if (typeof e === 'string') {
        msg = e;
      }

      const fields: any = {
        error: msg,
        sdp: sd.sdp,
      };
      if (munged && munged !== originalSdp) {
        fields.mungedSdp = munged;
      }
      if (!remote && this.pc.remoteDescription) {
        fields.remoteSdp = this.pc.remoteDescription;
      }
      this.log.error(`unable to set ${sd.type}`, { fields });
      throw new NegotiationError(msg);
    }
  }
}

/**
 * Adds the AV1 dependency descriptor extension to `media` unless it is already there, and
 * returns the id it is mapped to so callers can pass it back in as `ddExtID` (0 when no id has
 * been chosen yet).
 *
 * A bundle has to map one URI to one id, so an id already in use for the extension anywhere in
 * `sdp` wins over both the cached one and a fresh one: Chrome advertises the extension itself on
 * sections it can send on, and an earlier offer may have munged it into others.
 * @internal
 */
export function ensureVideoDDExtension(
  media: {
    type: string;
    port: number;
    protocol: string;
    payloads?: string | undefined;
  } & MediaDescription,
  sdp: SessionDescription,
  ddExtID: number,
): number {
  const id = ddExtensionIDFor(sdp, ddExtID);
  if (id === undefined) {
    return ddExtID;
  }

  if (!media.ext?.some((ext) => ext.uri === ddExtensionURI)) {
    media.ext ??= [];
    media.ext.push({
      value: id,
      uri: ddExtensionURI,
    });
  }
  return id;
}

/**
 * The id to map the dependency descriptor to throughout `sdp`, or undefined when no id would be
 * consistent for the whole bundle and the extension therefore has to be left out.
 */
function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined {
  const mapped = mappedExtensionID(sdp, ddExtensionURI);
  if (mapped !== undefined) {
    // Adopting an id that also stands for another URI is what the browser rejects the bundle
    // over, and its own half of the map is not ours to renumber, so give up on this offer.
    return usedForOtherURI(sdp, mapped, ddExtensionURI) ? undefined : mapped;
  }
  // Reusing the id from the last offer keeps the mapping stable across renegotiations, but only
  // while nothing else has taken it: the browser assigns ids to its own extensions without
  // knowing about ours, so an id that was free when we picked it can since have been claimed —
  // typically by the fuller extension set that arrives with the first section we send on.
  if (cachedID !== 0 && !usedForOtherURI(sdp, cachedID, ddExtensionURI)) {
    return cachedID;
  }
  return unusedExtensionID(sdp);
}

/** The id `uri` is mapped to in `sdp`, if any section maps it. */
function mappedExtensionID(sdp: SessionDescription, uri: string): number | undefined {
  for (const media of sdp.media) {
    const ext = media.ext?.find((candidate) => candidate.uri === uri);
    if (ext) {
      return ext.value;
    }
  }
  return undefined;
}

/** Whether `id` stands for anything in `sdp` other than `uri`. */
function usedForOtherURI(sdp: SessionDescription, id: number, uri: string): boolean {
  return sdp.media.some((media) => media.ext?.some((ext) => ext.value === id && ext.uri !== uri));
}

/**
 * An id no extension in `sdp` uses. Stays above every id in use rather than filling gaps, so it
 * is less likely to be an id the browser goes on to allocate to another extension, and steps
 * over 15, which RFC 8285 reserves.
 */
function unusedExtensionID(sdp: SessionDescription): number {
  let maxID = 0;
  sdp.media.forEach((media) => {
    media.ext?.forEach((ext) => {
      if (ext.value > maxID) {
        maxID = ext.value;
      }
    });
  });
  return maxID + 1 === 15 ? 16 : maxID + 1;
}

/**
 * Checks whether an fmtp config declares `param` as an exact, `;`-delimited
 * token. A plain substring check conflates distinct opus parameters — e.g.
 * `stereo=1` is a substring of `sprop-stereo=1` — so `param` must match a whole
 * parameter, not appear anywhere within the config string.
 * @internal
 */
export function fmtpConfigHasParam(config: string, param: string): boolean {
  return config.split(';').some((entry) => entry.trim() === param);
}

/** @internal */
export function ensureAudioNackAndStereo(
  media: {
    type: string;
    port: number;
    protocol: string;
    payloads?: string | undefined;
  } & MediaDescription,
  stereoMids: string[],
  nackMids: string[],
) {
  // sdp-transform types don't include number however the parser outputs mids as numbers in some cases
  const mid = getMidString(media.mid!);

  // found opus codec to add nack fb
  let opusPayload = 0;
  media.rtp.some((rtp): boolean => {
    // rtpmap encoding names are case-insensitive (RFC 4855)
    if (rtp.codec.toLowerCase() === 'opus') {
      opusPayload = rtp.payload;
      return true;
    }
    return false;
  });

  // add nack rtcpfb if not exist
  if (opusPayload > 0) {
    if (!media.rtcpFb) {
      media.rtcpFb = [];
    }

    if (
      nackMids.includes(mid) &&
      !media.rtcpFb.some((fb) => fb.payload === opusPayload && fb.type === 'nack')
    ) {
      media.rtcpFb.push({
        payload: opusPayload,
        type: 'nack',
      });
    }

    if (stereoMids.includes(mid) || (stereoMids.length === 1 && stereoMids[0] === 'all')) {
      media.fmtp.some((fmtp): boolean => {
        if (fmtp.payload === opusPayload) {
          if (!fmtpConfigHasParam(fmtp.config, 'stereo=1')) {
            fmtp.config += ';stereo=1';
          }
          return true;
        }
        return false;
      });
    }
  }
}

/**
 * Returns the mids of transceivers that carry no outgoing track: the
 * pre-populated placeholders added by `RTCEngine.applyInitialPublisherLayout`,
 * plus any transceiver that was used for a track and reverted on unpublish. The
 * `sender.track` check is the reliable signal — an unpublished section keeps its
 * `a=msid` (and its stale send-derived fmtp), so it can't be told apart from a
 * real send by SDP alone. Transceiver mids are stable across renegotiations, so
 * this works for every offer/answer after the first.
 * @internal
 */
export function placeholderMidsFromTransceivers(
  transceivers: readonly RTCRtpTransceiver[],
): Set<string> {
  const mids = new Set<string>();
  for (const transceiver of transceivers) {
    if (transceiver.mid && !transceiver.sender.track) {
      mids.add(transceiver.mid);
    }
  }
  return mids;
}

/**
 * Within a BUNDLE group a payload type must map to identical codec parameters
 * across every m-line. When the same payload type carries different fmtp between
 * sections — e.g. opus `usedtx=1` on the published microphone but not on the
 * pre-populated recvonly placeholders, or H.265 with different `level-id` between
 * a published video track and a placeholder — libwebrtc flags a "bundled payload
 * type collision".
 *
 * Rewrite the placeholder sections so every shared payload type carries the
 * same fmtp. Real (non-placeholder) sections always win the canonical value, so
 * a published track's encoder parameters are never altered. When no real
 * section declares a payload type — e.g. a placeholder that was reused for a
 * track and then reverted to recvonly keeps its send-derived `level-id` while
 * fresh placeholders use the default — the placeholders still converge on the
 * first value seen, so two placeholders can't disagree either. Only placeholder
 * sections are ever rewritten, and it is codec-agnostic (opus, H.265, ...).
 * `isPlaceholder` identifies the sections to conform.
 * @internal
 */
export function conformBundledCodecFmtp(
  media: MediaDescription[],
  isPlaceholder: (media: MediaDescription) => boolean,
) {
  // Canonical fmtp per payload type. Payload types are unique within a BUNDLE
  // group, so keying by payload alone (across audio and video) is safe. A real
  // section's value always takes precedence; otherwise the first placeholder
  // value seen is used so divergent placeholders still converge.
  const canonicalByPayload = new Map<number, string>();
  const fromRealSection = new Set<number>();
  for (const m of media) {
    const placeholder = isPlaceholder(m);
    for (const fmtp of m.fmtp ?? []) {
      if (!placeholder) {
        canonicalByPayload.set(fmtp.payload, fmtp.config);
        fromRealSection.add(fmtp.payload);
      } else if (!canonicalByPayload.has(fmtp.payload)) {
        canonicalByPayload.set(fmtp.payload, fmtp.config);
      }
    }
  }
  if (canonicalByPayload.size === 0) {
    return;
  }

  // Conform placeholder sections to the canonical fmtp for each shared payload.
  for (const m of media) {
    if (!isPlaceholder(m)) {
      continue;
    }
    for (const fmtp of m.fmtp ?? []) {
      const config = canonicalByPayload.get(fmtp.payload);
      if (config !== undefined && fmtp.config !== config) {
        fmtp.config = config;
      }
    }
  }
}

/** @internal */
export function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
  stereoMids: string[];
  nackMids: string[];
} {
  const stereoMids: string[] = [];
  const nackMids: string[] = [];
  const sdpParsed = parse(offer.sdp ?? '');
  let opusPayload = 0;
  sdpParsed.media.forEach((media) => {
    const mid = getMidString(media.mid!);
    if (media.type === 'audio') {
      media.rtp.some((rtp): boolean => {
        // rtpmap encoding names are case-insensitive (RFC 4855)
        if (rtp.codec.toLowerCase() === 'opus') {
          opusPayload = rtp.payload;
          return true;
        }
        return false;
      });

      if (media.rtcpFb?.some((fb) => fb.payload === opusPayload && fb.type === 'nack')) {
        nackMids.push(mid);
      }

      media.fmtp.some((fmtp): boolean => {
        if (fmtp.payload === opusPayload) {
          if (fmtpConfigHasParam(fmtp.config, 'sprop-stereo=1')) {
            stereoMids.push(mid);
          }
          return true;
        }
        return false;
      });
    }
  });
  return { stereoMids, nackMids };
}

function ensureIPAddrMatchVersion(media: MediaDescription) {
  // Chrome could generate sdp with c = IN IP4 <ipv6 addr>
  // in edge case and return error when set sdp.This is not a
  // sdk error but correct it if the issue detected.
  if (media.connection) {
    const isV6 = media.connection.ip.indexOf(':') >= 0;
    if ((media.connection.version === 4 && isV6) || (media.connection.version === 6 && !isV6)) {
      // fallback to dummy address
      media.connection.ip = '0.0.0.0';
      media.connection.version = 4;
    }
  }
}

function getMidString(mid: string | number) {
  return typeof mid === 'number' ? mid.toFixed(0) : mid;
}

type PCTransportEventCallbacks = {
  negotiationStarted: () => void;
  negotiationComplete: () => void;
  offerAnswered: (offerId: number) => void;
  rtpVideoPayloadTypes: (attributes: MediaAttributes['rtp']) => void;
};
