import { Checker, Track, type CheckInfo } from 'livekit-client'

/**
 * Addresses are useful to a network administrator (they identify the egress IP
 * and the SFU endpoint actually reached) but they also land in a downloadable
 * report. Flip this to false to keep only the candidate types and protocols.
 */
const INCLUDE_CANDIDATE_ADDRESSES = true

/** Beyond this, the log becomes noise rather than evidence. */
const MAX_LOGGED_PAIRS = 8

export type IceCandidateInfo = {
  /** host, srflx, prflx or relay. */
  type?: string
  /** Transport to the first hop: udp or tcp. */
  protocol?: string
  /** Transport used by the relay itself (udp, tcp, tls). Local relay only. */
  relayProtocol?: string
  /** Chrome reports an mDNS `.local` name here for host candidates. */
  address?: string
  port?: number
  /** Local candidates only, and not reported by every browser. */
  networkType?: string
}

export type IceCandidatePair = {
  /** The pair the browser is actually sending media on. */
  selected: boolean
  nominated?: boolean
  local: IceCandidateInfo
  remote: IceCandidateInfo
  /** Round trip time in milliseconds. */
  rttMs?: number
  availableOutgoingBitrate?: number
  bytesSent?: number
}

export type IceCandidateReport = {
  selected: IceCandidatePair | null
  /** Every pair that completed its connectivity checks, selected one first. */
  working: IceCandidatePair[]
}

const PROBE_WIDTH = 320
const PROBE_HEIGHT = 180
const PROBE_FPS = 15
const SETTLE_DELAY_MS = 3000

type Stats = Record<string, unknown> & { type?: string }

const readCandidate = (stats?: Stats): IceCandidateInfo => {
  if (!stats) return {}

  return {
    type: stats.candidateType as string | undefined,
    protocol: stats.protocol as string | undefined,
    relayProtocol: stats.relayProtocol as string | undefined,
    networkType: stats.networkType as string | undefined,
    ...(INCLUDE_CANDIDATE_ADDRESSES
      ? {
          address: stats.address as string | undefined,
          port: stats.port as number | undefined,
        }
      : {}),
  }
}

const describeCandidate = (candidate: IceCandidateInfo) => {
  const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
  const endpoint =
    candidate.address === undefined
      ? ''
      : ` ${candidate.address}:${candidate.port ?? '?'}`
  return `${candidate.type ?? 'unknown'} ${transport}${endpoint}`
}

const parseCandidates = (report: RTCStatsReport): IceCandidateReport => {
  let selectedId: string | undefined

  report.forEach((stats: Stats) => {
    if (stats.type === 'transport' && stats.selectedCandidatePairId) {
      selectedId = stats.selectedCandidatePairId as string
    }
  })

  const working: IceCandidatePair[] = []

  report.forEach((stats: Stats) => {
    // `succeeded` means the pair completed its connectivity checks; failed,
    // waiting and in-progress pairs are not evidence of anything working.
    if (stats.type !== 'candidate-pair' || stats.state !== 'succeeded') return

    const rtt = stats.currentRoundTripTime as number | undefined

    working.push({
      selected: selectedId !== undefined && stats.id === selectedId,
      nominated: stats.nominated as boolean | undefined,
      local: readCandidate(report.get(stats.localCandidateId as string)),
      remote: readCandidate(report.get(stats.remoteCandidateId as string)),
      rttMs: rtt === undefined ? undefined : Math.round(rtt * 1000),
      availableOutgoingBitrate: stats.availableOutgoingBitrate as
        | number
        | undefined,
      bytesSent: stats.bytesSent as number | undefined,
    })
  })

  // Firefox does not report transport.selectedCandidatePairId: fall back to the
  // nominated pair, then to the one that actually carried bytes.
  let selected = working.find((pair) => pair.selected) ?? null
  if (!selected) {
    selected =
      working.find((pair) => pair.nominated) ??
      working
        .slice()
        .sort((a, b) => (b.bytesSent ?? 0) - (a.bytesSent ?? 0))[0] ??
      null
    if (selected) selected.selected = true
  }

  working.sort((a, b) => Number(b.selected) - Number(a.selected))

  return { selected, working }
}

/**
 * A synthetic track avoids asking for camera or microphone permission: this
 * check must work for someone who denied both.
 */
const createProbeTrack = () => {
  const canvas = document.createElement('canvas')
  canvas.width = PROBE_WIDTH
  canvas.height = PROBE_HEIGHT

  const context = canvas.getContext('2d')
  if (!context) throw new Error('Could not get canvas context')

  let frame = 0
  let rafId = 0
  const draw = () => {
    frame = (frame + 4) % 360
    context.fillStyle = `hsl(${frame}, 100%, 50%)`
    context.fillRect(0, 0, canvas.width, canvas.height)
    rafId = requestAnimationFrame(draw)
  }
  draw()

  const track = canvas.captureStream(PROBE_FPS).getVideoTracks()[0]

  return {
    track,
    stop: () => {
      cancelAnimationFrame(rafId)
      track.stop()
    },
  }
}

export class SelectedCandidateCheck extends Checker {
  private result: IceCandidateReport | null = null

  get description() {
    const selected = this.result?.selected
    if (!selected) return 'Selected ICE candidate pair'

    const transport =
      selected.local.relayProtocol ?? selected.local.protocol ?? 'unknown'
    const rtt =
      selected.rttMs === undefined ? '' : ` · RTT ${selected.rttMs} ms`
    return `${selected.local.type ?? 'unknown'} over ${transport}${rtt}`
  }

  protected async perform() {
    await this.connect()

    const probe = createProbeTrack()
    try {
      let publication
      try {
        publication = await this.room.localParticipant.publishTrack(
          probe.track,
          {
            // The token restricts `can_publish_sources`, so a raw
            // MediaStreamTrack published as `unknown` is rejected server side.
            source: Track.Source.Camera,
            simulcast: false,
            videoEncoding: { maxBitrate: 300_000, maxFramerate: PROBE_FPS },
          }
        )
      } catch (error) {
        // A server-side grant problem is not a diagnosis of the user's network.
        this.appendWarning(
          `Could not publish the probe track: ${
            error instanceof Error ? error.message : 'unknown error'
          }`
        )
        this.skip()
        return
      }

      // ICE keeps promoting pairs for a moment after the track goes up.
      await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))

      // Stats come from the publisher peer connection: in an empty test room
      // there is no subscriber transport to inspect.
      const report = await publication.track?.getRTCStatsReport()
      this.result = report ? parseCandidates(report) : null
    } finally {
      probe.stop()
    }

    const selected = this.result?.selected
    const working = this.result?.working ?? []

    if (!selected) {
      this.appendWarning('No working candidate pair reported by the browser')
      return
    }

    this.appendMessage(`selected: ${describeCandidate(selected.local)}`)
    this.appendMessage(`server: ${describeCandidate(selected.remote)}`)
    if (selected.rttMs !== undefined) {
      this.appendMessage(`round trip time: ${selected.rttMs} ms`)
    }

    this.appendMessage(`working candidate pairs: ${working.length}`)
    for (const pair of working.slice(0, MAX_LOGGED_PAIRS)) {
      const rtt = pair.rttMs === undefined ? '' : ` · ${pair.rttMs} ms`
      this.appendMessage(
        `${pair.selected ? '→' : ' '} ${describeCandidate(pair.local)} → ${describeCandidate(pair.remote)}${rtt}`
      )
    }
    if (working.length > MAX_LOGGED_PAIRS) {
      this.appendMessage(
        `… and ${working.length - MAX_LOGGED_PAIRS} more, see the report`
      )
    }

    if (selected.local.type === 'relay') {
      this.appendWarning(
        'Media is relayed through TURN. Direct connections are likely blocked by a firewall.'
      )
    }
    if ((selected.local.relayProtocol ?? selected.local.protocol) !== 'udp') {
      this.appendWarning(
        'Media is not using UDP, which usually means degraded quality under load.'
      )
    }
  }

  getInfo(): CheckInfo {
    const info = super.getInfo()
    info.data = this.result ?? undefined
    return info
  }
}
