import type {
  AggregatedStatsReport,
  AudioAggregatedStats,
  BaseStats,
  CameraStats,
  ParticipantsStatsReport,
  RTCCodecStats,
  RTCMediaSourceStats,
  StatsReport,
} from './types';
import { CallState } from '../store';
import { Publisher, Subscriber } from '../rtc';
import { flatten } from './utils';
import { TrackType } from '../gen/video/sfu/models/models';
import { isFirefox } from '../helpers/browsers';
import { videoLoggerSystem } from '../logger';

export type StatsReporterOpts = {
  subscriber: Subscriber;
  publisher?: Publisher;
  state: CallState;
  datacenter: string;
  pollingIntervalInMs?: number;
};

type PeerConnectionKind = 'subscriber' | 'publisher';

export type StatsReporter = {
  /**
   * Will turn on stats reporting for a given sessionId.
   *
   * @param sessionId the session id.
   */
  startReportingStatsFor: (sessionId: string) => void;

  /**
   * Will turn off stats reporting for a given sessionId.
   *
   * @param sessionId the session id.
   */
  stopReportingStatsFor: (sessionId: string) => void;

  /**
   * Helper method for retrieving stats for a given peer connection kind
   * and media stream flowing through it.
   *
   * @param kind the peer connection kind (subscriber or publisher).
   * @param mediaStream the media stream.
   */
  getStatsForStream: (
    kind: PeerConnectionKind,
    tracks: MediaStreamTrack[],
  ) => Promise<StatsReport[]>;

  /**
   * Helper method for retrieving raw stats for a given peer connection kind.
   *
   * @param kind the peer connection kind (subscriber or publisher).
   * @param selector the track selector. If not provided, stats for all tracks will be returned.
   */
  getRawStatsForTrack: (
    kind: PeerConnectionKind,
    selector?: MediaStreamTrack,
  ) => Promise<RTCStatsReport | undefined>;

  /**
   * Stops the stats reporter and releases all resources.
   */
  stop: () => void;
};

/**
 * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
 */
export const createStatsReporter = ({
  subscriber,
  publisher,
  state,
  datacenter,
  pollingIntervalInMs = 2000,
}: StatsReporterOpts): StatsReporter => {
  const logger = videoLoggerSystem.getLogger('stats');
  const getRawStatsForTrack = async (
    kind: PeerConnectionKind,
    selector?: MediaStreamTrack,
  ) => {
    if (kind === 'subscriber' && subscriber) {
      return subscriber.getStats(selector);
    } else if (kind === 'publisher' && publisher) {
      return publisher.getStats(selector);
    } else {
      return undefined;
    }
  };

  const getStatsForStream = async (
    kind: PeerConnectionKind,
    tracks: MediaStreamTrack[],
  ) => {
    const pc = kind === 'subscriber' ? subscriber : publisher;
    if (!pc) return [];
    const statsForStream: StatsReport[] = [];
    for (const track of tracks) {
      const report = await pc.getStats(track);
      const stats = transform(report, {
        trackKind: track.kind as 'audio' | 'video',
        kind,
        publisher: undefined,
      });
      statsForStream.push(stats);
    }
    return statsForStream;
  };

  const startReportingStatsFor = (sessionId: string) => {
    sessionIdsToTrack.add(sessionId);
    void run();
  };

  const stopReportingStatsFor = (sessionId: string) => {
    sessionIdsToTrack.delete(sessionId);
    void run();
  };

  const sessionIdsToTrack = new Set<string>();

  /**
   * The main stats reporting loop.
   */
  const run = async () => {
    const participantStats: ParticipantsStatsReport = {};
    if (sessionIdsToTrack.size > 0) {
      const sessionIds = new Set(sessionIdsToTrack);
      for (const participant of state.participants) {
        if (!sessionIds.has(participant.sessionId)) continue;
        const {
          audioStream,
          isLocalParticipant,
          sessionId,
          userId,
          videoStream,
        } = participant;
        const kind = isLocalParticipant ? 'publisher' : 'subscriber';
        try {
          const tracks = isLocalParticipant
            ? publisher?.getPublishedTracks() || []
            : [
                ...(videoStream?.getVideoTracks() || []),
                ...(audioStream?.getAudioTracks() || []),
              ];
          participantStats[sessionId] = await getStatsForStream(kind, tracks);
        } catch (e) {
          logger.warn(`Failed to collect ${kind} stats for ${userId}`, e);
        }
      }
    }

    const [subscriberRawStats, publisherRawStats] = await Promise.all([
      getRawStatsForTrack('subscriber'),
      publisher ? getRawStatsForTrack('publisher') : undefined,
    ]);

    const process = (report: RTCStatsReport, kind: PeerConnectionKind) => {
      const videoStats = aggregate(
        transform(report, { kind, trackKind: 'video', publisher }),
      );
      const audioStats = aggregateAudio(
        transform(report, { kind, trackKind: 'audio', publisher }),
      );
      return {
        videoStats,
        audioStats,
      };
    };

    const subscriberResult = subscriberRawStats
      ? process(subscriberRawStats, 'subscriber')
      : { videoStats: getEmptyVideoStats(), audioStats: getEmptyAudioStats() };
    const publisherResult = publisherRawStats
      ? process(publisherRawStats, 'publisher')
      : { videoStats: getEmptyVideoStats(), audioStats: getEmptyAudioStats() };

    state.setCallStatsReport({
      datacenter,
      publisherStats: publisherResult.videoStats,
      publisherAudioStats: publisherResult.audioStats,
      subscriberStats: subscriberResult.videoStats,
      subscriberAudioStats: subscriberResult.audioStats,
      subscriberRawStats,
      publisherRawStats,
      participants: participantStats,
      timestamp: Date.now(),
    });
  };

  let timeoutId: NodeJS.Timeout | undefined;
  if (pollingIntervalInMs > 0) {
    const loop = async () => {
      // bail out of the loop as we don't want to collect stats
      // (they are expensive) if no one is listening to them
      if (state.isCallStatsReportObserved) {
        await run().catch((e) => {
          logger.debug('Failed to collect stats', e);
        });
      }
      timeoutId = setTimeout(loop, pollingIntervalInMs);
    };
    void loop();
  }

  const stop = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
  };

  return {
    getRawStatsForTrack,
    getStatsForStream,
    startReportingStatsFor,
    stopReportingStatsFor,
    stop,
  };
};

export type StatsTransformOpts = {
  /**
   * The kind of track we are transforming stats for.
   */
  trackKind: 'audio' | 'video';
  /**
   * The kind of peer connection we are transforming stats for.
   */
  kind: PeerConnectionKind;
  /**
   * The publisher instance.
   */
  publisher: Publisher | undefined;
};

/**
 * Extracts camera statistics from a media source.
 *
 * @param mediaSource the media source stats to extract camera info from.
 */
const getCameraStats = (mediaSource: RTCMediaSourceStats): CameraStats => ({
  frameRate: mediaSource.framesPerSecond,
  frameWidth: mediaSource.width,
  frameHeight: mediaSource.height,
});

/**
 * Transforms raw RTC stats into a slimmer and uniform across browsers format.
 *
 * @param report the report to transform.
 * @param opts the transform options.
 */
const transform = (
  report: RTCStatsReport,
  opts: StatsTransformOpts,
): StatsReport => {
  const { trackKind, kind, publisher } = opts;
  const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
  const stats = flatten(report);
  const streams = stats
    .filter(
      (stat) =>
        stat.type === direction &&
        (stat as RTCRtpStreamStats).kind === trackKind,
    )
    .map((stat): BaseStats => {
      const rtcStreamStats = stat as RTCInboundRtpStreamStats &
        RTCOutboundRtpStreamStats;

      const codec = stats.find(
        (s) => s.type === 'codec' && s.id === rtcStreamStats.codecId,
      ) as RTCCodecStats | undefined;

      const transport = stats.find(
        (s) => s.type === 'transport' && s.id === rtcStreamStats.transportId,
      ) as RTCTransportStats | undefined;

      let roundTripTime: number | undefined;
      if (transport && transport.dtlsState === 'connected') {
        const candidatePair = stats.find(
          (s) =>
            s.type === 'candidate-pair' &&
            s.id === transport.selectedCandidatePairId,
        ) as RTCIceCandidatePairStats | undefined;
        roundTripTime = candidatePair?.currentRoundTripTime;
      }

      let trackType: TrackType | undefined;
      let audioLevel: number | undefined;
      let camera: CameraStats | undefined;
      let concealedSamples: number | undefined;
      let concealmentEvents: number | undefined;
      let packetsReceived: number | undefined;
      let packetsLost: number | undefined;
      if (kind === 'publisher' && publisher) {
        const firefox = isFirefox();
        const mediaSource = stats.find(
          (s) =>
            s.type === 'media-source' &&
            // Firefox doesn't have mediaSourceId, so we need to guess the media source
            (firefox ? true : s.id === rtcStreamStats.mediaSourceId),
        ) as RTCMediaSourceStats | undefined;
        if (mediaSource) {
          trackType = publisher.getTrackType(mediaSource.trackIdentifier);
          if (
            trackKind === 'audio' &&
            typeof mediaSource.audioLevel === 'number'
          ) {
            audioLevel = mediaSource.audioLevel;
          }
          if (trackKind === 'video') {
            camera = getCameraStats(mediaSource);
          }
        }
      } else if (kind === 'subscriber' && trackKind === 'audio') {
        const inboundStats = rtcStreamStats as RTCInboundRtpStreamStats;
        const inboundLevel = inboundStats.audioLevel;
        if (typeof inboundLevel === 'number') {
          audioLevel = inboundLevel;
        }
        concealedSamples = inboundStats.concealedSamples;
        concealmentEvents = inboundStats.concealmentEvents;
        packetsReceived = inboundStats.packetsReceived;
        packetsLost = inboundStats.packetsLost;
      }

      return {
        bytesSent: rtcStreamStats.bytesSent,
        bytesReceived: rtcStreamStats.bytesReceived,
        codec: codec?.mimeType,
        currentRoundTripTime: roundTripTime,
        frameHeight: rtcStreamStats.frameHeight,
        frameWidth: rtcStreamStats.frameWidth,
        framesPerSecond: rtcStreamStats.framesPerSecond,
        jitter: rtcStreamStats.jitter,
        kind: rtcStreamStats.kind,
        mediaSourceId: rtcStreamStats.mediaSourceId,
        qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
        rid: rtcStreamStats.rid,
        ssrc: rtcStreamStats.ssrc,
        trackType,
        audioLevel,
        concealedSamples,
        concealmentEvents,
        packetsReceived,
        packetsLost,
        camera,
      };
    });

  return {
    rawStats: report,
    streams,
    timestamp: Date.now(),
  };
};

const getEmptyVideoStats = (stats?: StatsReport): AggregatedStatsReport => {
  return {
    rawReport: stats ?? { streams: [], timestamp: Date.now() },
    totalBytesSent: 0,
    totalBytesReceived: 0,
    averageJitterInMs: 0,
    averageRoundTripTimeInMs: 0,
    qualityLimitationReasons: 'none',
    highestFrameWidth: 0,
    highestFrameHeight: 0,
    highestFramesPerSecond: 0,
    camera: {},
    codec: '',
    codecPerTrackType: {},
    timestamp: Date.now(),
  };
};

const getEmptyAudioStats = (): AudioAggregatedStats => {
  return {
    totalBytesSent: 0,
    totalBytesReceived: 0,
    averageJitterInMs: 0,
    averageRoundTripTimeInMs: 0,
    codec: '',
    codecPerTrackType: {},
    timestamp: Date.now(),
    totalConcealedSamples: 0,
    totalConcealmentEvents: 0,
    totalPacketsReceived: 0,
    totalPacketsLost: 0,
  };
};

/**
 * Aggregates generic stats.
 *
 * @param stats the stats to aggregate.
 */
const aggregate = (stats: StatsReport): AggregatedStatsReport => {
  const aggregatedStats = getEmptyVideoStats(stats);

  let maxArea = -1;
  const area = (w: number, h: number) => w * h;

  const qualityLimitationReasons = new Set<string>();
  const streams = stats.streams;
  const report = streams.reduce((acc, stream) => {
    acc.totalBytesSent += stream.bytesSent || 0;
    acc.totalBytesReceived += stream.bytesReceived || 0;
    acc.averageJitterInMs += stream.jitter || 0;
    acc.averageRoundTripTimeInMs += stream.currentRoundTripTime || 0;

    // naive calculation of the highest resolution
    const streamArea = area(stream.frameWidth || 0, stream.frameHeight || 0);
    if (streamArea > maxArea) {
      acc.highestFrameWidth = stream.frameWidth || 0;
      acc.highestFrameHeight = stream.frameHeight || 0;
      acc.highestFramesPerSecond = stream.framesPerSecond || 0;
      maxArea = streamArea;
    }

    if (stream.trackType === TrackType.VIDEO) {
      acc.camera = stream.camera;
    }

    qualityLimitationReasons.add(stream.qualityLimitationReason || '');
    return acc;
  }, aggregatedStats);

  if (streams.length > 0) {
    report.averageJitterInMs = Math.round(
      (report.averageJitterInMs / streams.length) * 1000,
    );
    report.averageRoundTripTimeInMs = Math.round(
      (report.averageRoundTripTimeInMs / streams.length) * 1000,
    );
    // we take the first codec we find, as it should be the same for all streams
    report.codec = streams[0].codec || '';
    report.codecPerTrackType = streams.reduce(
      (acc, stream) => {
        if (stream.trackType) {
          acc[stream.trackType] = stream.codec || '';
        }
        return acc;
      },
      {} as Record<TrackType, string>,
    );
  }

  const qualityLimitationReason = [
    qualityLimitationReasons.has('cpu') && 'cpu',
    qualityLimitationReasons.has('bandwidth') && 'bandwidth',
    qualityLimitationReasons.has('other') && 'other',
  ]
    .filter(Boolean)
    .join(', ');
  if (qualityLimitationReason) {
    report.qualityLimitationReasons = qualityLimitationReason;
  }

  return report;
};

/**
 * Aggregates audio stats from a stats report.
 *
 * @param stats the stats report containing audio streams.
 * @returns aggregated audio stats.
 */
const aggregateAudio = (stats: StatsReport): AudioAggregatedStats => {
  const streams = stats.streams;

  const audioStats = getEmptyAudioStats();

  const report = streams.reduce((acc, stream) => {
    acc.totalBytesSent += stream.bytesSent || 0;
    acc.totalBytesReceived += stream.bytesReceived || 0;
    acc.averageJitterInMs += stream.jitter || 0;
    acc.averageRoundTripTimeInMs += stream.currentRoundTripTime || 0;
    acc.totalConcealedSamples += stream.concealedSamples || 0;
    acc.totalConcealmentEvents += stream.concealmentEvents || 0;
    acc.totalPacketsReceived += stream.packetsReceived || 0;
    acc.totalPacketsLost += stream.packetsLost || 0;

    return acc;
  }, audioStats);

  if (streams.length > 0) {
    report.averageJitterInMs = Math.round(
      (report.averageJitterInMs / streams.length) * 1000,
    );
    report.averageRoundTripTimeInMs = Math.round(
      (report.averageRoundTripTimeInMs / streams.length) * 1000,
    );
    report.codec = streams[0].codec || '';
    report.codecPerTrackType = streams.reduce(
      (acc, stream) => {
        if (stream.trackType) {
          acc[stream.trackType] = stream.codec || '';
        }
        return acc;
      },
      {} as Record<TrackType, string>,
    );
  }

  return report;
};
