"use client";

import type { TipoDeTrilha } from "../evento";
import { ErroDaSala, type ApiDaSala } from "./api";

/*
 * A conexão de vídeo da sala: DUAS conexões WebRTC com o SFU da Cloudflare
 * por aparelho, cada uma com a sua fila de negociação:
 *
 * - a de MANDAR (câmera, microfone, tela), aberta só quando a pessoa liga
 *   alguma coisa;
 * - a de RECEBER (quem está no palco).
 *
 * Separadas porque receber de alguém que acabou de entrar (ou de recarregar)
 * pode demorar no SFU: com uma conexão só, o "ligar a minha câmera" ficava na
 * fila atrás e quem entrava depois não conseguia ligar nada.
 *
 * Caiu uma delas? Abre outra sessão só daquele lado, manda de novo o que
 * mandava ou volta a receber o que recebia.
 */

export type TrilhaRecebida = { participante: string; tipo: TipoDeTrilha; nome: string; mid: string; track: MediaStreamTrack };
export type Desejo = { participante: string; tipo: TipoDeTrilha; nome: string };
export type SituacaoDaConexao = "parada" | "conectando" | "conectada" | "reconectando" | "falhou";

type OpcoesDeEnvio = { maxBitrate?: number; maxFramerate?: number; dica?: "detail" | "motion" | "speech" | "music" | "" };

type Publicada = { transceiver: RTCRtpTransceiver | null; mid: string; nome?: string; track: MediaStreamTrack; opcoes: OpcoesDeEnvio };

/* Câmera ou microfone desligado sem fechar a trilha no SFU: religar é só trocar a imagem de volta. */
type Pausada = { transceiver: RTCRtpTransceiver; mid: string; nome: string; pc: RTCPeerConnection };

/* O que pode ficar pausado (a tela fecha de verdade: outra pessoa pode assumir a apresentação). */
const PAUSAVEIS: TipoDeTrilha[] = ["cam", "mic"];

type Lado = "envio" | "recebe";

const chave = (d: { participante: string; tipo: TipoDeTrilha }) => `${d.participante}:${d.tipo}`;

/*
 * Quanto esperar antes de tentar receber de novo de quem não veio: quem
 * acabou de ligar a câmera leva um instante para os pacotes chegarem ao SFU
 * ("Track not found on remote peer"). 2 s, 4 s, 8 s... até 15 s.
 */
const ESPERA_MINIMA_MS = 2000;
const ESPERA_MAXIMA_MS = 15000;

/* Erro da conexão (do navegador ou da rede) em português, para a tela. */
export function erroParaATela(erro: unknown, padrao: string): string {
  if (erro instanceof ErroDaSala) return erro.message;
  return padrao;
}

/* Uma conexão WebRTC com o SFU, com a sua fila e a sua reconexão. */
class Ligacao {
  readonly lado: Lado;
  private readonly api: ApiDaSala;
  pc: RTCPeerConnection | null = null;
  private fila: Promise<unknown> = Promise.resolve();
  private esperaDeQueda: ReturnType<typeof setTimeout> | null = null;
  private relogioDeReconexao: ReturnType<typeof setTimeout> | null = null;
  private tentativas = 0;
  encerrada = false;
  situacao: SituacaoDaConexao = "parada";
  aoMudar: () => void = () => undefined;
  /* O que refazer quando a conexão é aberta de novo. */
  aoAbrir: () => Promise<void> = async () => undefined;

  constructor(api: ApiDaSala, lado: Lado) {
    this.api = api;
    this.lado = lado;
  }

  private mudar(situacao: SituacaoDaConexao) {
    if (this.situacao === situacao) return;
    this.situacao = situacao;
    this.aoMudar();
  }

  enfileirar<T>(tarefa: () => Promise<T>): Promise<T> {
    const proxima = this.fila.then(tarefa, tarefa);
    this.fila = proxima.catch(() => undefined);
    return proxima;
  }

  pedir<T>(caminho: string, corpo: Record<string, unknown> = {}, prazoMs?: number) {
    return this.api.pedir<T>(caminho, { ...corpo, lado: this.lado }, prazoMs ? { prazoMs } : {});
  }

  /* Abre (ou reabre) a conexão. Roda dentro da fila. */
  async abrir() {
    if (this.encerrada) return;
    if (this.situacao === "parada") this.mudar("conectando");
    this.pc?.close();
    this.pc = null;
    const r = await this.pedir<{ ice: RTCIceServer[] }>("sfu/conectar");
    if (this.encerrada) return;
    const pc = new RTCPeerConnection({ iceServers: r.ice, bundlePolicy: "max-bundle" });
    this.pc = pc;
    pc.onconnectionstatechange = () => this.mudouAConexao(pc);
    await this.aoAbrir();
  }

  private mudouAConexao(pc: RTCPeerConnection) {
    if (pc !== this.pc || this.encerrada) return;
    const estado = pc.connectionState;
    if (estado === "connected") {
      this.tentativas = 0;
      if (this.esperaDeQueda) clearTimeout(this.esperaDeQueda);
      this.esperaDeQueda = null;
      this.mudar("conectada");
    } else if (estado === "failed") {
      this.reconectar();
    } else if (estado === "disconnected") {
      this.mudar("reconectando");
      /* Às vezes volta sozinha (troca de rede rápida): espera um pouco antes de refazer. */
      if (this.esperaDeQueda) clearTimeout(this.esperaDeQueda);
      this.esperaDeQueda = setTimeout(() => {
        if (this.pc === pc && pc.connectionState !== "connected") this.reconectar();
      }, 6000);
    }
  }

  reconectar() {
    if (this.encerrada || this.relogioDeReconexao) return;
    this.tentativas += 1;
    this.mudar(this.tentativas > 6 ? "falhou" : "reconectando");
    const espera = Math.min(15000, 800 * 2 ** Math.min(this.tentativas, 4));
    this.relogioDeReconexao = setTimeout(() => {
      this.relogioDeReconexao = null;
      void this.enfileirar(() => this.abrir()).catch(() => this.reconectar());
    }, espera);
  }

  /*
   * A Cloudflare só aceita trilha nova com a conexão ligada ("Session appears
   * to be disconnected"). Depois da primeira negociação, espera ligar (até 15 s).
   */
  async esperarLigada(pc: RTCPeerConnection) {
    if (!pc.remoteDescription || pc.connectionState === "connected") return;
    await new Promise<void>((resolve) => {
      const fim = setTimeout(pronto, 15000);
      function pronto() {
        clearTimeout(fim);
        pc.removeEventListener("connectionstatechange", olhar);
        resolve();
      }
      function olhar() {
        if (pc.connectionState === "connected" || pc.connectionState === "failed" || pc.connectionState === "closed") pronto();
      }
      pc.addEventListener("connectionstatechange", olhar);
    });
  }

  /* A conexão trocou no meio do caminho (reconectou): o que estava sendo feito vale de novo na próxima. */
  trocou(pc: RTCPeerConnection) {
    return this.pc !== pc || pc.signalingState === "closed";
  }

  encerrar() {
    this.encerrada = true;
    if (this.esperaDeQueda) clearTimeout(this.esperaDeQueda);
    if (this.relogioDeReconexao) clearTimeout(this.relogioDeReconexao);
    this.pc?.close();
    this.pc = null;
    this.mudar("parada");
  }
}

export class ConexaoDeVideo {
  private readonly envio: Ligacao;
  private readonly recebe: Ligacao;
  private readonly publicadas = new Map<TipoDeTrilha, Publicada>();
  private readonly pausadas = new Map<TipoDeTrilha, Pausada>();
  private readonly recebidas = new Map<string, TrilhaRecebida>();
  /* Quem não deu para receber: quando tentar de novo e quantas vezes já falhou. */
  private readonly falhas = new Map<string, { ate: number; vezes: number }>();
  private desejadas: Desejo[] = [];
  private envioAberto = false;
  private releitura: ReturnType<typeof setTimeout> | null = null;

  aoMudarRecebidas: (lista: TrilhaRecebida[]) => void = () => undefined;
  aoMudarSituacao: (situacao: SituacaoDaConexao) => void = () => undefined;

  constructor(api: ApiDaSala) {
    this.envio = new Ligacao(api, "envio");
    this.recebe = new Ligacao(api, "recebe");
    this.envio.aoMudar = () => this.avisarSituacao();
    this.recebe.aoMudar = () => this.avisarSituacao();

    /* A de mandar reabriu: manda de novo o que estava mandando. */
    this.envio.aoAbrir = async () => {
      const antes = Array.from(this.publicadas.entries());
      this.publicadas.clear();
      /* As pausadas eram da conexão que caiu: religar vai mandar de novo, do zero. */
      this.pausadas.clear();
      for (const [tipo, p] of antes) {
        if (p.track.readyState === "live") await this.publicarAgora(tipo, p.track, p.opcoes).catch(() => undefined);
      }
    };
    /* A de receber reabriu: volta a receber tudo o que quer. */
    this.recebe.aoAbrir = async () => {
      this.recebidas.clear();
      this.avisarRecebidas();
      await this.sincronizarAgora().catch(() => undefined);
      if (!this.desejadas.length && this.recebe.situacao !== "conectada") this.recebe.situacao = "conectada";
      this.avisarSituacao();
    };
  }

  /* A situação que a tela mostra: a pior das duas (a de mandar só conta depois de aberta). */
  private avisarSituacao() {
    const lista = [this.recebe.situacao, ...(this.envioAberto ? [this.envio.situacao] : [])];
    const ordem: SituacaoDaConexao[] = ["falhou", "reconectando", "conectando", "conectada", "parada"];
    const pior = ordem.find((s) => lista.includes(s)) ?? "parada";
    this.aoMudarSituacao(pior);
  }

  private avisarRecebidas() {
    this.aoMudarRecebidas(Array.from(this.recebidas.values()));
  }

  iniciar(): Promise<void> {
    return this.recebe.enfileirar(() => this.recebe.abrir());
  }

  /* -------------------------------------------------------------- mandar */

  publicar(tipo: TipoDeTrilha, track: MediaStreamTrack, opcoes: OpcoesDeEnvio = {}): Promise<void> {
    return this.envio
      .enfileirar(async () => {
        if (!this.envioAberto || !this.envio.pc) {
          this.envioAberto = true;
          await this.envio.abrir();
        }
        await this.publicarAgora(tipo, track, opcoes);
      })
      .catch((erro) => {
        /* A sessão caiu na Cloudflare (ou a conexão trocou): abre outra, que manda esta trilha de novo. */
        if (erro instanceof ErroDaSala && erro.motivo === "reconectar") {
          this.publicadas.set(tipo, { transceiver: null, mid: "", track, opcoes });
          this.envio.reconectar();
          return;
        }
        throw erro;
      });
  }

  private async publicarAgora(tipo: TipoDeTrilha, track: MediaStreamTrack, opcoes: OpcoesDeEnvio) {
    const pc = this.envio.pc;
    if (!pc) throw new ErroDaSala("O vídeo ainda está conectando. Tente de novo em instantes.", 409);
    if (track.kind === "video" && opcoes.dica !== undefined) track.contentHint = opcoes.dica;
    await this.envio.esperarLigada(pc);
    if (this.envio.trocou(pc)) throw new ErroDaSala("Reconectando o vídeo…", 409, { motivo: "reconectar" });
    const existente = this.publicadas.get(tipo);
    if (existente?.transceiver && existente.transceiver.currentDirection !== "stopped") {
      /* Trocar a câmera, ligar de novo: troca a trilha sem negociar. */
      await existente.transceiver.sender.replaceTrack(track);
      existente.track = track;
      return;
    }
    const pausada = this.pausadas.get(tipo);
    if (pausada) {
      this.pausadas.delete(tipo);
      if (pausada.pc === pc && pausada.transceiver.direction !== "stopped" && (await this.retomar(tipo, pausada, track))) {
        this.publicadas.set(tipo, { transceiver: pausada.transceiver, mid: pausada.mid, nome: pausada.nome, track, opcoes });
        return;
      }
    }
    const transceiver = pc.addTransceiver(track, {
      direction: "sendonly",
      sendEncodings: opcoes.maxBitrate ? [{ maxBitrate: opcoes.maxBitrate, ...(opcoes.maxFramerate ? { maxFramerate: opcoes.maxFramerate } : {}) }] : undefined,
    });
    try {
      await pc.setLocalDescription(await pc.createOffer());
      const mid = transceiver.mid;
      if (!mid || !pc.localDescription) throw new ErroDaSala("Não foi possível preparar o envio. Tente de novo.", 500);
      const r = await this.envio.pedir<{ resposta: RTCSessionDescriptionInit | null; trilhas: Array<{ mid: string; nome?: string; ok: boolean }> }>("sfu/publicar", {
        sdp: pc.localDescription.sdp,
        trilhas: [{ mid, tipo }],
      });
      if (this.envio.trocou(pc)) throw new ErroDaSala("Reconectando o vídeo…", 409, { motivo: "reconectar" });
      if (!r.resposta) throw new ErroDaSala("A transmissão não respondeu. Tente de novo.", 502);
      await pc.setRemoteDescription(r.resposta);
      const aceita = r.trilhas.find((t) => t.mid === mid);
      if (!aceita?.ok) throw new ErroDaSala("A transmissão recusou agora. Tente de novo.", 502);
      this.publicadas.set(tipo, { transceiver, mid, nome: aceita.nome, track, opcoes });
    } catch (erro) {
      /* Desfaz a oferta para a conexão continuar usável. */
      if (!this.envio.trocou(pc) && pc.signalingState === "have-local-offer") await pc.setLocalDescription({ type: "rollback" }).catch(() => undefined);
      try {
        transceiver.stop();
      } catch {
        /* já parado */
      }
      if (erro instanceof ErroDaSala) throw erro;
      /* Erro do navegador no meio da negociação: a conexão pode ter ficado pela metade; abre outra. */
      console.warn("[live] envio", erro);
      throw new ErroDaSala("Reconectando o vídeo…", 409, { motivo: "reconectar" });
    }
  }

  /*
   * `deVez`: o servidor já fechou a trilha (quem organiza fechou o microfone,
   * outra pessoa assumiu a tela). Sem isso, câmera e microfone só pausam.
   */
  parar(tipo: TipoDeTrilha, opcoes: { deVez?: boolean } = {}): Promise<void> {
    return this.envio.enfileirar(() => this.pararAgora(tipo, Boolean(opcoes.deVez)));
  }

  /* Põe a trilha pausada de volta no palco. Devolve false se for preciso mandar de novo, do zero. */
  private async retomar(tipo: TipoDeTrilha, pausada: Pausada, track: MediaStreamTrack): Promise<boolean> {
    await pausada.transceiver.sender.replaceTrack(track);
    try {
      await this.envio.pedir("sfu/retomar", { tipo, mid: pausada.mid, nome: pausada.nome });
      return true;
    } catch (erro) {
      await pausada.transceiver.sender.replaceTrack(null).catch(() => undefined);
      /* Sem permissão (perdeu a fala, por exemplo): avisa e continua pausada. Outro problema: manda de novo, do zero. */
      if (erro instanceof ErroDaSala && erro.motivo === "sem_permissao") {
        this.pausadas.set(tipo, pausada);
        throw erro;
      }
      this.largar(pausada.transceiver, pausada.pc, pausada.mid);
      return false;
    }
  }

  /*
   * A primeira linha ativa do SDP é a dona do transporte (BUNDLE). Fechar ela
   * negociando (stop) derrubava a conexão de mandar inteira: tudo sumia para
   * quem assiste e o envio seguinte esperava 15 s em esperarLigada e mais uma
   * reconexão (o religar da câmera que levava 20 a 30 s).
   */
  private donaDoTransporte(pc: RTCPeerConnection, transceiver: RTCRtpTransceiver): boolean {
    return pc.getTransceivers().find((t) => t.direction !== "stopped" && t.currentDirection !== "stopped") === transceiver;
  }

  /* Larga uma trilha sem negociar: fecha no SFU e a linha fica muda no SDP (não é usada de novo). */
  private largar(transceiver: RTCRtpTransceiver, pc: RTCPeerConnection, mid: string) {
    void transceiver.sender.replaceTrack(null).catch(() => undefined);
    if (!this.envio.trocou(pc)) void this.envio.pedir("sfu/fechar", { mids: [mid] }).catch(() => undefined);
  }

  private async pararAgora(tipo: TipoDeTrilha, deVez: boolean) {
    const p = this.publicadas.get(tipo);
    const pc = this.envio.pc;
    this.publicadas.delete(tipo);
    if (!p || !pc || !p.transceiver) {
      await this.envio.pedir("sfu/fechar", { tipos: [tipo] }).catch(() => undefined);
      return;
    }
    await p.transceiver.sender.replaceTrack(null).catch(() => undefined);
    /* Câmera e microfone só pausam: saem do palco (quem assiste para de receber), a trilha fica no SFU e religar é na hora. */
    if (!deVez && p.nome && PAUSAVEIS.includes(tipo) && !this.envio.trocou(pc)) {
      this.pausadas.set(tipo, { transceiver: p.transceiver, mid: p.mid, nome: p.nome, pc });
      await this.envio.pedir("sfu/fechar", { tipos: [tipo] }).catch(() => undefined);
      return;
    }
    /* A dona do transporte fecha só no SFU, sem negociar; a linha fica muda no SDP. */
    if (this.donaDoTransporte(pc, p.transceiver)) {
      await this.envio.pedir("sfu/fechar", { mids: [p.mid], tipos: [tipo] }).catch(() => undefined);
      return;
    }
    try {
      p.transceiver.stop();
      await pc.setLocalDescription(await pc.createOffer());
      const r = await this.envio.pedir<{ resposta: RTCSessionDescriptionInit | null }>("sfu/fechar", {
        mids: [p.mid],
        sdp: pc.localDescription?.sdp,
        tipos: [tipo],
      });
      if (r.resposta && !this.envio.trocou(pc)) await pc.setRemoteDescription(r.resposta);
    } catch {
      if (!this.envio.trocou(pc) && pc.signalingState === "have-local-offer") await pc.setLocalDescription({ type: "rollback" }).catch(() => undefined);
      await this.envio.pedir("sfu/fechar", { mids: [p.mid], tipos: [tipo] }).catch(() => undefined);
    }
  }

  estaMandando(tipo: TipoDeTrilha): boolean {
    return this.publicadas.has(tipo);
  }

  /* ------------------------------------------------------------- receber */

  sincronizar(desejadas: Desejo[]): Promise<void> {
    this.desejadas = desejadas;
    return this.recebe.enfileirar(() => this.sincronizarAgora()).catch(() => undefined);
  }

  private marcarFalha(chaveDaFalha: string) {
    const vezes = (this.falhas.get(chaveDaFalha)?.vezes ?? 0) + 1;
    this.falhas.set(chaveDaFalha, { ate: Date.now() + Math.min(ESPERA_MAXIMA_MS, ESPERA_MINIMA_MS * 2 ** (vezes - 1)), vezes });
    if (this.falhas.size > 200) this.falhas.delete(this.falhas.keys().next().value as string);
  }

  /* Tenta de novo quem falhou, quando a menor espera passar. */
  private agendarReleitura() {
    if (this.releitura || this.recebe.encerrada) return;
    const proxima = Math.min(...Array.from(this.falhas.values()).map((f) => f.ate), Date.now() + ESPERA_MAXIMA_MS);
    this.releitura = setTimeout(() => {
      this.releitura = null;
      void this.recebe.enfileirar(() => this.sincronizarAgora()).catch(() => undefined);
    }, Math.max(300, proxima - Date.now() + 100));
  }

  private async sincronizarAgora() {
    const pc = this.recebe.pc;
    if (!pc || this.recebe.encerrada) return;
    await this.recebe.esperarLigada(pc);
    if (this.recebe.trocou(pc)) return;
    const quero = new Map(this.desejadas.map((d) => [chave(d), d]));

    const sair = Array.from(this.recebidas.values()).filter((r) => quero.get(chave(r))?.nome !== r.nome);
    if (sair.length) {
      for (const r of sair) this.recebidas.delete(chave(r));
      await this.recebe.pedir("sfu/fechar", { mids: sair.map((r) => r.mid) }).catch(() => undefined);
    }

    /* Cada pessoa num pedido próprio: quem demora (acabou de entrar, caiu) não segura as outras. */
    const agora = Date.now();
    const entrar = Array.from(quero.values()).filter((d) => this.recebidas.get(chave(d))?.nome !== d.nome);
    const prontas = entrar.filter((d) => (this.falhas.get(`${d.participante}:${d.nome}`)?.ate ?? 0) <= agora);
    if (prontas.length < entrar.length) this.agendarReleitura();
    const porPessoa = new Map<string, Desejo[]>();
    for (const d of prontas) porPessoa.set(d.participante, [...(porPessoa.get(d.participante) ?? []), d]);

    let mudou = sair.length > 0;
    for (const lista of porPessoa.values()) {
      if (this.recebe.trocou(pc)) return;
      try {
        const r = await this.recebe.pedir<{
          oferta: RTCSessionDescriptionInit | null;
          trilhas: Array<{ mid: string; participante: string; tipo: TipoDeTrilha; nome: string }>;
        }>("sfu/puxar", { quero: lista.map(({ participante, tipo }) => ({ participante, tipo })) }, 14000);
        if (this.recebe.trocou(pc)) return;
        /* Veio oferta: responde sempre (mesmo sem trilha boa), senão a Cloudflare fica esperando e trava a conexão. */
        if (r.oferta) {
          await pc.setRemoteDescription(r.oferta);
          await pc.setLocalDescription(await pc.createAnswer());
          if (pc.localDescription) await this.recebe.pedir("sfu/renegociar", { sdp: pc.localDescription.sdp });
          const transceivers = pc.getTransceivers();
          for (const t of r.trilhas) {
            const tr = transceivers.find((x) => x.mid === t.mid);
            if (tr?.receiver.track) {
              this.recebidas.set(chave(t), { ...t, track: tr.receiver.track });
              this.falhas.delete(`${t.participante}:${t.nome}`);
              mudou = true;
            }
          }
        }
        /* Quem não veio (ainda não está mandando): tenta de novo daqui a pouco. */
        for (const d of lista) {
          if (!this.recebidas.has(chave(d))) this.marcarFalha(`${d.participante}:${d.nome}`);
        }
      } catch (erro) {
        /* Não deu para receber desta pessoa: espera um pouco e, como a negociação pode ter ficado pela metade, abre outra conexão de receber. */
        for (const d of lista) this.marcarFalha(`${d.participante}:${d.nome}`);
        console.warn("[live] receber", erro);
        if (mudou) this.avisarRecebidas();
        this.agendarReleitura();
        this.recebe.reconectar();
        return;
      }
    }
    if (mudou) this.avisarRecebidas();
    if (Array.from(quero.values()).some((d) => this.recebidas.get(chave(d))?.nome !== d.nome)) this.agendarReleitura();
    /* Nada a receber (ou só gente que ainda não está mandando): a conexão de receber está pronta mesmo assim. */
    if (!this.recebidas.size && this.recebe.situacao === "conectando") {
      this.recebe.situacao = "conectada";
      this.avisarSituacao();
    }
  }

  /* -------------------------------------------------------------- fechar */

  encerrar() {
    if (this.releitura) clearTimeout(this.releitura);
    for (const p of this.publicadas.values()) p.track.stop();
    this.publicadas.clear();
    this.pausadas.clear();
    this.recebidas.clear();
    this.envio.encerrar();
    this.recebe.encerrar();
    this.aoMudarSituacao("parada");
  }

  /* O nível do áudio que chega (para o anel de "falando"). */
  async niveisDeAudio(): Promise<Map<string, number>> {
    const niveis = new Map<string, number>();
    const pc = this.recebe.pc;
    if (!pc) return niveis;
    for (const r of this.recebidas.values()) {
      if (r.tipo !== "mic") continue;
      const tr = pc.getTransceivers().find((x) => x.mid === r.mid);
      const fontes = tr?.receiver.getSynchronizationSources?.() ?? [];
      const nivel = fontes.reduce((maior, f) => Math.max(maior, f.audioLevel ?? 0), 0);
      niveis.set(r.participante, nivel);
    }
    return niveis;
  }
}
