"use client";

/*
 * O envio de um arquivo grande, direto do navegador para o depósito.
 *
 * 1. O servidor confere formato e espaço, reserva o tamanho e devolve um
 *    endereço assinado por parte (16 MB cada).
 * 2. O navegador manda as partes, três de cada vez, contando o progresso.
 *    Parte que falha tenta de novo (até 4 vezes, esperando cada vez mais);
 *    endereço vencido é trocado por um novo.
 * 3. No fim, o servidor junta as partes e confere tamanho e formato real.
 *
 * Se a internet cair no meio, `retomarEnvio` (com o mesmo arquivo escolhido
 * de novo) pergunta ao depósito o que já chegou e manda só o que falta.
 */

export type ProgressoDoEnvio = { enviados: number; total: number; fase: "enviando" | "conferindo" };

type Parte = { numero: number; url: string };
type RespostaDaAbertura = { id: string; tamanhoDaParte: number; partes: Parte[] };
type RespostaDaRetomada = { tamanhoDaParte: number; recebidas: Array<{ numero: number; etag: string }>; partes: Parte[] };

export class ErroDoEnvio extends Error {}

/* Onde abrir, retomar, concluir e cancelar o envio. O padrão é o do painel;
   a página de envio do assistente (/enviar/<código>) usa as rotas do código. */
export type RotasDoEnvio = {
  abrir: string;
  partes: (arquivoId: string) => string;
  concluir: (arquivoId: string) => string;
  cancelar: (arquivoId: string) => string;
};

export const ROTAS_DO_PAINEL: RotasDoEnvio = {
  abrir: "/api/digitais/arquivos",
  partes: (id) => `/api/digitais/arquivos/${id}/partes`,
  concluir: (id) => `/api/digitais/arquivos/${id}/concluir`,
  cancelar: (id) => `/api/digitais/arquivos/${id}`,
};

const PARALELAS = 3;
const TENTATIVAS = 4;

async function pedir<T>(url: string, init: RequestInit): Promise<T> {
  const resposta = await fetch(url, { ...init, headers: { "content-type": "application/json" }, cache: "no-store" });
  const corpo = (await resposta.json().catch(() => null)) as (T & { error?: string }) | null;
  if (!resposta.ok) throw new ErroDoEnvio(corpo?.error || "O envio não deu certo. Tente de novo.");
  return corpo as T;
}

/* Uma parte por XMLHttpRequest: é o único jeito de saber quantos bytes já subiram. */
function enviarParte(url: string, pedaco: Blob, aoProgredir: (bytes: number) => void, sinal: AbortSignal): Promise<string> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.upload.onprogress = (evento) => {
      if (evento.lengthComputable) aoProgredir(evento.loaded);
    };
    xhr.onload = () => {
      const etag = xhr.getResponseHeader("ETag") || xhr.getResponseHeader("etag");
      if (xhr.status >= 200 && xhr.status < 300 && etag) resolve(etag);
      else reject(Object.assign(new Error(`parte ${xhr.status}`), { status: xhr.status }));
    };
    xhr.onerror = () => reject(Object.assign(new Error("rede"), { status: 0 }));
    xhr.onabort = () => reject(new DOMException("cancelado", "AbortError"));
    sinal.addEventListener("abort", () => xhr.abort(), { once: true });
    xhr.send(pedaco);
  });
}

const esperar = (ms: number, sinal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const relogio = setTimeout(resolve, ms);
    sinal.addEventListener(
      "abort",
      () => {
        clearTimeout(relogio);
        reject(new DOMException("cancelado", "AbortError"));
      },
      { once: true }
    );
  });

/* Duração e tamanho do vídeo, medidos pelo próprio navegador (sem subir nada). */
export function medirVideo(arquivo: File): Promise<{ duracao?: number; largura?: number; altura?: number }> {
  return new Promise((resolve) => {
    if (!arquivo.type.startsWith("video/") && !/\.(mp4|m4v|mov|webm)$/i.test(arquivo.name)) {
      resolve({});
      return;
    }
    const endereco = URL.createObjectURL(arquivo);
    const video = document.createElement("video");
    let terminou = false;
    const fim = (dados: { duracao?: number; largura?: number; altura?: number }) => {
      if (terminou) return;
      terminou = true;
      URL.revokeObjectURL(endereco);
      resolve(dados);
    };
    const prazo = setTimeout(() => fim({}), 8000);
    video.preload = "metadata";
    video.onloadedmetadata = () => {
      clearTimeout(prazo);
      fim({
        duracao: Number.isFinite(video.duration) ? video.duration : undefined,
        largura: video.videoWidth || undefined,
        altura: video.videoHeight || undefined,
      });
    };
    video.onerror = () => {
      clearTimeout(prazo);
      fim({});
    };
    video.src = endereco;
  });
}

async function mandarPartes(
  arquivoId: string,
  arquivo: File,
  tamanhoDaParte: number,
  partes: Parte[],
  jaEnviados: number,
  aoProgredir: (progresso: ProgressoDoEnvio) => void,
  sinal: AbortSignal,
  rotas: RotasDoEnvio
): Promise<void> {
  const emAndamento = new Map<number, number>();
  let concluidos = jaEnviados;
  const avisar = () => {
    let somaEmAndamento = 0;
    for (const bytes of emAndamento.values()) somaEmAndamento += bytes;
    aoProgredir({ enviados: Math.min(arquivo.size, concluidos + somaEmAndamento), total: arquivo.size, fase: "enviando" });
  };

  const fila = [...partes];
  const enderecos = new Map(partes.map((p) => [p.numero, p.url]));

  const renovarEnderecos = async () => {
    const retomada = await pedir<RespostaDaRetomada>(rotas.partes(arquivoId), { method: "POST", body: "{}" });
    for (const parte of retomada.partes) enderecos.set(parte.numero, parte.url);
  };

  const trabalhador = async () => {
    while (fila.length) {
      if (sinal.aborted) throw new DOMException("cancelado", "AbortError");
      const parte = fila.shift()!;
      const inicio = (parte.numero - 1) * tamanhoDaParte;
      const pedaco = arquivo.slice(inicio, Math.min(arquivo.size, inicio + tamanhoDaParte));
      for (let tentativa = 1; ; tentativa += 1) {
        try {
          await enviarParte(enderecos.get(parte.numero) ?? parte.url, pedaco, (bytes) => {
            emAndamento.set(parte.numero, bytes);
            avisar();
          }, sinal);
          emAndamento.delete(parte.numero);
          concluidos += pedaco.size;
          avisar();
          break;
        } catch (erro) {
          emAndamento.delete(parte.numero);
          if (erro instanceof DOMException && erro.name === "AbortError") throw erro;
          if (tentativa >= TENTATIVAS) throw new ErroDoEnvio("A internet caiu durante o envio. Escolha o mesmo arquivo de novo para continuar de onde parou.");
          const status = (erro as { status?: number }).status ?? 0;
          if (status === 403) await renovarEnderecos().catch(() => undefined);
          await esperar(1500 * tentativa * tentativa, sinal);
        }
      }
    }
  };

  avisar();
  await Promise.all(Array.from({ length: Math.min(PARALELAS, partes.length || 1) }, () => trabalhador()));
}

async function concluir(arquivoId: string, arquivo: File, aoProgredir: (p: ProgressoDoEnvio) => void, rotas: RotasDoEnvio): Promise<void> {
  aoProgredir({ enviados: arquivo.size, total: arquivo.size, fase: "conferindo" });
  const meta = await medirVideo(arquivo);
  await pedir(rotas.concluir(arquivoId), { method: "POST", body: JSON.stringify({ meta }) });
}

/* Envia um arquivo novo. Devolve o id dele na biblioteca. */
export async function enviarArquivo(
  arquivo: File,
  aoProgredir: (progresso: ProgressoDoEnvio) => void,
  sinal: AbortSignal,
  aoAbrir?: (id: string) => void,
  rotas: RotasDoEnvio = ROTAS_DO_PAINEL
): Promise<string> {
  const abertura = await pedir<RespostaDaAbertura>(rotas.abrir, {
    method: "POST",
    body: JSON.stringify({ nome: arquivo.name, tamanho: arquivo.size }),
  });
  aoAbrir?.(abertura.id);
  try {
    await mandarPartes(abertura.id, arquivo, abertura.tamanhoDaParte, abertura.partes, 0, aoProgredir, sinal, rotas);
    await concluir(abertura.id, arquivo, aoProgredir, rotas);
    return abertura.id;
  } catch (erro) {
    if (erro instanceof DOMException && erro.name === "AbortError") {
      await fetch(rotas.cancelar(abertura.id), { method: "DELETE" }).catch(() => undefined);
    }
    throw erro;
  }
}

/* Continua um envio que caiu, com o mesmo arquivo escolhido de novo. */
export async function retomarEnvio(
  arquivoId: string,
  arquivo: File,
  aoProgredir: (progresso: ProgressoDoEnvio) => void,
  sinal: AbortSignal,
  rotas: RotasDoEnvio = ROTAS_DO_PAINEL
): Promise<void> {
  const retomada = await pedir<RespostaDaRetomada>(rotas.partes(arquivoId), { method: "POST", body: "{}" });
  const jaEnviados = retomada.recebidas.reduce((soma, parte) => {
    const inicio = (parte.numero - 1) * retomada.tamanhoDaParte;
    return soma + Math.max(0, Math.min(arquivo.size, inicio + retomada.tamanhoDaParte) - inicio);
  }, 0);
  await mandarPartes(arquivoId, arquivo, retomada.tamanhoDaParte, retomada.partes, jaEnviados, aoProgredir, sinal, rotas);
  await concluir(arquivoId, arquivo, aoProgredir, rotas);
}
