import { DURACAO_PADRAO, MAXIMO_DE_QUADROS, PARADO, animacaoNova, diagonal, persianas, estadoDoFundo, estadoNoTempo, poligonoDoRecorte, poseNoTempo, type AnimacaoDaPeca, type AnimacaoDaPagina, type Caixa, type CurvaDoQuadro, type EfeitoDaAnimacao, type EstadoNaCena, type MovimentoDoFundo, type PoseDaPeca, type QuadroChave, type TipoDeTransicao } from "./animacao";
import { novoId, type AudioDoProjeto, type DocumentoDoBanner, type Peca } from "./documento";
import { camadasDoVideo } from "./video-camadas";
import { css3D, desenharEm3D, tem3D } from "./perspectiva";

/*
 * A página como cena: as mesmas unidades do vídeo (o fundo, e cada peça ou
 * grupo com o que está dentro dela) com a animação de cada uma. O palco do
 * editor toca com isto e o vídeo grava com isto.
 */

/* `palavras`: as caixas das palavras do texto na página (medidas no navegador, palavras.ts), para a entrada palavra por palavra. */
/* `base`: a pose guardada da peça que tem os quadros-chave (a camada sai desenhada nela). */
export type UnidadeDaCena = { ids: string[]; fundo: boolean; caixa: Caixa; animacao: AnimacaoDaPeca | null; linhas: number; nome: string; palavras?: Caixa[]; base?: PoseDaPeca };

export const duracaoDaPagina = (p: Pick<DocumentoDoBanner, "animacao">) => p.animacao?.duracao ?? DURACAO_PADRAO;
export const temAnimacao = (p: DocumentoDoBanner) => Boolean(p.animacao && p.animacao.fundo !== "parado") || p.pecas.some((x) => x.animacao);

/* Quantas linhas o texto tem (a Máquina de escrever vai linha por linha). */
function linhasDe(pecas: Peca[]) {
  if (pecas.length !== 1 || pecas[0].tipo !== "texto") return 1;
  const t = pecas[0];
  return Math.max(1, Math.min(30, Math.round(t.h / Math.max(1, t.tamanho * t.linha))));
}

export function unidadesDaCena(pagina: DocumentoDoBanner): UnidadeDaCena[] {
  const porId = new Map(pagina.pecas.map((p) => [p.id, p]));
  return camadasDoVideo(pagina).map((c) => {
    const pecas = c.ids.map((id) => porId.get(id)!).filter(Boolean);
    const comAnimacao = pecas.find((p) => p.animacao);
    const comQuadros0 = pecas.find((p) => p.animacao?.quadros?.length);
    const ref = comQuadros0 ? referenciaDosQuadros(pagina.pecas, comQuadros0) : null;
    const comQuadros = ref && pecas.includes(ref) ? ref : comQuadros0;
    return {
      ids: c.ids,
      fundo: c.fundo,
      caixa: c.caixa,
      animacao: c.fundo ? null : (comQuadros ?? comAnimacao)?.animacao ?? null,
      linhas: linhasDe(pecas),
      nome: c.fundo ? "Fundo" : pecas[0]?.nome || (pecas[0]?.tipo === "texto" ? pecas[0].texto.slice(0, 30) : "Peça"),
      ...(comQuadros && !c.fundo ? { base: poseDa(comQuadros) } : {}),
    };
  });
}

/** O estado de cada unidade em `t`. */
export function estadosNoTempo(pagina: DocumentoDoBanner, unidades: UnidadeDaCena[], t: number): EstadoNaCena[] {
  const duracao = duracaoDaPagina(pagina);
  return unidades.map((u) => (u.fundo ? estadoDoFundo(pagina.animacao?.fundo, t, duracao, pagina.largura) : estadoNoTempo(u.animacao, t, { caixa: u.caixa, largura: pagina.largura, altura: pagina.altura, linhas: u.linhas, palavras: u.palavras, base: u.base })));
}

/** O centro em que a unidade gira e cresce (o fundo, no centro da página). */
export const centroDa = (u: Pick<UnidadeDaCena, "fundo" | "caixa">, pagina: { largura: number; altura: number }): [number, number] => (u.fundo ? [pagina.largura / 2, pagina.altura / 2] : [u.caixa.x + u.caixa.w / 2, u.caixa.y + u.caixa.h / 2]);

export const temRastro = (e: Pick<EstadoNaCena, "rastro">) => Math.hypot(e.rastro[0], e.rastro[1]) > 0.5;

/**
 * O estado como CSS de uma camada do tamanho da página (no palco do editor e
 * nas amostras do painel). O rastro é um filtro SVG da página (`filtroDoRastro`,
 * com o borrão só na direção do movimento); sem ele, vira um desfoque comum.
 */
export function cssDoEstado(e: EstadoNaCena, centro: [number, number], caixa: Caixa, filtroDoRastro?: string): Record<string, string> {
  const rastro = temRastro(e);
  const filtros = [e.desfoque > 0.05 ? `blur(${e.desfoque.toFixed(1)}px)` : "", rastro ? (filtroDoRastro ? `url(#${filtroDoRastro})` : `blur(${(Math.hypot(...e.rastro) / 5).toFixed(1)}px)`) : ""].filter(Boolean);
  return {
    opacity: String(Math.round(e.alfa * 1000) / 1000),
    transformOrigin: `${centro[0]}px ${centro[1]}px`,
    transform: `translate(${e.dx.toFixed(2)}px, ${e.dy.toFixed(2)}px) rotate(${e.giro.toFixed(2)}deg) scale(${e.escala.toFixed(4)})${css3D(e, caixa)}`,
    filter: filtros.join(" "),
    clipPath: e.recorte ? `polygon(${poligonoDoRecorte(e.recorte, caixa).map(([x, y]) => `${x.toFixed(1)}px ${y.toFixed(1)}px`).join(", ")})` : "",
  };
}

/*
 * O borrão de movimento automático do palco: quem anda rápido ganha um rastro
 * do tamanho do que andou em 1/48 s (o obturador de meio quadro do cinema).
 * O MP4 faz o borrão de verdade, com várias amostras por quadro (video.ts).
 */
export const OBTURADOR = 1 / 48;
export function comRastroDoMovimento(agora: EstadoNaCena[], antes: EstadoNaCena[]): EstadoNaCena[] {
  return agora.map((e, i) => {
    const a = antes[i];
    if (!a || e.partes) return e;
    const [vx, vy] = [e.dx - a.dx, e.dy - a.dy];
    if (Math.hypot(vx, vy) < 3 || Math.hypot(vx, vy) <= Math.hypot(...e.rastro)) return e;
    return { ...e, rastro: [-vx, -vy] };
  });
}

/** O desvio do borrão do rastro (feGaussianBlur), em px da arte. */
export const desvioDoRastro = (e: Pick<EstadoNaCena, "rastro">): [number, number] => [Math.abs(e.rastro[0]) / 6, Math.abs(e.rastro[1]) / 6];

/** A caixa da palavra com folga (acentos, pernas das letras e contorno não ficam cortados). */
export function folgaDaParte(c: Caixa): Caixa {
  const f = c.h * 0.18;
  return { x: c.x - f, y: c.y - f, w: c.w + 2 * f, h: c.h + 2 * f };
}

type Contexto2d = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;

/* Entra no estado: a transformação em volta do centro e o recorte (ou a caixa da palavra). */
function entrarNoEstado(ctx: Contexto2d, e: EstadoNaCena, centro: [number, number], caixa: Caixa, escala: number, recortePadrao?: Caixa) {
  ctx.globalAlpha *= Math.min(1, e.alfa);
  const [cx, cy] = [centro[0] * escala, centro[1] * escala];
  ctx.translate(cx + e.dx * escala, cy + e.dy * escala);
  ctx.rotate((e.giro * Math.PI) / 180);
  ctx.scale(e.escala, e.escala);
  ctx.translate(-cx, -cy);
  const pontos = e.recorte ? poligonoDoRecorte(e.recorte, caixa) : recortePadrao ? poligonoDoRecorte({ tipo: "caixa", x0: 0, y0: 0, x1: 1, y1: 1 }, recortePadrao) : null;
  if (pontos) {
    ctx.beginPath();
    pontos.forEach(([x, y], i) => (i ? ctx.lineTo(x * escala, y * escala) : ctx.moveTo(x * escala, y * escala)));
    ctx.closePath();
    ctx.clip();
  }
}

/* A imagem com o desfoque e o rastro (cópias cada vez mais fracas para trás, como o borrão de uma câmera). */
function pintar(ctx: Contexto2d, imagem: CanvasImageSource, e: EstadoNaCena, escala: number, largura: number, altura: number) {
  if (e.desfoque > 0.05) ctx.filter = `blur(${(e.desfoque * escala).toFixed(1)}px)`;
  if (temRastro(e)) {
    const forca = ctx.globalAlpha;
    const copias = 7;
    for (let i = copias; i >= 1; i -= 1) {
      ctx.globalAlpha = forca * 0.42 * (1 - i / (copias + 1));
      ctx.drawImage(imagem, (e.rastro[0] * escala * i) / copias, (e.rastro[1] * escala * i) / copias, largura, altura);
    }
    ctx.globalAlpha = forca;
  }
  ctx.drawImage(imagem, 0, 0, largura, altura);
}

/* Uma tela de rascunho do tamanho da camada (para recortar antes do 3D). */
let rascunho: OffscreenCanvas | null = null;
function telaDeRascunho(largura: number, altura: number) {
  const [w, h] = [Math.round(largura), Math.round(altura)];
  if (!rascunho || rascunho.width !== w || rascunho.height !== h) rascunho = new OffscreenCanvas(w, h);
  const c = rascunho.getContext("2d")!;
  c.setTransform(1, 0, 0, 1, 0, 0);
  c.clearRect(0, 0, w, h);
  return { tela: rascunho, c };
}

/* A camada em 3D: o recorte (e o desfoque) primeiro, no rascunho, e depois a perspectiva. */
function desenhar3D(ctx: Contexto2d, imagem: CanvasImageSource, e: EstadoNaCena, centro: [number, number], caixa: Caixa, escala: number, largura: number, altura: number, recortePadrao?: Caixa, alfaDeFora = 1) {
  let fonte: CanvasImageSource = imagem;
  const pontos = e.recorte ? poligonoDoRecorte(e.recorte, caixa) : recortePadrao ? poligonoDoRecorte({ tipo: "caixa", x0: 0, y0: 0, x1: 1, y1: 1 }, recortePadrao) : null;
  if (pontos || e.desfoque > 0.05) {
    const { tela, c } = telaDeRascunho(largura, altura);
    if (pontos) {
      c.beginPath();
      pontos.forEach(([x, y], i) => (i ? c.lineTo(x * escala, y * escala) : c.moveTo(x * escala, y * escala)));
      c.closePath();
      c.clip();
    }
    if (e.desfoque > 0.05) c.filter = `blur(${(e.desfoque * escala).toFixed(1)}px)`;
    c.drawImage(imagem, 0, 0, largura, altura);
    fonte = tela;
  }
  if (!desenharEm3D(ctx, fonte, e, centro, caixa, escala, largura, altura, e.alfa * alfaDeFora)) {
    /* Sem WebGL: o giro vira um achatamento (cosseno do ângulo), sem a perspectiva — a peça ainda vira. */
    ctx.save();
    ctx.globalAlpha *= Math.min(1, e.alfa) * alfaDeFora;
    const [cx, cy] = [centro[0] * escala, centro[1] * escala];
    ctx.translate(cx + e.dx * escala, cy + e.dy * escala);
    ctx.rotate((e.giro * Math.PI) / 180);
    ctx.scale(e.escala * Math.cos((e.ry * Math.PI) / 180), e.escala * Math.cos((e.rx * Math.PI) / 180));
    ctx.translate(-cx, -cy);
    ctx.drawImage(fonte, 0, 0, largura, altura);
    ctx.restore();
  }
}

/** Desenha a camada (do tamanho da página, já na escala do vídeo) no estado dado. */
export function desenharNoEstado(ctx: Contexto2d, imagem: CanvasImageSource, e: EstadoNaCena, centro: [number, number], caixa: Caixa, escala: number, largura: number, altura: number) {
  if (e.alfa <= 0.001) return;
  if (!e.partes && tem3D(e)) {
    desenhar3D(ctx, imagem, e, centro, caixa, escala, largura, altura);
    return;
  }
  ctx.save();
  entrarNoEstado(ctx, e, centro, caixa, escala);
  if (e.partes) {
    /* Palavra por palavra: a camada uma vez por palavra, recortada nela e no estado dela. */
    for (const parte of e.partes) {
      if (parte.estado.alfa <= 0.001) continue;
      const folga = folgaDaParte(parte.caixa);
      if (tem3D(parte.estado)) {
        /* A palavra que vira em 3D: recortada nela e projetada sozinha. */
        ctx.save();
        ctx.globalAlpha = 1;
        desenhar3D(ctx, imagem, parte.estado, [parte.caixa.x + parte.caixa.w / 2, parte.caixa.y + parte.caixa.h / 2], folga, escala, largura, altura, folga, e.alfa);
        ctx.restore();
        continue;
      }
      ctx.save();
      entrarNoEstado(ctx, parte.estado, [parte.caixa.x + parte.caixa.w / 2, parte.caixa.y + parte.caixa.h / 2], folga, escala, folga);
      pintar(ctx, imagem, parte.estado, escala, largura, altura);
      ctx.restore();
    }
  } else pintar(ctx, imagem, e, escala, largura, altura);
  ctx.restore();
}

/* ------------------------------------------------- quadros-chave */

export const poseDa = (p: Pick<Peca, "x" | "y" | "w" | "h" | "rot" | "opacidade">): PoseDaPeca => ({ x: p.x, y: p.y, w: p.w, h: p.h, rot: p.rot, opacidade: p.opacidade });
const mesmaPose = (a: PoseDaPeca, b: PoseDaPeca) => Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01 && Math.abs(a.w - b.w) < 0.01 && Math.abs(a.h - b.h) < 0.01 && Math.abs(a.rot - b.rot) < 0.01 && Math.abs(a.opacidade - b.opacidade) < 0.001;
/** Dois quadros a menos disso são o mesmo instante. */
export const PERTO_DO_QUADRO = 0.06;
const redondo = (t: number) => Math.round(t * 100) / 100;

/** O índice do quadro no instante `t` (ou -1). */
export const quadroEm = (a: AnimacaoDaPeca | null | undefined, t: number) => (a?.quadros ?? []).findIndex((q) => Math.abs(q.t - t) < PERTO_DO_QUADRO);

/** Os quadros com a pose `pose` gravada em `t` (troca o que já havia ali, mantendo a curva). */
export function comQuadro(quadros: QuadroChave[], t: number, pose: PoseDaPeca, curva?: CurvaDoQuadro): QuadroChave[] {
  const i = quadros.findIndex((q) => Math.abs(q.t - t) < PERTO_DO_QUADRO);
  const novo: QuadroChave = { ...pose, t: i >= 0 ? quadros[i].t : redondo(Math.max(0, t)), curva: curva ?? (i >= 0 ? quadros[i].curva : "suave") };
  const lista = i >= 0 ? quadros.map((q, k) => (k === i ? novo : q)) : [...quadros, novo];
  return lista.sort((a, b) => a.t - b.t).slice(0, MAXIMO_DE_QUADROS);
}

/*
 * Num grupo, as peças dividem a mesma animação: os quadros são a pose de uma
 * delas (`quadrosDe`) e as outras andam junto. Sem `quadrosDe` (ou se ela saiu
 * da página, numa cópia), a peça é a própria referência.
 */
export function referenciaDosQuadros(pecas: Peca[], p: Peca): Peca {
  const id = p.animacao?.quadrosDe;
  return (id && pecas.find((q) => q.id === id)) || p;
}
const ehReferencia = (pecas: Peca[], p: Peca) => Boolean(p.animacao?.quadros?.length) && referenciaDosQuadros(pecas, p) === p;
/* As peças que andam com a referência `r` (ela incluída). */
const doMesmoQuadro = (pecas: Peca[], r: Peca) => pecas.filter((p) => p.animacao?.quadros?.length && referenciaDosQuadros(pecas, p) === r);

/** Marca (ou tira, se já havia) o quadro-chave das peças no instante `t` com a pose de agora. */
export function alternarQuadro(pagina: DocumentoDoBanner, ids: string[], t: number): DocumentoDoBanner {
  const pecas = pagina.pecas.filter((p) => ids.includes(p.id) && !p.fixa);
  if (!pecas.length) return pagina;
  const achada = referenciaDosQuadros(pagina.pecas, pecas[0]);
  const ref = pecas.includes(achada) ? achada : pecas[0];
  const atuais = ref.animacao?.quadros ?? [];
  const tirar = quadroEm(ref.animacao, t) >= 0;
  const quadros = tirar ? atuais.filter((q) => Math.abs(q.t - t) >= PERTO_DO_QUADRO) : comQuadro(atuais, t, poseDa(ref));
  return {
    ...pagina,
    pecas: pagina.pecas.map((p) => {
      if (!pecas.includes(p)) return p;
      const a = p.animacao ?? ref.animacao ?? animacaoNova(null, { inicio: 0 });
      return { ...p, animacao: { ...a, quadros: quadros.length ? quadros : undefined, quadrosDe: quadros.length && pecas.length > 1 ? ref.id : undefined } } as Peca;
    }),
  };
}

/** Muda os quadros (a curva de um, o instante de um arrastado) da referência e das que andam com ela. */
export function comQuadrosDa(pagina: DocumentoDoBanner, id: string, mudar: (q: QuadroChave[]) => QuadroChave[]): DocumentoDoBanner {
  const p = pagina.pecas.find((x) => x.id === id);
  if (!p?.animacao?.quadros?.length) return pagina;
  const ref = referenciaDosQuadros(pagina.pecas, p);
  const quadros = [...mudar(ref.animacao?.quadros ?? [])].sort((a, b) => a.t - b.t);
  const juntas = new Set(doMesmoQuadro(pagina.pecas, ref).map((x) => x.id));
  return { ...pagina, pecas: pagina.pecas.map((x) => (juntas.has(x.id) ? ({ ...x, animacao: { ...x.animacao!, quadros: quadros.length ? quadros : undefined, quadrosDe: quadros.length ? x.animacao!.quadrosDe : undefined } } as Peca) : x)) };
}

/**
 * Mexeu numa peça com quadros-chave (arrastou, girou, mudou o tamanho ou a
 * transparência) com a agulha em `t`: a pose nova vira o quadro desse instante
 * (como no CapCut). As outras peças e os outros campos ficam como estão.
 */
export function comQuadrosAutomaticos(antes: DocumentoDoBanner, depois: DocumentoDoBanner, t: number): DocumentoDoBanner {
  const velhas = new Map(antes.pecas.map((p) => [p.id, p]));
  const novos = new Map<Peca, QuadroChave[]>();
  for (const p of depois.pecas) {
    const velha = velhas.get(p.id);
    if (!velha || !ehReferencia(depois.pecas, p) || mesmaPose(poseDa(velha), poseDa(p))) continue;
    novos.set(p, comQuadro(p.animacao!.quadros!, t, poseDa(p)));
  }
  if (!novos.size) return depois;
  return {
    ...depois,
    pecas: depois.pecas.map((p) => {
      if (!p.animacao?.quadros?.length) return p;
      const quadros = novos.get(referenciaDosQuadros(depois.pecas, p));
      return quadros ? ({ ...p, animacao: { ...p.animacao, quadros } } as Peca) : p;
    }),
  };
}

/**
 * As peças com quadros-chave postas na pose do instante `t` (a agulha parada
 * mostra onde elas estão). As do mesmo grupo andam, giram e crescem junto com
 * a referência, em volta do centro dela.
 */
export function comPoseNoTempo(pagina: DocumentoDoBanner, t: number): DocumentoDoBanner {
  const mudas = new Map<string, PoseDaPeca>();
  for (const r of pagina.pecas) {
    if (!ehReferencia(pagina.pecas, r)) continue;
    const k = poseNoTempo(r.animacao!.quadros!, t);
    const b = poseDa(r);
    if (mesmaPose(k, b)) continue;
    const [sx, sy] = [k.w / Math.max(1, b.w), k.h / Math.max(1, b.h)];
    const g = ((k.rot - b.rot) * Math.PI) / 180;
    const [bcx, bcy, kcx, kcy] = [b.x + b.w / 2, b.y + b.h / 2, k.x + k.w / 2, k.y + k.h / 2];
    for (const m of doMesmoQuadro(pagina.pecas, r)) {
      if (m === r) {
        mudas.set(m.id, k);
        continue;
      }
      const [vx, vy] = [(m.x + m.w / 2 - bcx) * sx, (m.y + m.h / 2 - bcy) * sy];
      const [cx, cy] = [kcx + vx * Math.cos(g) - vy * Math.sin(g), kcy + vx * Math.sin(g) + vy * Math.cos(g)];
      const [w, h] = [m.w * sx, m.h * sy];
      mudas.set(m.id, { x: cx - w / 2, y: cy - h / 2, w, h, rot: m.rot + (k.rot - b.rot), opacidade: Math.max(0, Math.min(1, m.opacidade * (k.opacidade / Math.max(0.01, b.opacidade)))) });
    }
  }
  if (!mudas.size) return pagina;
  return { ...pagina, pecas: pagina.pecas.map((p) => (mudas.has(p.id) ? ({ ...p, ...mudas.get(p.id) } as Peca) : p)) };
}

/* Os quadros recontados a partir de `corte` (a segunda parte de uma cena dividida), sem os de antes (fica a pose do corte). */
function quadrosDepoisDe(quadros: QuadroChave[] | undefined, corte: number): QuadroChave[] | undefined {
  if (!quadros?.length) return quadros;
  const pose = poseNoTempo(quadros, corte);
  const resto = quadros.filter((q) => q.t > corte + 0.01).map((q) => ({ ...q, t: redondo(q.t - corte) }));
  return [{ ...pose, t: 0, curva: "suave" as const }, ...resto];
}

/* ------------------------------------------- animar a página de uma vez */

export const ESTILOS_DA_PAGINA = [
  { chave: "suave", nome: "Suave", texto: "subir", resto: "subir" },
  { chave: "pop", nome: "Pop", texto: "pop", resto: "pop" },
  { chave: "deslizar", nome: "Deslizar", texto: "deslizar", resto: "deslizar" },
  { chave: "revelar", nome: "Revelar", texto: "revelar", resto: "revelar" },
  { chave: "maquina", nome: "Máquina", texto: "maquina", resto: "surgir" },
  { chave: "carimbo", nome: "Carimbo", texto: "carimbo", resto: "pop" },
  { chave: "neon", nome: "Neon", texto: "neon", resto: "surgir" },
  { chave: "quicar", nome: "Quicar", texto: "quicar", resto: "quicar" },
  { chave: "desfocar", nome: "Desfocar", texto: "desfocar", resto: "desfocar" },
  { chave: "aproximar", nome: "Aproximar", texto: "aproximar", resto: "aproximar" },
] as const satisfies ReadonlyArray<{ chave: string; nome: string; texto: EfeitoDaAnimacao; resto: EfeitoDaAnimacao }>;
export type EstiloDaPagina = (typeof ESTILOS_DA_PAGINA)[number]["chave"];

/**
 * Anima a página inteira num estilo: cada peça (ou grupo) entra depois da
 * anterior, de cima para baixo (as que cobrem quase tudo primeiro), e o fundo
 * faz um zoom lento. Substitui a animação que as peças tinham.
 */
export function animarPagina(pagina: DocumentoDoBanner, estilo: EstiloDaPagina, fundo: MovimentoDoFundo = "aproximar"): DocumentoDoBanner {
  const tipo = ESTILOS_DA_PAGINA.find((e) => e.chave === estilo) ?? ESTILOS_DA_PAGINA[0];
  const area = pagina.largura * pagina.altura;
  const unidades = unidadesDaCena({ ...pagina, pecas: pagina.pecas.map((p) => ({ ...p, animacao: undefined })) }).filter((u) => !u.fundo);
  const ordem = [...unidades].sort((a, b) => {
    const grandeA = a.caixa.w * a.caixa.h > area * 0.45 ? 0 : 1;
    const grandeB = b.caixa.w * b.caixa.h > area * 0.45 ? 0 : 1;
    return grandeA - grandeB || a.caixa.y + a.caixa.h / 2 - (b.caixa.y + b.caixa.h / 2) || a.caixa.x - b.caixa.x;
  });
  const porTipo = new Map(pagina.pecas.map((p) => [p.id, p.tipo]));
  const passo = Math.min(0.25, 2.2 / Math.max(1, ordem.length));
  const animacoes = new Map<string, AnimacaoDaPeca>();
  ordem.forEach((u, i) => {
    const soTexto = u.ids.every((id) => porTipo.get(id) === "texto");
    const efeito = soTexto ? tipo.texto : tipo.resto;
    /* Deslizar: um vem da esquerda, o seguinte da direita. */
    const campos: Partial<AnimacaoDaPeca> = { inicio: Math.round((0.2 + i * passo) * 100) / 100, ...(estilo === "deslizar" ? { direcao: i % 2 ? "esquerda" : "direita" } : {}) };
    const nova = animacaoNova(efeito, campos);
    u.ids.forEach((id) => animacoes.set(id, nova));
  });
  const ultima = Math.max(0, ...[...animacoes.values()].map((a) => a.inicio + a.duracao));
  const duracao = Math.max(pagina.animacao?.duracao ?? DURACAO_PADRAO, Math.ceil(ultima + 2));
  const animacao: AnimacaoDaPagina = { duracao, fundo };
  return { ...pagina, animacao, pecas: pagina.pecas.map((p) => ({ ...p, animacao: animacoes.get(p.id) })) };
}

/** Tira toda a animação da página. */
/**
 * O projeto tem movimento (vira vídeo)? Animação em alguma peça, duração ou
 * transição de cena, vídeo ou som. Sem nada disso a arte é parada: o editor
 * mostra as páginas (a tira de baixo, como antes), não a linha do tempo.
 */
export function projetoAnimado(paginas: DocumentoDoBanner[]): boolean {
  return paginas.some((p) => Boolean(p.animacao || p.transicao || p.audios?.length) || p.pecas.some((x) => Boolean(x.animacao) || (x.tipo === "imagem" && Boolean(x.video))));
}

export function semAnimacao(pagina: DocumentoDoBanner): DocumentoDoBanner {
  return { ...pagina, animacao: null, pecas: pagina.pecas.map((p) => ({ ...p, animacao: undefined })) };
}

/* ----------------------------------------------- o projeto em sequência */

/*
 * O projeto como um vídeo: as páginas (cenas) uma depois da outra, cada uma
 * com a duração dela. A transição acontece no começo da cena seguinte (a
 * anterior fica parada no último quadro enquanto isso), então ela não muda
 * a duração total.
 */
export function temposDoProjeto(paginas: DocumentoDoBanner[]) {
  const duracoes = paginas.map(duracaoDaPagina);
  const inicios: number[] = [];
  let soma = 0;
  for (const d of duracoes) {
    inicios.push(soma);
    soma += d;
  }
  return { inicios, duracoes, total: soma };
}

/** Em que cena está o instante `t` do projeto e quanto dela já passou (no fim exato, a última no fim). */
export function paginaNoTempo(paginas: DocumentoDoBanner[], t: number): { indice: number; local: number } {
  const { inicios, duracoes, total } = temposDoProjeto(paginas);
  if (!paginas.length) return { indice: 0, local: 0 };
  const tt = Math.max(0, Math.min(total, t));
  for (let i = paginas.length - 1; i >= 0; i -= 1) if (tt >= inicios[i] && (tt < inicios[i] + duracoes[i] || i === paginas.length - 1)) return { indice: i, local: Math.min(duracoes[i], tt - inicios[i]) };
  return { indice: 0, local: 0 };
}

/** A transição em andamento no instante (a cena de antes e a fração, 0 a 1), ou null. */
export function transicaoNoTempo(paginas: DocumentoDoBanner[], t: number): { de: number; para: number; fracao: number; tipo: TipoDeTransicao } | null {
  const { indice, local } = paginaNoTempo(paginas, t);
  const tr = paginas[indice]?.transicao;
  if (!indice || !tr || local >= tr.duracao) return null;
  return { de: indice - 1, para: indice, fracao: local / tr.duracao, tipo: tr.tipo };
}

/** As duas cenas na transição: como a de antes sai e a nova entra (camadas do tamanho da página), e o clarão (do Flash, branco, e do Apagar, preto). */
export function estadosDaTransicao(tipo: TipoDeTransicao, p: number, largura: number): { antes: EstadoNaCena; depois: EstadoNaCena; clarao: number; corDoClarao: string } {
  const e = 1 - Math.pow(1 - Math.max(0, Math.min(1, p)), 3);
  return { corDoClarao: tipo === "preto" ? "#000000" : "#FFFFFF", ...duasCenas(tipo, p, e, largura) };
}

function duasCenas(tipo: TipoDeTransicao, p: number, e: number, largura: number): { antes: EstadoNaCena; depois: EstadoNaCena; clarao: number } {
  switch (tipo) {
    case "circulo":
      return { antes: PARADO, depois: { ...PARADO, recorte: e >= 1 ? null : { tipo: "circulo", cx: 0.5, cy: 0.5, r: e } }, clarao: 0 };
    case "diagonal":
      return { antes: PARADO, depois: { ...PARADO, recorte: e >= 1 ? null : { tipo: "poligono", pontos: diagonal(e, "direita") } }, clarao: 0 };
    case "virar": {
      /* A cena de antes gira até ficar de lado; a nova vem do outro lado. */
      const q = Math.max(0, Math.min(1, p));
      const ida = q < 0.5 ? 4 * q * q * q : 1 - Math.pow(-2 * q + 2, 3) / 2;
      return q < 0.5 ? { antes: { ...PARADO, ry: ida * 180, escala: 1 - 0.12 * Math.sin(Math.PI * q) }, depois: { ...PARADO, alfa: 0 }, clarao: 0 } : { antes: { ...PARADO, alfa: 0 }, depois: { ...PARADO, ry: (ida - 1) * 180, escala: 1 - 0.12 * Math.sin(Math.PI * q) }, clarao: 0 };
    }
    case "persianas":
      return { antes: PARADO, depois: { ...PARADO, recorte: e >= 1 ? null : { tipo: "poligono", pontos: persianas(e) } }, clarao: 0 };
    case "chicote": {
      /* Acelera e freia (mais rápido no meio, onde o rastro é maior). */
      const q = Math.max(0, Math.min(1, p));
      const ida = q < 0.5 ? 16 * Math.pow(q, 5) : 1 - Math.pow(-2 * q + 2, 5) / 2;
      const rastro: [number, number] = [largura * 0.28 * Math.sin(Math.PI * q), 0];
      return { antes: { ...PARADO, dx: -ida * largura, rastro }, depois: { ...PARADO, dx: (1 - ida) * largura, rastro }, clarao: 0 };
    }
    case "dissolver":
      return { antes: PARADO, depois: { ...PARADO, alfa: p }, clarao: 0 };
    case "deslizar":
      return { antes: PARADO, depois: { ...PARADO, dx: (1 - e) * largura }, clarao: 0 };
    case "empurrar":
      return { antes: { ...PARADO, dx: -e * largura }, depois: { ...PARADO, dx: (1 - e) * largura }, clarao: 0 };
    case "zoom":
      return { antes: { ...PARADO, escala: 1 + 0.35 * e, alfa: 1 - p }, depois: { ...PARADO, escala: 0.85 + 0.15 * e, alfa: Math.min(1, p * 1.6) }, clarao: 0 };
    case "cortina":
      return { antes: PARADO, depois: { ...PARADO, recorte: e >= 1 ? null : { tipo: "caixa", x0: 0, y0: 0, x1: e, y1: 1 } }, clarao: 0 };
    case "preto":
    case "branco":
      return { antes: p < 0.5 ? PARADO : { ...PARADO, alfa: 0 }, depois: p < 0.5 ? { ...PARADO, alfa: 0 } : PARADO, clarao: 1 - Math.abs(2 * p - 1) };
    default:
      return { antes: { ...PARADO, alfa: 0 }, depois: PARADO, clarao: 0 };
  }
}

/* ---------------------------------------------------------- os clipes */

/** De quando a quando a peça aparece na cena (com a saída inteira). Sem animação, a cena toda. */
export function intervaloDoClipe(a: AnimacaoDaPeca | null | undefined, duracao: number) {
  const comeco = Math.min(duracao, a?.inicio ?? 0);
  const fim = a && a.fim !== null ? Math.min(duracao, a.fim + (a.saida ? a.duracaoDaSaida : 0)) : duracao;
  return { comeco, fim: Math.max(comeco, fim) };
}

const PERTO = 0.05;

/**
 * O corte (o "Dividir" do CapCut) de uma peça ou grupo no instante `t` da
 * cena: a primeira parte vai até `t` (sem a saída), a segunda é uma cópia que
 * começa em `t` (sem a entrada, com a saída da original). No vídeo, a segunda
 * continua do ponto do arquivo onde a primeira parou. Volta null se `t` não
 * cai dentro do clipe (ou a peça é fixa em todas as páginas).
 */
export function dividirUnidade(pagina: DocumentoDoBanner, ids: string[], t: number): { pagina: DocumentoDoBanner; novos: string[] } | null {
  const pecas = pagina.pecas.filter((p) => ids.includes(p.id));
  if (!pecas.length || pecas.some((p) => p.fixa)) return null;
  const duracao = duracaoDaPagina(pagina);
  const a = pecas.find((p) => p.animacao)?.animacao ?? null;
  const { comeco, fim } = intervaloDoClipe(a, duracao);
  if (t <= comeco + PERTO || t >= fim - PERTO) return null;
  const base = a ?? animacaoNova(null, { inicio: 0 });
  const primeira: AnimacaoDaPeca = { ...base, fim: t, saida: null };
  const segunda: AnimacaoDaPeca = { ...base, entrada: null, inicio: t };
  const copias = copiarComIdsNovos(pecas, (p) => comAndamentoDoVideo({ ...p, animacao: segunda } as Peca, t - base.inicio));
  const ultima = Math.max(...pagina.pecas.map((p, i) => (ids.includes(p.id) ? i : -1)));
  const lista = pagina.pecas.map((p) => (ids.includes(p.id) ? ({ ...p, animacao: primeira } as Peca) : p));
  lista.splice(ultima + 1, 0, ...copias);
  return { pagina: { ...pagina, pecas: lista }, novos: copias.map((c) => c.id) };
}

/** Peças copiadas com ids novos (grupo e "dentro de" apontando para as cópias); as fixas mantêm o id. */
function copiarComIdsNovos(pecas: Peca[], mudar: (p: Peca) => Peca = (p) => p): Peca[] {
  const ids = new Map(pecas.filter((p) => !p.fixa).map((p) => [p.id, novoId("p")]));
  const grupos = new Map<string, string>();
  return pecas.map((p) => {
    const c = mudar({ ...p, id: ids.get(p.id) ?? p.id } as Peca);
    if (p.grupo) {
      if (!grupos.has(p.grupo)) grupos.set(p.grupo, novoId("g"));
      c.grupo = grupos.get(p.grupo);
    }
    if (p.dentroDe && ids.has(p.dentroDe)) c.dentroDe = ids.get(p.dentroDe);
    return c;
  });
}

/* O vídeo da cópia continua `andou` segundos à frente no arquivo. */
function comAndamentoDoVideo(p: Peca, andou: number): Peca {
  if (p.tipo !== "imagem" || !p.video || andou <= 0) return p;
  /* Na velocidade do clipe (congelado não anda no arquivo). */
  return { ...p, video: { ...p.video, corte: Math.min(Math.max(0, p.video.duracao - 0.1), p.video.corte + andou * (p.video.velocidade ?? 1)) } };
}

/**
 * Divide a cena no instante `t`: a primeira fica com o que acontece até `t`,
 * a segunda (peças com ids novos, menos as fixas) com o resto, os tempos
 * recontados do zero. A peça que atravessa o corte fica nas duas (na
 * segunda, sem a entrada; o vídeo continua de onde parou).
 */
export function dividirPagina(pagina: DocumentoDoBanner, t: number): [DocumentoDoBanner, DocumentoDoBanner] | null {
  const duracao = duracaoDaPagina(pagina);
  if (t <= 0.2 || t >= duracao - 0.2) return null;
  const fundo = pagina.animacao?.fundo ?? "parado";
  const antes: Peca[] = [];
  const depois: Peca[] = [];
  for (const p of pagina.pecas) {
    const a = p.animacao ?? null;
    const { comeco, fim } = intervaloDoClipe(a, duracao);
    if (fim <= t + PERTO) antes.push(p);
    else if (comeco >= t - PERTO) depois.push({ ...p, animacao: a ? { ...a, inicio: Math.max(0, a.inicio - t), fim: a.fim === null ? null : a.fim - t, quadros: quadrosDepoisDe(a.quadros, t), mover: a.mover ? { ...a.mover, inicio: Math.max(0, a.mover.inicio - t) } : a.mover } : undefined } as Peca);
    else {
      antes.push({ ...p, animacao: a ? { ...a, fim: null, saida: null } : undefined } as Peca);
      depois.push(comAndamentoDoVideo({ ...p, animacao: a ? { ...a, entrada: null, inicio: 0, fim: a.fim === null ? null : Math.max(0.1, a.fim - t), quadros: quadrosDepoisDe(a.quadros, t), mover: a.mover ? { ...a.mover, inicio: Math.max(0, a.mover.inicio - t) } : a.mover } : undefined } as Peca, t - comeco));
    }
  }
  const primeira: DocumentoDoBanner = { ...pagina, pecas: antes, animacao: { duracao: Math.round(t * 10) / 10, fundo } };
  const segunda: DocumentoDoBanner = { ...pagina, pecas: copiarComIdsNovos(depois), animacao: { duracao: Math.round((duracao - t) * 10) / 10, fundo }, transicao: null };
  return [primeira, segunda];
}

/* --------------------------------------------------------- os áudios */

/** Os áudios do projeto (moram na primeira página; uma cópia de página não leva os dela). */
export const audiosDoProjeto = (paginas: DocumentoDoBanner[]): AudioDoProjeto[] => paginas.find((p) => p.audios?.length)?.audios ?? [];

/** As páginas com os áudios só na primeira. */
export function comAudios(paginas: DocumentoDoBanner[], audios: AudioDoProjeto[]): DocumentoDoBanner[] {
  return paginas.map((p, i) => {
    const { audios: _fora, ...resto } = p;
    void _fora;
    return i === 0 && audios.length ? { ...resto, audios } : resto;
  });
}

/** Os segundos de fade in e fade out do áudio (o "Suave" antigo é meio segundo em cada ponta). */
export function fadesDoAudio(a: AudioDoProjeto): [number, number] {
  const padrao = a.suave ? Math.min(0.5, a.dura / 4) : 0;
  const metade = a.dura / 2;
  return [Math.min(metade, a.fadeDeEntrada ?? padrao), Math.min(metade, a.fadeDeSaida ?? padrao)];
}

/** O volume do áudio no instante (com o fade in e o fade out). */
export function volumeNoTempo(a: AudioDoProjeto, t: number) {
  const dentro = t - a.inicio;
  if (dentro < 0 || dentro > a.dura) return 0;
  const [entra, sai] = fadesDoAudio(a);
  return a.volume * Math.min(1, entra > 0 ? dentro / entra : 1, sai > 0 ? (a.dura - dentro) / sai : 1);
}

/** Divide o áudio no instante `t` do projeto (null se `t` não cai nele). */
export function dividirAudio(a: AudioDoProjeto, t: number): [AudioDoProjeto, AudioDoProjeto] | null {
  const dentro = t - a.inicio;
  if (dentro <= 0.1 || dentro >= a.dura - 0.1) return null;
  return [
    { ...a, dura: Math.round(dentro * 100) / 100 },
    { ...a, id: novoId("a"), inicio: Math.round(t * 100) / 100, corte: Math.round((a.corte + dentro) * 100) / 100, dura: Math.round((a.dura - dentro) * 100) / 100 },
  ];
}

/* --------------------------------------------------- relógio do palco */

/** O tempo da cena tocando no editor: quem desenha assina e é chamado a cada quadro (sem passar pelo React). */
export type RelogioDaCena = { tempo: () => number; mudar: (t: number) => void; assinar: (f: (t: number) => void) => () => void; tocando: boolean };

export function criarRelogio(): RelogioDaCena {
  let tempo = 0;
  const quem = new Set<(t: number) => void>();
  return {
    tocando: false,
    tempo: () => tempo,
    mudar: (t) => {
      tempo = t;
      quem.forEach((f) => f(t));
    },
    assinar: (f) => {
      quem.add(f);
      f(tempo);
      return () => void quem.delete(f);
    },
  };
}
