/*
 * O tratamento da foto feito nos pixels (no navegador), como no Corel:
 *   - trocar cores: as bolinhas com as cores que compõem o elemento (a
 *     paleta achada aqui) e o "Substituir cor" (a cor pega no conta-gotas
 *     vira outra, com tolerância e suavidade, mantendo luz e sombra);
 *   - a retícula (meio-tom, halftone) para compor e para gravação a laser:
 *     pontos, quadrados, losangos, elipses, linhas e cruzes saem em VETOR
 *     (SVG, nítido em qualquer tamanho); os pontilhados saem em pixels de
 *     uma cor só (PNG).
 * A peça guarda a foto de antes (`de`), o que foi feito e o arquivo gerado
 * (`feito`); o editor gera o arquivo e troca o `src`. Aqui é só conta em
 * cima dos pixels (RGBA de 0 a 255), sem DOM, para rodar e testar em
 * qualquer lugar.
 */

export type TrocaDeCor = {
  de: string;
  para: string;
  /* 0 a 100: o quanto a cor pode variar e ainda ser trocada. */
  tolerancia: number;
  /* 0 a 100: a passagem suave entre o que troca e o que fica (sem serrilhado). */
  suavidade: number;
  /* Mantém o claro e o escuro da foto (a sombra continua sombra); desligado, pinta a cor chapada. */
  manterLuz: boolean;
};

export const ESTILOS_DA_RETICULA = [
  { chave: "pontos", nome: "Pontos", dica: "O meio-tom clássico: círculos que crescem no escuro" },
  { chave: "quadrados", nome: "Quadrados", dica: "Pontos quadrados, gráfico e retrô" },
  { chave: "losangos", nome: "Losangos", dica: "Quadrados girados: textura de jornal antigo" },
  { chave: "elipses", nome: "Elipses", dica: "Pontos alongados que se emendam (corrente)" },
  { chave: "linhas", nome: "Linhas", dica: "Linhas que engrossam no escuro, como gravura" },
  { chave: "cruzes", nome: "Cruzes", dica: "Pequenas cruzes que crescem no escuro" },
  { chave: "pontilhado", nome: "Pontilhado", dica: "Pontinhos espalhados (difusão de erro): o preferido da gravação a laser em foto" },
  { chave: "ordenado", nome: "Ordenado", dica: "Pontilhado em padrão regular (Bayer): textura de jogo antigo" },
] as const;
export type EstiloDaReticula = (typeof ESTILOS_DA_RETICULA)[number]["chave"];
/* Os estilos que saem em pixels (o resto sai em vetor). */
export const reticulaEmPixels = (estilo: EstiloDaReticula) => estilo === "pontilhado" || estilo === "ordenado";

export type MeioTom = {
  estilo: EstiloDaReticula;
  /* Quantas células na largura da foto (nos pontilhados, quantos pixels). */
  celulas: number;
  /* O giro da grade, em graus (45 é o clássico, que o olho menos percebe). */
  angulo: number;
  contraste: number;
  brilho: number;
  /* 0 a 40: abaixo deste tanto de tinta (%) o ponto some (os claros ficam limpos). */
  minimo: number;
  /* Os pontos vão no claro (gravação em material escuro, como ardósia e couro). */
  inverter: boolean;
  /* "uma": tudo na cor da tinta; "original": cada ponto com a cor da foto; "cmyk": as quatro tintas da gráfica. */
  cor: "uma" | "original" | "cmyk";
  tinta: string;
  /* A cor do papel atrás dos pontos (null = transparente). */
  fundo: string | null;
};

export const MEIO_TOM_PADRAO: MeioTom = { estilo: "pontos", celulas: 90, angulo: 45, contraste: 0, brilho: 0, minimo: 4, inverter: false, cor: "uma", tinta: "#111111", fundo: null };
/* Pronto para a gravação a laser: preto puro, sem cinza, papel branco. */
export const MEIO_TOM_LASER: Partial<MeioTom> = { cor: "uma", tinta: "#000000", fundo: "#FFFFFF", minimo: 6, contraste: 20 };

export type TratamentoDaFoto = {
  /* A foto de antes (tudo se refaz a partir dela). */
  de: string;
  /* O arquivo gerado (se o src da peça não for ele, o tratamento ficou velho: a foto mudou por outro caminho). */
  feito?: string;
  trocas?: TrocaDeCor[];
  meioTom?: MeioTom;
};

export const temTratamento = (t: Pick<TratamentoDaFoto, "trocas" | "meioTom"> | null | undefined) => Boolean(t && (t.trocas?.length || t.meioTom));

/* ------------------------------------------------------------- cores */

const HEX = /^#[0-9a-f]{6}$/i;
export function rgbDoHex(hex: string): [number, number, number] {
  const h = HEX.test(hex) ? hex.slice(1) : "000000";
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
export const hexDoRgb = (r: number, g: number, b: number) => `#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("")}`.toUpperCase();

/* sRGB → linear (tabela), para o Lab. */
const LINEAR = Float64Array.from({ length: 256 }, (_, i) => {
  const c = i / 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fInv = (t: number) => (t > 0.206893 ? t * t * t : (t - 16 / 116) / 7.787);
const paraSrgb = (c: number) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);

/** Lab (D65) de uma cor em 0 a 255. */
export function labDoRgb(r: number, g: number, b: number): [number, number, number] {
  const R = LINEAR[r | 0];
  const G = LINEAR[g | 0];
  const B = LINEAR[b | 0];
  const x = f((R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047);
  const y = f(R * 0.2126 + G * 0.7152 + B * 0.0722);
  const z = f((R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883);
  return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
}

export function rgbDoLab(L: number, a: number, b: number): [number, number, number] {
  const y = (L + 16) / 116;
  const x = fInv(a / 500 + y) * 0.95047;
  const z = fInv(y - b / 200) * 1.08883;
  const Y = fInv(y);
  const R = x * 3.2406 + Y * -1.5372 + z * -0.4986;
  const G = x * -0.9689 + Y * 1.8758 + z * 0.0415;
  const B = x * 0.0557 + Y * -0.204 + z * 1.057;
  return [paraSrgb(Math.max(0, Math.min(1, R))), paraSrgb(Math.max(0, Math.min(1, G))), paraSrgb(Math.max(0, Math.min(1, B)))];
}

const distancia = (p: [number, number, number], q: [number, number, number]) => Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);

export type PaletaDaFoto = {
  /* As cores que compõem a imagem, da que mais aparece para a que menos. */
  cores: string[];
  /* Quanto da imagem (0 a 1) fica perto de uma das cores: alto = elemento de cores chapadas. */
  cobertura: number;
  /* Tem partes transparentes (recorte, elemento, logo). */
  transparente: boolean;
};

/**
 * As cores que compõem a imagem (até `max`). Agrupa os pixels em caixinhas
 * de cor (4 bits por canal) e roda o k-means em cima delas, pesado pela
 * quantidade (rápido e sempre igual para a mesma imagem); cores quase
 * iguais viram uma só e as que quase não aparecem saem.
 */
export function paletaDosPixels(dados: Uint8ClampedArray, max = 6): PaletaDaFoto {
  const contagem = new Float64Array(4096);
  const somas = new Float64Array(4096 * 3);
  let opacos = 0;
  let transparentes = 0;
  const total = dados.length / 4;
  const passo = Math.max(1, Math.floor(total / 60000));
  for (let i = 0; i < total; i += passo) {
    const o = i * 4;
    if (dados[o + 3] < 200) {
      if (dados[o + 3] < 30) transparentes += 1;
      continue;
    }
    const r = dados[o];
    const g = dados[o + 1];
    const b = dados[o + 2];
    const k = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
    contagem[k] += 1;
    somas[k * 3] += r;
    somas[k * 3 + 1] += g;
    somas[k * 3 + 2] += b;
    opacos += 1;
  }
  const amostrados = Math.ceil(total / passo);
  const transparente = transparentes > amostrados * 0.02;
  if (!opacos) return { cores: [], cobertura: 0, transparente };
  const caixas: Array<{ lab: [number, number, number]; peso: number }> = [];
  for (let k = 0; k < 4096; k += 1) {
    if (!contagem[k]) continue;
    const n = contagem[k];
    caixas.push({ lab: labDoRgb(somas[k * 3] / n, somas[k * 3 + 1] / n, somas[k * 3 + 2] / n), peso: n });
  }
  caixas.sort((a, b) => b.peso - a.peso);
  /* As sementes: a mais comum e, depois, a que está mais longe das escolhidas (pesada pela quantidade). */
  const k = Math.min(max + 2, caixas.length);
  const centros: Array<[number, number, number]> = [caixas[0].lab];
  while (centros.length < k) {
    let melhor = -1;
    let valor = -1;
    for (let i = 0; i < caixas.length; i += 1) {
      const d = Math.min(...centros.map((c) => distancia(c, caixas[i].lab)));
      const v = d * d * Math.sqrt(caixas[i].peso);
      if (v > valor) {
        valor = v;
        melhor = i;
      }
    }
    if (melhor < 0 || valor <= 0) break;
    centros.push(caixas[melhor].lab);
  }
  const dono = new Int32Array(caixas.length);
  const pesos = new Float64Array(centros.length);
  for (let volta = 0; volta < 12; volta += 1) {
    const novos = centros.map(() => [0, 0, 0] as [number, number, number]);
    pesos.fill(0);
    for (let i = 0; i < caixas.length; i += 1) {
      let j0 = 0;
      let d0 = Infinity;
      for (let j = 0; j < centros.length; j += 1) {
        const d = distancia(centros[j], caixas[i].lab);
        if (d < d0) {
          d0 = d;
          j0 = j;
        }
      }
      dono[i] = j0;
      const w = caixas[i].peso;
      pesos[j0] += w;
      novos[j0][0] += caixas[i].lab[0] * w;
      novos[j0][1] += caixas[i].lab[1] * w;
      novos[j0][2] += caixas[i].lab[2] * w;
    }
    for (let j = 0; j < centros.length; j += 1) if (pesos[j]) centros[j] = [novos[j][0] / pesos[j], novos[j][1] / pesos[j], novos[j][2] / pesos[j]];
  }
  /* Junta as quase iguais (a de mais peso fica) e tira as que quase não aparecem. */
  let grupos = centros.map((lab, j) => ({ lab, peso: pesos[j] })).filter((g) => g.peso > 0).sort((a, b) => b.peso - a.peso);
  const juntas: typeof grupos = [];
  for (const g of grupos) {
    const perto = juntas.find((j) => distancia(j.lab, g.lab) < 10);
    if (perto) perto.peso += g.peso;
    else juntas.push({ ...g });
  }
  grupos = juntas.filter((g) => g.peso >= opacos * 0.015).slice(0, max);
  let dentro = 0;
  for (const c of caixas) if (grupos.some((g) => distancia(g.lab, c.lab) < 22)) dentro += c.peso;
  return { cores: grupos.map((g) => hexDoRgb(...rgbDoLab(...g.lab))), cobertura: dentro / opacos, transparente };
}

/* A tolerância (0 a 100) em distância de cor (Lab) e a suavidade na largura da passagem. */
const raioDa = (tolerancia: number) => 2 + (Math.max(0, Math.min(100, tolerancia)) / 100) * 58;
const bordaDa = (suavidade: number) => 1 + (Math.max(0, Math.min(100, suavidade)) / 100) * 40;

/**
 * Troca as cores nos pixels (no lugar). Cada troca pesa pela distância da
 * cor do pixel até a cor `de` (1 dentro da tolerância, caindo suave até 0
 * na suavidade); com "manter luz", o pixel anda o mesmo tanto que `de` anda
 * até `para` (a sombra e o brilho continuam), senão vira `para` chapado.
 * As trocas olham sempre a cor original do pixel (não encadeiam).
 */
export function trocarCoresNosPixels(dados: Uint8ClampedArray, trocas: TrocaDeCor[]) {
  const lista = trocas
    .filter((t) => HEX.test(t.de) && HEX.test(t.para))
    .map((t) => ({ de: labDoRgb(...rgbDoHex(t.de)), para: labDoRgb(...rgbDoHex(t.para)), raio: raioDa(t.tolerancia), borda: bordaDa(t.suavidade), manterLuz: t.manterLuz }));
  if (!lista.length) return;
  const lembrado = new Map<number, number>();
  for (let o = 0; o < dados.length; o += 4) {
    if (!dados[o + 3]) continue;
    const chave = (dados[o] << 16) | (dados[o + 1] << 8) | dados[o + 2];
    let pronto = lembrado.get(chave);
    if (pronto === undefined) {
      const lab = labDoRgb(dados[o], dados[o + 1], dados[o + 2]);
      let soma = 0;
      const mudanca = [0, 0, 0];
      for (const t of lista) {
        const d = distancia(lab, t.de);
        if (d >= t.raio + t.borda) continue;
        const x = d <= t.raio ? 1 : 1 - (d - t.raio) / t.borda;
        const w = x * x * (3 - 2 * x);
        const alvo = t.manterLuz ? [t.para[0] + lab[0] - t.de[0], t.para[1] + lab[1] - t.de[1], t.para[2] + lab[2] - t.de[2]] : t.para;
        mudanca[0] += w * (alvo[0] - lab[0]);
        mudanca[1] += w * (alvo[1] - lab[1]);
        mudanca[2] += w * (alvo[2] - lab[2]);
        soma += w;
      }
      if (soma === 0) pronto = -1;
      else {
        const div = Math.max(1, soma);
        const [r, g, b] = rgbDoLab(Math.max(0, Math.min(100, lab[0] + mudanca[0] / div)), lab[1] + mudanca[1] / div, lab[2] + mudanca[2] / div);
        pronto = (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
      }
      if (lembrado.size < 250000) lembrado.set(chave, pronto);
    }
    if (pronto < 0) continue;
    dados[o] = (pronto >> 16) & 255;
    dados[o + 1] = (pronto >> 8) & 255;
    dados[o + 2] = pronto & 255;
  }
}

/* ----------------------------------------------------------- retícula */

/* O tom (0 = sem tinta, 1 = tinta cheia) com brilho, contraste e o inverter. */
function tomDa(m: Pick<MeioTom, "contraste" | "brilho" | "inverter">) {
  const c = Math.max(-100, Math.min(100, m.contraste)) / 100;
  const k = c >= 0 ? 1 + c * 3 : 1 + c;
  const b = (Math.max(-100, Math.min(100, m.brilho)) / 100) * 0.5;
  return (luz: number) => {
    const tinta = m.inverter ? luz : 1 - luz;
    return Math.max(0, Math.min(1, (tinta - 0.5) * k + 0.5 - (m.inverter ? -b : b)));
  };
}

/* As somas acumuladas (tabela de áreas) da cor pré-multiplicada e da opacidade: a média de qualquer retângulo sai em 4 leituras. */
function tabelaDeAreas(dados: Uint8ClampedArray, largura: number, altura: number) {
  const L = largura + 1;
  const t = new Float64Array(L * (altura + 1) * 4);
  for (let y = 0; y < altura; y += 1) {
    let r = 0;
    let g = 0;
    let b = 0;
    let a = 0;
    for (let x = 0; x < largura; x += 1) {
      const o = (y * largura + x) * 4;
      const alfa = dados[o + 3] / 255;
      r += (dados[o] / 255) * alfa;
      g += (dados[o + 1] / 255) * alfa;
      b += (dados[o + 2] / 255) * alfa;
      a += alfa;
      const i = ((y + 1) * L + x + 1) * 4;
      const acima = (y * L + x + 1) * 4;
      t[i] = t[acima] + r;
      t[i + 1] = t[acima + 1] + g;
      t[i + 2] = t[acima + 2] + b;
      t[i + 3] = t[acima + 3] + a;
    }
  }
  /* A média (cor sem pré-multiplicar e a cobertura) no retângulo [x0,x1) × [y0,y1). */
  return (x0: number, y0: number, x1: number, y1: number): [number, number, number, number] => {
    const xa = Math.max(0, Math.min(largura, Math.floor(x0)));
    const xb = Math.max(0, Math.min(largura, Math.ceil(x1)));
    const ya = Math.max(0, Math.min(altura, Math.floor(y0)));
    const yb = Math.max(0, Math.min(altura, Math.ceil(y1)));
    if (xb <= xa || yb <= ya) return [0, 0, 0, 0];
    const s = [0, 0, 0, 0];
    for (let c = 0; c < 4; c += 1) s[c] = t[(yb * L + xb) * 4 + c] - t[(ya * L + xb) * 4 + c] - t[(yb * L + xa) * 4 + c] + t[(ya * L + xa) * 4 + c];
    const area = (xb - xa) * (yb - ya);
    return s[3] > 1e-6 ? [s[0] / s[3], s[1] / s[3], s[2] / s[3], s[3] / area] : [0, 0, 0, 0];
  };
}

const num = (v: number) => {
  const r = Math.round(v * 10) / 10;
  return Object.is(r, -0) ? "0" : String(r);
};

/* O desenho de um ponto com `tinta` (0 a 1 da área da célula) no centro (cx, cy), com a grade girada (u = direção da grade). */
function desenhoDoPonto(estilo: EstiloDaReticula, tinta: number, cx: number, cy: number, c: number, ux: number, uy: number): string {
  const vx = -uy;
  const vy = ux;
  const poligono = (pontos: Array<[number, number]>) => `M${pontos.map(([p, q]) => `${num(cx + p * ux + q * vx)} ${num(cy + p * uy + q * vy)}`).join("L")}Z`;
  if (estilo === "pontos" || estilo === "elipses") {
    /* Até se encostarem (tinta = π/4) o círculo tem a área certa; depois cresce até cobrir os cantos. */
    const r = tinta <= Math.PI / 4 ? c * Math.sqrt(tinta / Math.PI) : c * (0.5 + ((tinta - Math.PI / 4) / (1 - Math.PI / 4)) * 0.21);
    if (estilo === "pontos") return `M${num(cx - r)} ${num(cy)}a${num(r)} ${num(r)} 0 1 0 ${num(2 * r)} 0a${num(r)} ${num(r)} 0 1 0 ${num(-2 * r)} 0Z`;
    /* Elipse deitada na direção da grade (mesma área do círculo): os pontos se emendam em corrente. */
    const rx = r * 1.3;
    const ry = r / 1.3;
    const pontos: Array<[number, number]> = [];
    for (let i = 0; i < 12; i += 1) pontos.push([Math.cos((i / 12) * Math.PI * 2) * rx, Math.sin((i / 12) * Math.PI * 2) * ry]);
    return poligono(pontos);
  }
  if (estilo === "quadrados") {
    const s = (c * Math.sqrt(tinta)) / 2;
    return poligono([[-s, -s], [s, -s], [s, s], [-s, s]]);
  }
  if (estilo === "losangos") {
    const h = c * Math.sqrt(tinta / 2);
    return poligono([[0, -h], [h, 0], [0, h], [-h, 0]]);
  }
  /* Cruz: dois braços da largura da célula com a grossura que dá a área. */
  const t = (c * (1 - Math.sqrt(Math.max(0, 1 - tinta)))) / 2;
  const m = c / 2;
  return poligono([[-t, -m], [t, -m], [t, -t], [m, -t], [m, t], [t, t], [t, m], [-t, m], [-t, t], [-m, t], [-m, -t], [-t, -t]]);
}

/* As tintas CMYK de uma cor (0 a 1), com o preto separado. */
function cmykDa(r: number, g: number, b: number): [number, number, number, number] {
  const k = 1 - Math.max(r, g, b);
  if (k >= 0.999) return [0, 0, 0, 1];
  return [(1 - r - k) / (1 - k), (1 - g - k) / (1 - k), (1 - b - k) / (1 - k), k];
}
const TINTAS_CMYK = [
  { cor: "#00AEEF", angulo: 15 },
  { cor: "#EC008C", angulo: 75 },
  { cor: "#FFF200", angulo: 0 },
  { cor: "#111111", angulo: 45 },
];

/**
 * A retícula em vetor (SVG): a grade girada cobre a foto, cada célula lê a
 * média da foto embaixo dela (cor e cobertura) e vira um ponto com a área
 * da tinta. `largura` × `altura` são os pixels lidos; `natural` é o tamanho
 * da foto original (o SVG sai nele, com o desenho escalado).
 */
export function reticulaEmSvg(dados: Uint8ClampedArray, largura: number, altura: number, m: MeioTom, natural: { w: number; h: number } = { w: largura, h: altura }): string {
  const media = tabelaDeAreas(dados, largura, altura);
  const tom = tomDa(m);
  const c = largura / Math.max(8, Math.min(400, m.celulas));
  const minimo = Math.max(0, Math.min(40, m.minimo)) / 100;
  const meioX = largura / 2;
  const meioY = altura / 2;
  const n = Math.ceil(Math.hypot(largura, altura) / (2 * c)) + 1;

  /* Uma camada: a grade no ângulo, e a tinta de cada célula pela função `tintaDa` (cor média e cobertura → 0 a 1). */
  const camada = (angulo: number, tintaDa: (cor: [number, number, number, number]) => number, porCor?: (cor: [number, number, number, number]) => string) => {
    const rad = (angulo * Math.PI) / 180;
    const ux = Math.cos(rad);
    const uy = Math.sin(rad);
    const caminhos = new Map<string, string[]>();
    const linhas: Array<Array<{ x: number; y: number; t: number }>> = [];
    for (let j = -n; j < n; j += 1) {
      const linha: Array<{ x: number; y: number; t: number }> = [];
      for (let i = -n; i < n; i += 1) {
        const p = (i + 0.5) * c;
        const q = (j + 0.5) * c;
        const x = meioX + p * ux - q * uy;
        const y = meioY + p * uy + q * ux;
        if (x < -c || y < -c || x > largura + c || y > altura + c) {
          if (m.estilo === "linhas") linha.push({ x, y, t: 0 });
          continue;
        }
        const cor = media(x - c / 2, y - c / 2, x + c / 2, y + c / 2);
        const tinta = cor[3] > 0.02 ? tintaDa(cor) * Math.min(1, cor[3] * 1.15) : 0;
        if (m.estilo === "linhas") {
          linha.push({ x, y, t: tinta < minimo ? 0 : tinta });
          continue;
        }
        if (tinta < minimo) continue;
        const chave = porCor ? porCor(cor) : "";
        const lista = caminhos.get(chave) ?? [];
        lista.push(desenhoDoPonto(m.estilo, Math.min(1, tinta), x, y, c, ux, uy));
        caminhos.set(chave, lista);
      }
      if (m.estilo === "linhas") linhas.push(linha);
    }
    if (m.estilo === "linhas") {
      /* Cada linha da grade vira faixas de grossura variável (some onde não há tinta). */
      const vx = -uy;
      const vy = ux;
      const partes: string[] = [];
      for (const linha of linhas) {
        let trecho: Array<{ x: number; y: number; t: number }> = [];
        const fechar = () => {
          if (trecho.length >= 1) {
            const ida = trecho.map((p) => `${num(p.x + (vx * p.t * c) / 2)} ${num(p.y + (vy * p.t * c) / 2)}`);
            const volta = [...trecho].reverse().map((p) => `${num(p.x - (vx * p.t * c) / 2)} ${num(p.y - (vy * p.t * c) / 2)}`);
            const ponta = (p: { x: number; y: number }, s: number) => `${num(p.x + (ux * s * c) / 2)} ${num(p.y + (uy * s * c) / 2)}`;
            partes.push(`M${ponta(trecho[0], -1)}L${ida.join("L")}L${ponta(trecho[trecho.length - 1], 1)}L${volta.join("L")}Z`);
          }
          trecho = [];
        };
        for (const p of linha) {
          if (p.t > 0) trecho.push(p);
          else fechar();
        }
        fechar();
      }
      caminhos.set("", partes);
    }
    return caminhos;
  };

  const luzDa = (cor: [number, number, number, number]) => 0.2126 * cor[0] + 0.7152 * cor[1] + 0.0722 * cor[2];
  const partes: string[] = [];
  if (m.fundo && HEX.test(m.fundo)) partes.push(`<rect width="${largura}" height="${altura}" fill="${m.fundo}"/>`);
  if (m.cor === "cmyk") {
    partes.push(`<g style="isolation:isolate">`);
    TINTAS_CMYK.forEach((tinta, canal) => {
      const caminhos = camada(m.angulo + tinta.angulo - 45, (cor) => {
        const [r, g, b] = m.inverter ? [1 - cor[0], 1 - cor[1], 1 - cor[2]] : [cor[0], cor[1], cor[2]];
        return tomDa({ ...m, inverter: false })(1 - cmykDa(r, g, b)[canal]);
      });
      const d = caminhos.get("")?.join("") ?? "";
      if (d) partes.push(`<path fill="${tinta.cor}" style="mix-blend-mode:multiply" d="${d}"/>`);
    });
    partes.push(`</g>`);
  } else if (m.cor === "original") {
    /* Cada ponto na cor da foto (as cores parecidas num caminho só, para o arquivo ficar leve). */
    const caminhos = camada(
      m.angulo,
      (cor) => tom(luzDa(cor)),
      (cor) => hexDoRgb(Math.round(cor[0] * 15) * 17, Math.round(cor[1] * 15) * 17, Math.round(cor[2] * 15) * 17),
    );
    for (const [cor, lista] of caminhos) if (lista.length) partes.push(`<path fill="${cor}" d="${lista.join("")}"/>`);
  } else {
    const d = camada(m.angulo, (cor) => tom(luzDa(cor))).get("")?.join("") ?? "";
    if (d) partes.push(`<path fill="${HEX.test(m.tinta) ? m.tinta : "#111111"}" d="${d}"/>`);
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(natural.w)}" height="${Math.round(natural.h)}" viewBox="0 0 ${largura} ${altura}" preserveAspectRatio="none">${partes.join("")}</svg>`;
}

/* A matriz de Bayer 8 × 8 (limiares de 0 a 1). */
const BAYER = (() => {
  const m = [[0]];
  let atual = m;
  for (let n = 1; n < 8; n *= 2) {
    const nova: number[][] = [];
    for (let y = 0; y < n * 2; y += 1) {
      nova.push([]);
      for (let x = 0; x < n * 2; x += 1) nova[y].push(4 * atual[y % n][x % n] + [0, 2, 3, 1][(y >= n ? 2 : 0) + (x >= n ? 1 : 0)]);
    }
    atual = nova;
  }
  return atual.map((linha) => linha.map((v) => (v + 0.5) / 64));
})();

/**
 * O pontilhado em pixels (os pixels lidos já no tamanho final): cada pixel
 * vira tinta ou papel. "pontilhado" espalha o erro para os vizinhos
 * (Floyd–Steinberg em zigue-zague); "ordenado" compara com a matriz de
 * Bayer. Devolve os pixels novos (RGBA): a tinta, o papel (ou
 * transparente) e o transparente da foto continua transparente.
 */
export function pontilharPixels(dados: Uint8ClampedArray, largura: number, altura: number, m: MeioTom): Uint8ClampedArray {
  const tom = tomDa(m);
  const saida = new Uint8ClampedArray(dados.length);
  const fundo = m.fundo && HEX.test(m.fundo) ? rgbDoHex(m.fundo) : null;
  const canais = m.cor === "uma" ? 1 : 3;
  const tinta = rgbDoHex(HEX.test(m.tinta) ? m.tinta : "#111111");
  /* Por canal: quanto de tinta (0 a 1) cada pixel pede. */
  const pedido = Array.from({ length: canais }, () => new Float32Array(largura * altura));
  for (let i = 0; i < largura * altura; i += 1) {
    const o = i * 4;
    const [r, g, b] = [dados[o] / 255, dados[o + 1] / 255, dados[o + 2] / 255];
    if (canais === 1) pedido[0][i] = tom(0.2126 * r + 0.7152 * g + 0.0722 * b);
    else {
      /* Em cores: cada canal (R, G, B) pontilhado à parte, como os pontos de luz de uma tela. */
      pedido[0][i] = tom(r);
      pedido[1][i] = tom(g);
      pedido[2][i] = tom(b);
    }
  }
  const ligado = Array.from({ length: canais }, () => new Uint8Array(largura * altura));
  for (let c = 0; c < canais; c += 1) {
    const p = pedido[c];
    const l = ligado[c];
    if (m.estilo === "ordenado") {
      for (let y = 0; y < altura; y += 1) for (let x = 0; x < largura; x += 1) l[y * largura + x] = p[y * largura + x] > BAYER[y & 7][x & 7] ? 1 : 0;
      continue;
    }
    for (let y = 0; y < altura; y += 1) {
      const paraDireita = y % 2 === 0;
      for (let k = 0; k < largura; k += 1) {
        const x = paraDireita ? k : largura - 1 - k;
        const i = y * largura + x;
        const v = p[i];
        const on = v >= 0.5 ? 1 : 0;
        l[i] = on;
        const erro = v - on;
        const d = paraDireita ? 1 : -1;
        if (x + d >= 0 && x + d < largura) p[i + d] += (erro * 7) / 16;
        if (y + 1 < altura) {
          if (x - d >= 0 && x - d < largura) p[i + largura - d] += (erro * 3) / 16;
          p[i + largura] += (erro * 5) / 16;
          if (x + d >= 0 && x + d < largura) p[i + largura + d] += erro / 16;
        }
      }
    }
  }
  for (let i = 0; i < largura * altura; i += 1) {
    const o = i * 4;
    if (dados[o + 3] < 128) continue;
    if (canais === 1) {
      if (ligado[0][i]) {
        saida[o] = tinta[0];
        saida[o + 1] = tinta[1];
        saida[o + 2] = tinta[2];
        saida[o + 3] = 255;
      } else if (fundo) {
        saida[o] = fundo[0];
        saida[o + 1] = fundo[1];
        saida[o + 2] = fundo[2];
        saida[o + 3] = 255;
      }
      continue;
    }
    /* Tinta ligada = canal apagado (o "tom" é tinta, não luz). */
    saida[o] = ligado[0][i] ? 0 : 255;
    saida[o + 1] = ligado[1][i] ? 0 : 255;
    saida[o + 2] = ligado[2][i] ? 0 : 255;
    saida[o + 3] = 255;
  }
  return saida;
}

/* ------------------------------------------------------------ limpeza */

const numeroEntre = (v: unknown, min: number, max: number, padrao: number) => {
  const n = Number(v);
  return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : padrao;
};
const hexOu = (v: unknown, padrao: string) => (typeof v === "string" && HEX.test(v) ? v.toUpperCase() : padrao);

export function meioTomLimpo(bruto: unknown): MeioTom | null {
  if (!bruto || typeof bruto !== "object") return null;
  const m = bruto as Record<string, unknown>;
  const estilo = ESTILOS_DA_RETICULA.find((e) => e.chave === m.estilo)?.chave ?? "pontos";
  return {
    estilo,
    celulas: Math.round(numeroEntre(m.celulas, 8, reticulaEmPixels(estilo) ? 2400 : 400, MEIO_TOM_PADRAO.celulas)),
    angulo: Math.round(numeroEntre(m.angulo, 0, 90, 45)),
    contraste: Math.round(numeroEntre(m.contraste, -100, 100, 0)),
    brilho: Math.round(numeroEntre(m.brilho, -100, 100, 0)),
    minimo: Math.round(numeroEntre(m.minimo, 0, 40, MEIO_TOM_PADRAO.minimo)),
    inverter: m.inverter === true,
    cor: m.cor === "original" || m.cor === "cmyk" ? m.cor : "uma",
    tinta: hexOu(m.tinta, MEIO_TOM_PADRAO.tinta),
    fundo: m.fundo === null || m.fundo === undefined ? null : hexOu(m.fundo, "#FFFFFF"),
  };
}

export function trocasLimpas(bruto: unknown): TrocaDeCor[] {
  if (!Array.isArray(bruto)) return [];
  return bruto.slice(0, 12).flatMap((item): TrocaDeCor[] => {
    const t = (item ?? {}) as Record<string, unknown>;
    if (typeof t.de !== "string" || !HEX.test(t.de) || typeof t.para !== "string" || !HEX.test(t.para)) return [];
    return [{ de: t.de.toUpperCase(), para: t.para.toUpperCase(), tolerancia: Math.round(numeroEntre(t.tolerancia, 0, 100, 30)), suavidade: Math.round(numeroEntre(t.suavidade, 0, 100, 40)), manterLuz: t.manterLuz !== false }];
  });
}

/** O tratamento guardado na peça (os endereços passam pelo mesmo crivo das fotos). */
export function tratamentoLimpo(bruto: unknown, enderecoValido: (s: string) => boolean): TratamentoDaFoto | null {
  if (!bruto || typeof bruto !== "object") return null;
  const t = bruto as Record<string, unknown>;
  const de = typeof t.de === "string" ? t.de.trim() : "";
  if (!enderecoValido(de)) return null;
  const feito = typeof t.feito === "string" && enderecoValido(t.feito.trim()) ? t.feito.trim() : undefined;
  const trocas = trocasLimpas(t.trocas);
  const meioTom = meioTomLimpo(t.meioTom);
  if (!trocas.length && !meioTom) return null;
  return { de, ...(feito ? { feito } : {}), ...(trocas.length ? { trocas } : {}), ...(meioTom ? { meioTom } : {}) };
}

/* -------------------------------------------------------- PNG com DPI */

const CRC = (() => {
  const t = new Uint32Array(256);
  for (let n = 0; n < 256; n += 1) {
    let c = n;
    for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    t[n] = c >>> 0;
  }
  return t;
})();
function crc32(bytes: Uint8Array) {
  let c = 0xffffffff;
  for (let i = 0; i < bytes.length; i += 1) c = CRC[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
  return (c ^ 0xffffffff) >>> 0;
}

/**
 * O PNG com o DPI gravado (o bloco pHYs, logo depois do IHDR): o programa
 * do laser (LightBurn, RDWorks) abre no tamanho certo em milímetros.
 */
export function pngComDpi(png: Uint8Array, dpi: number): Uint8Array {
  const assinatura = [137, 80, 78, 71, 13, 10, 26, 10];
  if (png.length < 33 || assinatura.some((b, i) => png[i] !== b)) return png;
  const porMetro = Math.round(dpi / 0.0254);
  const corpo = new Uint8Array(13);
  corpo.set([0x70, 0x48, 0x59, 0x73]);
  const v = new DataView(corpo.buffer);
  v.setUint32(4, porMetro);
  v.setUint32(8, porMetro);
  corpo[12] = 1;
  const bloco = new Uint8Array(21);
  new DataView(bloco.buffer).setUint32(0, 9);
  bloco.set(corpo, 4);
  new DataView(bloco.buffer).setUint32(17, crc32(corpo));
  /* Tira um pHYs que já existir e põe o novo depois do IHDR (8 + 25 bytes). */
  const partes: Uint8Array[] = [png.slice(0, 33), bloco];
  let i = 33;
  while (i + 8 <= png.length) {
    const tamanho = new DataView(png.buffer, png.byteOffset + i).getUint32(0);
    const tipo = String.fromCharCode(png[i + 4], png[i + 5], png[i + 6], png[i + 7]);
    const fim = i + 12 + tamanho;
    if (tipo !== "pHYs") partes.push(png.slice(i, fim));
    i = fim;
  }
  const total = partes.reduce((s, p) => s + p.length, 0);
  const saida = new Uint8Array(total);
  let pos = 0;
  for (const p of partes) {
    saida.set(p, pos);
    pos += p.length;
  }
  return saida;
}
