/**
 * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
 * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
 */
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
import {derived, get, writable} from "svelte/store"
import {now} from "@welshman/lib"
import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveEventsForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast"

export const ROOM_PRESENCE = 10312

export {checkRelayHasLivekit} from "$lib/livekit"

const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300

export type VoiceSession = {
  url: string
  h: string
  room: Room
  muted: boolean
}

export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)

export const speakingPubkeys = writable(new Set<string>())

const fetchLivekitToken = async (
  url: string,
  groupId: string,
  signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
  const endpoint = getLivekitEndpoint(url, groupId)

  const $signer = signer.get()
  if (!$signer) throw new Error("No signer available")

  if (signal?.aborted) throw new DOMException("Aborted", "AbortError")

  const template = await makeHttpAuth(endpoint, "GET")
  const signedEvent = await $signer.sign(template)
  const authHeader = makeHttpAuthHeader(signedEvent)

  const response = await fetch(endpoint, {
    headers: {Authorization: authHeader},
    signal,
  })

  if (!response.ok) {
    const text = await response.text()
    throw new Error(`Token request failed (${response.status}): ${text}`)
  }

  return response.json()
}

export const deriveVoiceParticipants = (url: string, h: string) =>
  derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => {
    const cutoff = now() - PRESENCE_EXPIRY_S
    const pubkeys: string[] = []

    for (const event of $events) {
      if (event.created_at < cutoff) continue

      if (getTagValue("h", event.tags) === h) {
        pubkeys.push(event.pubkey)
      }
    }

    return pubkeys
  })

const publishPresence = (url: string, h: string) => {
  const event = makeEvent(ROOM_PRESENCE, {
    tags: [["h", h]],
  })

  return publishThunk({event, relays: [url]})
}

const deletePresence = (url: string) => {
  const event = makeEvent(ROOM_PRESENCE, {tags: []})

  return publishThunk({event, relays: [url]})
}

let presenceInterval: ReturnType<typeof setInterval> | undefined

const startPresenceHeartbeat = (url: string, h: string) => {
  stopPresenceHeartbeat()
  publishPresence(url, h)
  presenceInterval = setInterval(() => publishPresence(url, h), PRESENCE_INTERVAL_MS)
}

const stopPresenceHeartbeat = () => {
  if (presenceInterval) {
    clearInterval(presenceInterval)
    presenceInterval = undefined
  }
}

const onRoomDisconnected = (reason?: DisconnectReason) => {
  speakingPubkeys.set(new Set())
  currentVoiceSession.set(undefined)
  stopPresenceHeartbeat()
  if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
    const message =
      reason === DisconnectReason.JOIN_FAILURE
        ? "Could not connect to voice room. Please try again."
        : "Voice connection lost."
    pushToast({theme: "error", message})
  }
}

const onTrackSubscribed = (track: Track) => {
  if (track.kind === Track.Kind.Audio) {
    const element = track.attach()
    element.style.display = "none"
    document.body.appendChild(element)
    element.play().catch(() => {})
  }
}

const onTrackUnsubscribed = (track: Track) => {
  track.detach().forEach(el => el.remove())
}

const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
  speakingPubkeys.set(new Set(participants.map(p => p.identity)))
}

export const joinVoiceRoom = async (
  url: string,
  h: string,
  signal?: AbortSignal,
): Promise<void> => {
  const session = get(currentVoiceSession)

  if (session) await leaveVoiceRoom()

  const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)

  if (signal?.aborted) return

  const room = new Room({adaptiveStream: true, dynacast: true})

  room.on(RoomEvent.Disconnected, onRoomDisconnected)
  room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
  room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
  room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)

  const connect = room.connect(server_url, participant_token, {maxRetries: 0})
  const timeout = whenTimeout(5_000, {
    message: "Connection timed out. Please check your network and try again.",
  })
  const abort = whenAborted(signal)

  try {
    await Promise.race([connect, timeout, abort])
  } catch (e) {
    room.disconnect()
    if (e instanceof AbortError) return
    throw e
  }

  await room.localParticipant.setMicrophoneEnabled(true)

  currentVoiceSession.set({url, h, room, muted: false})

  startPresenceHeartbeat(url, h)
}

export const leaveVoiceRoom = async () => {
  const session = get(currentVoiceSession)
  if (!session) return

  speakingPubkeys.set(new Set())
  stopPresenceHeartbeat()
  session.room.disconnect()
  deletePresence(session.url)
  currentVoiceSession.set(undefined)
}

export const toggleMute = () => {
  const session = get(currentVoiceSession)
  if (!session) return

  const muted = !session.muted
  session.room.localParticipant.setMicrophoneEnabled(!muted)
  currentVoiceSession.set({...session, muted})
}
