"use client";

import type { RealtimeChannel } from "@supabase/supabase-js";

import { getSupabaseClient } from "@/lib/supabase";

import { lerRecadoDaCaneta, type RecadoDaCaneta } from "../anotacoes";
import { lerEvento, type EventoRecebido } from "../evento";

/*
 * O canal de avisos da sala no navegador. Só acredita no aviso que veio
 * assinado pelo servidor (a chave pública chega na entrada). As reações que
 * voam na tela vão sem assinatura: são inofensivas e saem direto daqui.
 *
 * Um canal por sala nesta aba, dividido por quem precisa dele (o cliente do
 * Supabase reaproveita o canal de mesmo nome: duas telas abrindo e fechando
 * o "mesmo" canal derrubavam uma à outra). O canal só fecha um pouco depois
 * de a última tela sair, e se cair (a entrada às vezes passa de 10 s), abre
 * de novo sozinho, esperando cada vez um pouco mais. Enquanto isso o sinal de
 * vida da sala mantém a tela em dia.
 */

export const REACOES = ["🔥", "👏", "❤️", "😂", "😮", "🎉"] as const;

const ESPERA_DA_ENTRADA_MS = 30000;
const ESPERA_PARA_FECHAR_MS = 1500;

type Ouvinte = {
  aoAviso: (payload: unknown) => void;
  aoReagir: (emoji: string) => void;
  aoRiscar: (payload: unknown) => void;
  aoMudarConexao: (conectado: boolean) => void;
};

/* Um aviso assinado pelo servidor, como viaja no canal. */
export type AvisoAssinado = { d: string; s: string };

/* Um risco da caneta: a licença (assinada pelo servidor) e o recado (assinado pela chave da licença). */
export type RiscoAssinado = { l: AvisoAssinado; d: string; s: string };

type Registro = {
  topico: string;
  canal: RealtimeChannel | null;
  ouvintes: Set<Ouvinte>;
  conectado: boolean;
  tentativas: number;
  relogio: ReturnType<typeof setTimeout> | null;
  fechamento: ReturnType<typeof setTimeout> | null;
  saindo: Promise<unknown>;
};

const registros = new Map<string, Registro>();
/* A saída do canal que fechou: o próximo do mesmo nome espera ela terminar. */
const saidas = new Map<string, Promise<unknown>>();

function base64ParaBytes(texto: string): Uint8Array<ArrayBuffer> {
  const binario = atob(texto);
  const bytes = new Uint8Array(new ArrayBuffer(binario.length));
  for (let i = 0; i < binario.length; i += 1) bytes[i] = binario.charCodeAt(i);
  return bytes;
}

function avisarConexao(registro: Registro, conectado: boolean) {
  registro.conectado = conectado;
  for (const o of registro.ouvintes) o.aoMudarConexao(conectado);
}

async function abrir(registro: Registro) {
  await registro.saindo.catch(() => undefined);
  if (!registros.has(registro.topico) || registro.canal) return;
  const supabase = getSupabaseClient();
  const canal = supabase.channel(registro.topico, { config: { broadcast: { self: false } } });
  registro.canal = canal;
  canal.on("broadcast", { event: "sala" }, ({ payload }) => {
    for (const o of registro.ouvintes) o.aoAviso(payload);
  });
  canal.on("broadcast", { event: "caneta" }, ({ payload }) => {
    for (const o of registro.ouvintes) o.aoRiscar(payload);
  });
  canal.on("broadcast", { event: "reacao" }, ({ payload }) => {
    const emoji = (payload as { e?: unknown } | null)?.e;
    if (typeof emoji !== "string" || !(REACOES as readonly string[]).includes(emoji)) return;
    for (const o of registro.ouvintes) o.aoReagir(emoji);
  });
  canal.subscribe((status, erro) => {
    if (process.env.NODE_ENV !== "production") console.info("[live] canal", status, erro?.message ?? "");
    if (registro.canal !== canal) return;
    if (status === "SUBSCRIBED") {
      registro.tentativas = 0;
      avisarConexao(registro, true);
      return;
    }
    avisarConexao(registro, false);
    if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") reabrir(registro);
  }, ESPERA_DA_ENTRADA_MS);
}

/* Caiu: fecha este e abre outro, esperando 2 s, 4 s, 8 s... até 30 s. */
function reabrir(registro: Registro) {
  if (registro.relogio || !registros.has(registro.topico)) return;
  const velho = registro.canal;
  registro.canal = null;
  if (velho) registro.saindo = getSupabaseClient().removeChannel(velho).catch(() => undefined);
  registro.tentativas += 1;
  const espera = Math.min(30000, 1000 * 2 ** Math.min(registro.tentativas, 5));
  registro.relogio = setTimeout(() => {
    registro.relogio = null;
    void abrir(registro);
  }, espera);
}

function fechar(registro: Registro) {
  registros.delete(registro.topico);
  if (registro.relogio) clearTimeout(registro.relogio);
  const canal = registro.canal;
  registro.canal = null;
  if (canal) saidas.set(registro.topico, getSupabaseClient().removeChannel(canal).catch(() => undefined));
}

export type CanalDaSala = {
  reagir: (emoji: string) => void;
  /* Repassa os avisos assinados que voltaram na resposta de um pedido (chegam na hora para todos). */
  repassar: (avisos: AvisoAssinado[]) => void;
  riscar: (risco: RiscoAssinado) => void;
  parar: () => void;
};

function bytesParaTexto(base64: string) {
  return base64ParaBytes(base64);
}

export async function ouvirSala(opcoes: {
  topico: string;
  sala: string;
  chavePublica: JsonWebKey;
  /* Diferença entre o relógio do servidor e o deste aparelho. */
  diferencaDoRelogio: number;
  aoEvento: (evento: EventoRecebido) => void;
  aoReagir: (emoji: string) => void;
  aoRiscar?: (recado: RecadoDaCaneta, participante: string) => void;
  aoMudarConexao: (conectado: boolean) => void;
}): Promise<CanalDaSala> {
  const chave = await crypto.subtle.importKey("jwk", opcoes.chavePublica, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
  const codificador = new TextEncoder();
  /* O mesmo aviso chega duas vezes (repassado por quem clicou e pelo servidor): só vale uma. */
  const vistos = new Set<string>();
  const jaVisto = (assinatura: string) => {
    if (vistos.has(assinatura)) return true;
    vistos.add(assinatura);
    if (vistos.size > 800) vistos.delete(vistos.values().next().value as string);
    return false;
  };
  /* As licenças da caneta já conferidas (assinatura → chave de quem risca). */
  const licencas = new Map<string, Promise<{ chave: CryptoKey; participante: string; ate: number } | null>>();
  const conferirLicenca = (l: AvisoAssinado) => {
    let pronta = licencas.get(l.s);
    if (!pronta) {
      pronta = (async () => {
        try {
          const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, chave, bytesParaTexto(l.s), codificador.encode(l.d));
          if (!ok) return null;
          const dados = JSON.parse(l.d) as { sala?: unknown; participante?: unknown; x?: unknown; y?: unknown; ate?: unknown };
          if (dados.sala !== opcoes.sala || typeof dados.participante !== "string" || typeof dados.x !== "string" || typeof dados.y !== "string") return null;
          const deQuem = await crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", x: dados.x, y: dados.y }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
          return { chave: deQuem, participante: dados.participante, ate: Number(dados.ate) || 0 };
        } catch {
          return null;
        }
      })();
      licencas.set(l.s, pronta);
      if (licencas.size > 50) licencas.delete(licencas.keys().next().value as string);
    }
    return pronta;
  };

  const ouvinte: Ouvinte = {
    aoRiscar: async (payload) => {
      if (!opcoes.aoRiscar) return;
      const p = payload as Partial<RiscoAssinado> | null;
      if (!p || !p.l || typeof p.l.d !== "string" || typeof p.l.s !== "string" || typeof p.d !== "string" || typeof p.s !== "string") return;
      if (p.d.length > 12000 || p.l.d.length > 2000 || jaVisto(p.s)) return;
      const licenca = await conferirLicenca({ d: p.l.d, s: p.l.s });
      const agora = Date.now() + opcoes.diferencaDoRelogio;
      if (!licenca || licenca.ate < agora) return;
      let valido = false;
      try {
        valido = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, licenca.chave, bytesParaTexto(p.s), codificador.encode(p.d));
      } catch {
        valido = false;
      }
      if (!valido) return;
      try {
        const dados = JSON.parse(p.d) as { ts?: unknown; r?: unknown };
        /* Recado velho (alguém repetindo um gravado) não vale. */
        if (Math.abs(agora - Number(dados.ts)) > 60000) return;
        const recado = lerRecadoDaCaneta(dados.r);
        if (recado) opcoes.aoRiscar(recado, licenca.participante);
      } catch {
        /* recado estragado */
      }
    },
    aoAviso: async (payload) => {
      const p = payload as { d?: unknown; s?: unknown } | null;
      if (!p || typeof p.d !== "string" || typeof p.s !== "string" || p.d.length > 60000) return;
      if (jaVisto(p.s)) return;
      let valida = false;
      try {
        valida = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, chave, base64ParaBytes(p.s), codificador.encode(p.d));
      } catch {
        valida = false;
      }
      const evento = valida ? lerEvento(p.d, opcoes.sala, Date.now() + opcoes.diferencaDoRelogio) : null;
      if (evento) opcoes.aoEvento(evento);
    },
    aoReagir: opcoes.aoReagir,
    aoMudarConexao: opcoes.aoMudarConexao,
  };

  let registro = registros.get(opcoes.topico);
  if (!registro) {
    registro = {
      topico: opcoes.topico,
      canal: null,
      ouvintes: new Set(),
      conectado: false,
      tentativas: 0,
      relogio: null,
      fechamento: null,
      saindo: saidas.get(opcoes.topico) ?? Promise.resolve(),
    };
    registros.set(opcoes.topico, registro);
  }
  const meu = registro;
  /* Alguém saiu e voltou logo (a tela remontou): o canal fica. */
  if (meu.fechamento) clearTimeout(meu.fechamento);
  meu.fechamento = null;
  meu.ouvintes.add(ouvinte);
  if (meu.conectado) opcoes.aoMudarConexao(true);
  if (!meu.canal && !meu.relogio) void abrir(meu);

  let ultimaReacao = 0;
  let parado = false;
  return {
    reagir: (emoji: string) => {
      if (!meu.canal || !(REACOES as readonly string[]).includes(emoji)) return;
      const agora = Date.now();
      if (agora - ultimaReacao < 700) return;
      ultimaReacao = agora;
      void meu.canal.send({ type: "broadcast", event: "reacao", payload: { e: emoji } });
    },
    repassar: (avisos: AvisoAssinado[]) => {
      for (const aviso of avisos.slice(0, 10)) {
        if (typeof aviso?.d !== "string" || typeof aviso?.s !== "string") continue;
        /* Vale aqui também (o canal não devolve para quem mandou); a cópia do servidor depois é ignorada. */
        void ouvinte.aoAviso({ d: aviso.d, s: aviso.s });
        void meu.canal?.send({ type: "broadcast", event: "sala", payload: { d: aviso.d, s: aviso.s } });
      }
    },
    riscar: (risco: RiscoAssinado) => {
      if (!meu.canal) return;
      void meu.canal.send({ type: "broadcast", event: "caneta", payload: risco });
    },
    parar: () => {
      if (parado) return;
      parado = true;
      meu.ouvintes.delete(ouvinte);
      if (meu.ouvintes.size === 0) {
        if (meu.fechamento) clearTimeout(meu.fechamento);
        meu.fechamento = setTimeout(() => {
          if (meu.ouvintes.size === 0 && registros.get(meu.topico) === meu) fechar(meu);
        }, ESPERA_PARA_FECHAR_MS);
      }
    },
  };
}
