/**
 * A ficha de produção de um pedido: o que separar, o que fazer e quanto tempo
 * leva.
 *
 * A receita do produto diz quanto se consome POR UNIDADE DA BASE: 1 m² de lona
 * por m², 1,5 m de torsal por metro de largura, 4 min de impressora por m².
 * Isso serve para precificar; não serve para produzir. Quem está na máquina
 * precisa do número do pedido: 25,2 m² de lona, 19,8 m de torsal, 96 minutos
 * de impressão mais 10 de preparo.
 *
 * A conta é a mesma da baixa de estoque (`baixar_estoque_de_um_item`) e da
 * saúde da precificação — mesma base, mesma medida, mesma perda, mesma regra
 * de peça inteira. Ela vive num lugar só porque conta repetida é conta que
 * diverge: a ficha impressa dizia "1 m²" para um banner de 6 m², e ninguém
 * conferia porque ninguém confiava.
 *
 * A natureza do insumo decide como se arredonda (decisão do Natan, 02/09/2026):
 * - peça inteira (caneca, ilhós): sobe para o inteiro POR PEÇA e multiplica;
 *   a perda em % é risco de errar e fica só no custo;
 * - com rendimento (folha, chapa): teto de peças ÷ N; no checklist do pedido
 *   soma as frações antes de arredondar;
 * - contínuo (m, m², l, kg): exato, com a perda; na bobina, quando roda
 *   sozinho, a conta vira o corte;
 * - serviço modelado como insumo: nada a separar.
 */

import { medidaDaBase, ROTULO_DA_BASE, type BaseDeConsumo } from "../precificacao/composicao.ts";

/* De onde a linha veio: da ficha técnica do produto, ou da receita de uma
   opção escolhida ("Com pingente" consome um pingente por peça). */
export type OrigemDoInsumo = { tipo: "ficha" } | { tipo: "opcao"; opcao: string };

export type NaturezaDoInsumo = "peca" | "rendimento" | "continuo" | "servico";

export type Rodagem = "casado" | "sozinho";

/* O que a produção decidiu para ESTE pedido, por insumo: quantas peças cabem
   na folha desta vez, se a peça roda sozinha na bobina. Chave: o id do
   material; em linha antiga sem vínculo, "nome|unidade". */
export type AjusteDeRodagem = {
  rendimento?: number | null;
  rodagem?: Rodagem | null;
};
export type PlanoDeRodagemDoItem = {
  insumos?: Record<string, AjusteDeRodagem>;
};

export type InsumoDaReceita = {
  nome: string;
  unidade: string;
  /* Quanto se consome por unidade da base. */
  consumo: number;
  base: BaseDeConsumo;
  perdaPct?: number | null;
  origem?: OrigemDoInsumo;
  /* O cadastro do insumo, para a perda registrada na bancada baixar o
     estoque certo. Ausente em linha antiga sem vínculo. */
  materialId?: string | null;
  /* Quantas peças saem de uma unidade do insumo (folha, chapa). Vem do
     cadastro (`pieces_per_unit`). */
  rendimento?: number | null;
  /* Formato de compra (bobina, resma, caixa…): desempata a natureza. */
  formato?: string | null;
  /* Largura da bobina, para a conta do corte. */
  larguraBobinaM?: number | null;
  /* Serviço modelado como insumo (corte terceirizado): não se separa. */
  servico?: boolean | null;
};

export type TipoDeRecurso = "maquina" | "pessoa" | null;

export type ProcessoDaReceita = {
  nome: string;
  tipo: TipoDeRecurso;
  /* A máquina ou o cargo que executa. */
  recurso: string | null;
  /* Minutos por unidade da base. */
  minutos: number;
  base: BaseDeConsumo;
  /* Preparo: uma vez por item do pedido, não por peça. */
  minutosDeSetup?: number | null;
};

export type ItemParaFicha = {
  quantidade: number;
  larguraM?: number | null;
  alturaM?: number | null;
  insumos: InsumoDaReceita[];
  processos: ProcessoDaReceita[];
  plano?: PlanoDeRodagemDoItem | null;
};

export type CorteNaBobina = {
  larguraBobinaM: number;
  /* Metros de bobina a puxar. */
  comprimentoM: number;
  /* Quantas peças cabem lado a lado numa faixa. */
  porFaixa: number;
  faixas: number;
  /* A peça entrou com a largura ao longo da bobina ("deitada") ou girada. */
  girada: boolean;
};

export type ConsumoDoInsumo = {
  nome: string;
  unidade: string;
  base: BaseDeConsumo;
  rotuloDaBase: string;
  consumoPorMedida: number;
  perdaPct: number;
  /* A medida do pedido inteiro: m² somados, metros de largura somados, peças. */
  medidaTotal: number;
  /* A conta crua da receita, sem perda e sem arredondar. Com rendimento, é
     peças ÷ N (a fração da folha). */
  quantidadeLiquida: number;
  /* O que separar de fato, já na regra da natureza. */
  quantidade: number;
  /* A líquida mais a perda, sempre: é o número da precificação. */
  quantidadeComPerda: number;
  natureza: NaturezaDoInsumo;
  /* Peças por unidade, quando a natureza é rendimento: o do plano de rodagem
     quando existe, senão o do cadastro (ou o deduzido). */
  rendimento: number | null;
  /* O que o cadastro diz, para a tela mostrar de onde o plano se afastou. */
  rendimentoDoCadastro: number | null;
  /* O rendimento foi deduzido de um consumo fracionado (0,17 → 6 por folha)
     e ainda não está confirmado no cadastro. */
  rendimentoEstimado: boolean;
  /* Fração da última folha que sobra (0,5 = meia folha). */
  sobra: number;
  rodagem: Rodagem | null;
  corte: CorteNaBobina | null;
  /* A largura da bobina do cadastro, para a tela prever o corte antes de
     a pessoa escolher "roda sozinho". */
  larguraBobinaM: number | null;
  /* Compatibilidade: unidade que só existe inteira. */
  discreta: boolean;
  origem: OrigemDoInsumo;
  /* A base pede uma dimensão que o item não tem (caneca não tem largura). A
     linha fica, com quantidade zero, para a produção ver que falta medida. */
  semMedida: boolean;
  materialId: string | null;
  /* A chave do plano de rodagem para esta linha. */
  chave: string;
  /* Esta linha aceita ajuste de rodagem (rendimento ou bobina). */
  ajustavel: "rendimento" | "bobina" | null;
};

export type TempoDoProcesso = {
  nome: string;
  tipo: TipoDeRecurso;
  recurso: string | null;
  base: BaseDeConsumo;
  rotuloDaBase: string;
  minutosPorMedida: number;
  medidaTotal: number;
  minutosDeProducao: number;
  minutosDeSetup: number;
  minutosTotais: number;
  semMedida: boolean;
};

export type FichaDoItem = {
  pecas: number;
  larguraM: number;
  alturaM: number;
  areaTotal: number;
  insumos: ConsumoDoInsumo[];
  processos: TempoDoProcesso[];
  minutosTotais: number;
};

export type LinhaDeSeparacao = {
  nome: string;
  unidade: string;
  quantidade: number;
  /* Em quantos itens do pedido este insumo aparece. */
  itens: number;
  natureza: NaturezaDoInsumo;
  /* Com rendimento: a soma das frações antes do teto (1,5 folha → 2). */
  fracaoSomada: number;
};

export type VariacaoEscolhida = {
  atributo: string;
  valor: string;
  optionId: string | null;
};

/* Como a receita consome, do jeito que se lê na bancada: "1,5 m por m de
   largura". O rótulo da precificação ("acompanha a largura") explica; este
   instrui. */
export const POR_BASE: Record<BaseDeConsumo, string> = {
  por_peca: "por peça",
  por_m2: "por m²",
  por_m_largura: "por m de largura",
  por_m_altura: "por m de altura",
  por_perimetro: "por m de contorno",
};

export const ROTULO_DA_NATUREZA: Record<NaturezaDoInsumo, string> = {
  peca: "peça inteira",
  rendimento: "com rendimento",
  continuo: "contínuo",
  servico: "serviço",
};

/* A unidade como o cadastro guarda ("m2") vira como se lê ("m²"). */
export function unidadeLegivel(unidade?: string | null): string {
  const u = (unidade || "un").trim();
  const mapa: Record<string, string> = { m2: "m²", M2: "m²", m3: "m³", un: "un", und: "un", unid: "un" };
  return mapa[u] ?? u;
}

/**
 * A quantidade na unidade em que se lê: 0,017 l vira 17 ml, 0,01 kg vira
 * 10 g, 0,003 m² vira 30 cm². A conta continua na unidade do cadastro; só a
 * leitura muda.
 */
export function quantidadeLegivel(quantidade: number, unidade?: string | null): { valor: number; unidade: string } {
  const q = Number(quantidade) || 0;
  const u = (unidade || "un").trim().toLowerCase();
  if (q > 0 && q < 1 && (u === "l" || u === "litro" || u === "litros")) return { valor: quatroCasas(q * 1000), unidade: "ml" };
  if (q > 0 && q < 1 && u === "kg") return { valor: quatroCasas(q * 1000), unidade: "g" };
  if (q > 0 && q < 0.01 && u === "m2") return { valor: quatroCasas(q * 10000), unidade: "cm²" };
  return { valor: q, unidade: unidadeLegivel(unidade) };
}

const enxutoPtBr = (n: number) =>
  new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 3 }).format(n);

/**
 * Como o consumo foi calculado, em português de bancada.
 *
 * "1 m² por m²" é linguagem de precificação — a taxa da receita. Quem separa
 * material precisa ler o que a taxa vira neste pedido: "a área da peça ·
 * 12 m²", "2 m a cada m de largura · 6 m de largura". A frase carrega a
 * medida do pedido para a conta fechar de cabeça com a coluna Separar.
 */
export function explicarConsumo(
  insumo: Pick<ConsumoDoInsumo, "base" | "consumoPorMedida" | "unidade" | "medidaTotal" | "semMedida"> &
    Partial<Pick<ConsumoDoInsumo, "natureza" | "rendimento" | "rendimentoEstimado">>
): string {
  const consumo = numero(insumo.consumoPorMedida);
  const unidade = unidadeLegivel(insumo.unidade);
  const taxa = `${enxutoPtBr(consumo)} ${unidade}`;
  const medida = enxutoPtBr(insumo.medidaTotal);
  const um = consumo === 1;

  if (insumo.natureza === "rendimento" && insumo.rendimento) {
    const pecas = `${medida} ${insumo.medidaTotal === 1 ? "peça" : "peças"}`;
    return `${enxutoPtBr(insumo.rendimento)} peças por ${unidade}${insumo.rendimentoEstimado ? " (estimado)" : ""} · ${pecas}`;
  }

  switch (insumo.base) {
    case "por_m2":
      if (insumo.semMedida) return "pela área da peça · sem medida";
      return um ? `a área da peça · ${medida} m²` : `${taxa} a cada m² · ${medida} m²`;
    case "por_m_largura":
      if (insumo.semMedida) return "pela largura da peça · sem medida";
      return um ? `a largura da peça · ${medida} m` : `${taxa} a cada m de largura · ${medida} m de largura`;
    case "por_m_altura":
      if (insumo.semMedida) return "pela altura da peça · sem medida";
      return um ? `a altura da peça · ${medida} m` : `${taxa} a cada m de altura · ${medida} m de altura`;
    case "por_perimetro":
      if (insumo.semMedida) return "pelo contorno da peça · sem medida";
      return um ? `o contorno da peça · ${medida} m` : `${taxa} a cada m de contorno · ${medida} m de contorno`;
    case "por_peca":
    default: {
      const pecas = `${medida} ${insumo.medidaTotal === 1 ? "peça" : "peças"}`;
      return `${taxa} por peça · ${pecas}`;
    }
  }
}

/**
 * As notas embaixo da quantidade: de onde o número veio, em uma linha cada.
 * Compartilhadas pela ficha impressa e pelo painel, para os dois dizerem a
 * mesma coisa.
 */
export function notasDaQuantidade(insumo: ConsumoDoInsumo): string[] {
  const unidade = unidadeLegivel(insumo.unidade);
  const perda = insumo.perdaPct > 0 ? `${enxutoPtBr(insumo.perdaPct)}% de perda só no custo` : null;
  const notas: string[] = [];
  switch (insumo.natureza) {
    case "servico":
      notas.push("serviço · nada a separar");
      break;
    case "peca":
      if (insumo.quantidadeLiquida !== insumo.quantidade) {
        notas.push(`${enxutoPtBr(insumo.quantidadeLiquida)} pela conta · sobe para inteiro por peça`);
      }
      if (perda) notas.push(perda);
      break;
    case "rendimento": {
      const n = insumo.rendimento ?? 0;
      notas.push(`${enxutoPtBr(n)} por ${unidade}${insumo.rendimentoEstimado ? " · estimado, confirme no cadastro" : ""}`);
      if (insumo.sobra > 0) notas.push(`sobra ${enxutoPtBr(insumo.sobra)} ${unidade}`);
      if (perda) notas.push(perda);
      break;
    }
    case "continuo":
    default:
      if (insumo.corte) {
        const c = insumo.corte;
        notas.push(
          `corte de ${enxutoPtBr(c.comprimentoM)} m na bobina de ${enxutoPtBr(c.larguraBobinaM)} m · ${c.porFaixa} por faixa${c.girada ? " · peça girada" : ""}`
        );
      } else if (insumo.perdaPct > 0 && !insumo.semMedida) {
        notas.push(`${enxutoPtBr(insumo.quantidadeLiquida)} ${unidade} + ${enxutoPtBr(insumo.perdaPct)}% perda`);
      }
      break;
  }
  return notas;
}

/* Quatro casas, como a baixa de estoque grava: o extrato e a ficha precisam
   mostrar o mesmo número. */
const quatroCasas = (n: number) => Math.round(n * 10000) / 10000;

/* Unidades que só existem inteiras. O que não está aqui é contínuo: metro,
   m², litro, quilo, mililitro. Mesma lista da função SQL
   `unidade_e_inteira`. */
const UNIDADES_DISCRETAS = new Set([
  "un", "und", "unid", "unidade", "unidades",
  "pc", "pç", "pcs", "pçs", "peca", "peça", "pecas", "peças",
  "folha", "folhas", "par", "pares", "kit", "kits",
  "caixa", "caixas", "pacote", "pacotes", "rolo", "rolos", "chapa", "chapas",
]);

export function unidadeDiscreta(unidade?: string | null): boolean {
  return UNIDADES_DISCRETAS.has((unidade || "").trim().toLowerCase());
}

/* Sobe para o inteiro, tolerando o ruído do ponto flutuante (6 × 0,1667 =
   1,0002 é uma folha, não duas). Mesma tolerância da função SQL `teto_util`. */
const teto = (n: number) => Math.ceil(quatroCasas(n) - 0.001);

/**
 * O rendimento escondido num consumo fracionado: 0,17 por peça é "6 por
 * folha", 0,05 é "20 por folha". Mesma tolerância da função SQL
 * `rendimento_deduzido`: 1 ÷ consumo a até 5% de um inteiro ≥ 2. Fora disso
 * (0,22 → 4,5) não é rendimento, é unidade errada.
 */
export function rendimentoDeduzido(consumo: number): number | null {
  const c = numero(consumo);
  if (c <= 0 || c >= 1) return null;
  const inverso = 1 / c;
  const n = Math.round(inverso);
  if (n < 2) return null;
  return Math.abs(inverso - n) <= 0.05 * n ? n : null;
}

/**
 * A natureza do insumo, a partir do que o cadastro diz. Serviço vence tudo;
 * rendimento gravado vence a unidade; unidade inteira com consumo fracionado
 * é rendimento escondido ou unidade errada.
 */
export function naturezaDoInsumo(i: {
  unidade: string;
  base: BaseDeConsumo;
  consumo: number;
  rendimento?: number | null;
  formato?: string | null;
  servico?: boolean | null;
}): { natureza: NaturezaDoInsumo; rendimento: number | null; rendimentoEstimado: boolean } {
  if (i.servico) return { natureza: "servico", rendimento: null, rendimentoEstimado: false };
  const gravado = numero(i.rendimento);
  if (gravado > 0 && i.base === "por_peca") {
    return { natureza: "rendimento", rendimento: gravado, rendimentoEstimado: false };
  }
  const formato = (i.formato || "").trim().toLowerCase();
  const inteira = unidadeDiscreta(i.unidade) || formato === "caixa" || formato === "resma";
  if (!inteira) return { natureza: "continuo", rendimento: null, rendimentoEstimado: false };

  if (i.base === "por_peca" && numero(i.consumo) < 1) {
    const deduzido = rendimentoDeduzido(i.consumo);
    if (deduzido) return { natureza: "rendimento", rendimento: deduzido, rendimentoEstimado: true };
    return { natureza: "continuo", rendimento: null, rendimentoEstimado: false };
  }
  return { natureza: "peca", rendimento: null, rendimentoEstimado: false };
}

/**
 * O corte quando a peça roda sozinha na bobina: quantas cabem lado a lado,
 * quantas faixas, quantos metros puxar. Testa a peça deitada e girada e fica
 * com o que gasta menos bobina. Peça maior que a bobina nos dois sentidos não
 * tem corte — fica a área, e a bancada resolve com emenda.
 */
export function corteNaBobina(
  larguraBobinaM: number,
  larguraPecaM: number,
  alturaPecaM: number,
  pecas: number
): CorteNaBobina | null {
  const L = numero(larguraBobinaM);
  const w = numero(larguraPecaM);
  const h = numero(alturaPecaM);
  const n = Math.max(0, Math.round(numero(pecas)));
  if (L <= 0 || w <= 0 || h <= 0 || n <= 0) return null;

  const opcoes: CorteNaBobina[] = [];
  if (w <= L) {
    const porFaixa = Math.floor(quatroCasas(L / w));
    const faixas = Math.ceil(n / porFaixa);
    opcoes.push({ larguraBobinaM: L, comprimentoM: quatroCasas(faixas * h), porFaixa, faixas, girada: false });
  }
  if (h <= L) {
    const porFaixa = Math.floor(quatroCasas(L / h));
    const faixas = Math.ceil(n / porFaixa);
    opcoes.push({ larguraBobinaM: L, comprimentoM: quatroCasas(faixas * w), porFaixa, faixas, girada: true });
  }
  if (opcoes.length === 0) return null;
  opcoes.sort((a, b) => a.comprimentoM - b.comprimentoM || Number(a.girada) - Number(b.girada));
  return opcoes[0];
}

const duasCasas = (n: number) => Math.round(n * 100) / 100;
const numero = (v: unknown) => {
  const n = Number(v);
  return Number.isFinite(n) && n > 0 ? n : 0;
};

export function chaveDoInsumo(i: { materialId?: string | null; nome: string; unidade: string }): string {
  return i.materialId || `${i.nome.trim().toLowerCase()}|${i.unidade.trim().toLowerCase()}`;
}

export function calcularFichaDoItem(item: ItemParaFicha): FichaDoItem {
  const pecas = numero(item.quantidade);
  const largura = numero(item.larguraM);
  const altura = numero(item.alturaM);
  const plano = item.plano?.insumos ?? {};

  const insumos: ConsumoDoInsumo[] = [];
  for (const i of item.insumos) {
    const consumo = numero(i.consumo);
    if (consumo <= 0) continue;
    const perda = Math.max(0, Number(i.perdaPct) || 0);
    const medidaUnitaria = medidaDaBase(i.base, largura, altura);
    const semMedida = i.base !== "por_peca" && medidaUnitaria <= 0;
    const medidaTotal = semMedida ? 0 : quatroCasas(medidaUnitaria * pecas);
    const chave = chaveDoInsumo(i);
    const ajuste = plano[chave] ?? {};

    /* O plano de rodagem vence o cadastro: quem está na bancada sabe quantas
       artes couberam na folha desta vez. */
    const rendimentoDoPlano = numero(ajuste.rendimento);
    const nat = naturezaDoInsumo({
      unidade: i.unidade,
      base: i.base,
      consumo,
      rendimento: rendimentoDoPlano > 0 ? rendimentoDoPlano : i.rendimento,
      formato: i.formato,
      servico: i.servico,
    });
    const natureza = nat.natureza;
    const rendimento = nat.rendimento;
    const rendimentoEstimado = rendimentoDoPlano > 0 ? false : nat.rendimentoEstimado;

    let liquida = quatroCasas(consumo * medidaTotal);
    let quantidade: number;
    let sobra = 0;
    let corte: CorteNaBobina | null = null;
    const larguraBobina = numero(i.larguraBobinaM);
    const rodagem: Rodagem | null =
      natureza === "continuo" && larguraBobina > 0 && i.base === "por_m2"
        ? (ajuste.rodagem === "sozinho" ? "sozinho" : "casado")
        : null;

    switch (natureza) {
      case "rendimento": {
        const n = rendimento ?? 1;
        liquida = quatroCasas(pecas / n);
        quantidade = pecas > 0 ? teto(liquida) : 0;
        sobra = quatroCasas(quantidade - liquida);
        break;
      }
      case "peca": {
        const porPeca = semMedida ? 0 : teto(consumo * medidaUnitaria);
        quantidade = porPeca * pecas;
        break;
      }
      case "servico":
        quantidade = quatroCasas(liquida * (1 + perda / 100));
        break;
      case "continuo":
      default: {
        if (rodagem === "sozinho" && !semMedida) {
          corte = corteNaBobina(larguraBobina, largura, altura, pecas);
        }
        quantidade = corte
          ? quatroCasas(corte.comprimentoM * corte.larguraBobinaM * consumo)
          : quatroCasas(liquida * (1 + perda / 100));
        break;
      }
    }

    const ajustavel: ConsumoDoInsumo["ajustavel"] =
      natureza === "rendimento" ? "rendimento" : rodagem ? "bobina" : null;

    insumos.push({
      nome: i.nome,
      unidade: i.unidade,
      base: i.base,
      rotuloDaBase: ROTULO_DA_BASE[i.base],
      consumoPorMedida: consumo,
      perdaPct: perda,
      medidaTotal,
      quantidadeLiquida: liquida,
      quantidade,
      quantidadeComPerda: quatroCasas(consumo * medidaTotal * (1 + perda / 100)),
      natureza,
      rendimento,
      rendimentoDoCadastro: numero(i.rendimento) > 0 ? numero(i.rendimento) : null,
      rendimentoEstimado,
      sobra,
      rodagem,
      corte,
      larguraBobinaM: larguraBobina > 0 ? larguraBobina : null,
      discreta: natureza === "peca" || natureza === "rendimento",
      origem: i.origem ?? { tipo: "ficha" },
      semMedida,
      materialId: i.materialId ?? null,
      chave,
      ajustavel,
    });
  }

  const processos: TempoDoProcesso[] = [];
  for (const p of item.processos) {
    const minutos = numero(p.minutos);
    const setup = numero(p.minutosDeSetup);
    if (minutos <= 0 && setup <= 0) continue;
    const medidaUnitaria = medidaDaBase(p.base, largura, altura);
    const semMedida = p.base !== "por_peca" && medidaUnitaria <= 0;
    const medidaTotal = semMedida ? 0 : quatroCasas(medidaUnitaria * pecas);
    const producao = duasCasas(minutos * medidaTotal);
    processos.push({
      nome: p.nome,
      tipo: p.tipo,
      recurso: p.recurso,
      base: p.base,
      rotuloDaBase: ROTULO_DA_BASE[p.base],
      minutosPorMedida: minutos,
      medidaTotal,
      minutosDeProducao: producao,
      minutosDeSetup: setup,
      minutosTotais: duasCasas(producao + setup),
      semMedida,
    });
  }

  return {
    pecas,
    larguraM: largura,
    alturaM: altura,
    areaTotal: duasCasas(largura * altura * pecas),
    insumos,
    processos,
    minutosTotais: duasCasas(processos.reduce((acc, p) => acc + p.minutosTotais, 0)),
  };
}

/**
 * A lista de separação: cada insumo uma vez, somado entre os itens do pedido.
 *
 * É o que o almoxarifado usa antes de a produção começar. A chave é nome e
 * unidade, sem caixa: "Lona 440g" e "lona 440g" são o mesmo rolo. Com
 * rendimento, soma as frações de folha dos itens e SÓ ENTÃO sobe para o
 * inteiro: duas canecas de itens diferentes, meia folha cada, é uma folha.
 * Serviço não entra: não há o que separar.
 */
export function separacaoDoPedido(fichas: FichaDoItem[]): LinhaDeSeparacao[] {
  const porChave = new Map<string, LinhaDeSeparacao>();
  for (const ficha of fichas) {
    const vistosNesteItem = new Set<string>();
    for (const i of ficha.insumos) {
      if (i.quantidade <= 0 || i.natureza === "servico") continue;
      const chave = `${i.nome.trim().toLowerCase()}|${i.unidade.trim().toLowerCase()}`;
      const atual =
        porChave.get(chave) ?? {
          nome: i.nome.trim(),
          unidade: i.unidade.trim(),
          quantidade: 0,
          itens: 0,
          natureza: i.natureza,
          fracaoSomada: 0,
        };
      if (i.natureza === "rendimento" && atual.natureza === "rendimento") {
        atual.fracaoSomada = quatroCasas(atual.fracaoSomada + i.quantidadeLiquida);
        atual.quantidade = teto(atual.fracaoSomada);
      } else {
        /* Misturou rendimento com outra natureza no mesmo insumo: soma o que
           cada item já arredondou, o jeito conservador. */
        if (atual.natureza === "rendimento" && i.natureza !== "rendimento") {
          atual.natureza = i.natureza;
        }
        atual.quantidade = quatroCasas(atual.quantidade + i.quantidade);
      }
      if (!vistosNesteItem.has(chave)) {
        atual.itens += 1;
        vistosNesteItem.add(chave);
      }
      porChave.set(chave, atual);
    }
  }
  return [...porChave.values()].sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
}

/**
 * O que a bancada precisa saber antes de confiar na ficha.
 *
 * O caso que o banco mostra: 172 dos 175 processos e quase todos os insumos
 * estão gravados "por peça", inclusive em produto vendido por m² — são fichas
 * de antes da base de consumo existir. Para um adesivo de 7,5 m² essa ficha
 * diz "1 m² de vinil". A conta está certa para a receita que existe; a
 * receita é que está errada, e a ficha avisa em vez de fingir que o número
 * serve.
 */
export function avisosDaFicha(ficha: FichaDoItem, item: { unitType?: string | null }): string[] {
  const avisos: string[] = [];
  const linhas = [...ficha.insumos, ...ficha.processos];
  if (linhas.length === 0) return avisos;

  const medido = item.unitType === "square_meter";
  if (medido && linhas.every((l) => l.base === "por_peca")) {
    avisos.push(
      "Ficha técnica montada por peça num item vendido por m²: os consumos não acompanham o tamanho. Confira a receita do produto."
    );
  }
  if (linhas.some((l) => l.semMedida)) {
    avisos.push("Item sem largura e altura: as linhas que dependem da medida ficaram sem quantidade.");
  }
  const estimados = ficha.insumos.filter((i) => i.rendimentoEstimado).map((i) => i.nome);
  if (estimados.length > 0) {
    avisos.push(
      `Rendimento por folha deduzido do consumo em: ${estimados.join(", ")}. Confirme "peças por folha" no cadastro do insumo.`
    );
  }
  return avisos;
}

export function minutosDoPedido(fichas: FichaDoItem[]): number {
  return duasCasas(fichas.reduce((acc, f) => acc + f.minutosTotais, 0));
}

/**
 * "2 h 26 min", "45 min", "0 min". Arredonda ao minuto: na produção ninguém
 * cronometra segundos, e "2 h 26,4 min" só atrapalha a leitura.
 */
export function formatarDuracao(minutos: number): string {
  const total = Math.round(Math.max(0, Number(minutos) || 0));
  const horas = Math.floor(total / 60);
  const resto = total % 60;
  if (horas <= 0) return `${resto} min`;
  if (resto === 0) return `${horas} h`;
  return `${horas} h ${resto} min`;
}

/**
 * As opções escolhidas no item, no formato que o orçamento e o catálogo
 * gravam: `{ options: [{ attribute_name, label, option_id }] }`. Qualquer
 * coisa fora desse formato é ignorada em silêncio — a ficha não pode cair por
 * causa de um JSON antigo.
 */
export function variacoesDoItem(selectedOptions: unknown): VariacaoEscolhida[] {
  if (!selectedOptions || typeof selectedOptions !== "object" || Array.isArray(selectedOptions)) return [];
  const lista = (selectedOptions as { options?: unknown }).options;
  if (!Array.isArray(lista)) return [];
  const saida: VariacaoEscolhida[] = [];
  for (const bruto of lista) {
    if (!bruto || typeof bruto !== "object" || Array.isArray(bruto)) continue;
    const linha = bruto as Record<string, unknown>;
    const atributo = typeof linha.attribute_name === "string" ? linha.attribute_name.trim() : "";
    const valor = typeof linha.label === "string" ? linha.label.trim() : "";
    if (!valor) continue;
    saida.push({
      atributo: atributo || "Opção",
      valor,
      optionId: typeof linha.option_id === "string" && linha.option_id ? linha.option_id : null,
    });
  }
  return saida;
}

/**
 * O plano de rodagem como o banco guarda (`order_items.production_plan`),
 * validado: só chaves conhecidas, só números positivos, só "casado" ou
 * "sozinho". JSON estranho vira plano vazio, nunca erro.
 */
export function planoValido(bruto: unknown): PlanoDeRodagemDoItem {
  if (!bruto || typeof bruto !== "object" || Array.isArray(bruto)) return {};
  const insumosBrutos = (bruto as { insumos?: unknown }).insumos;
  if (!insumosBrutos || typeof insumosBrutos !== "object" || Array.isArray(insumosBrutos)) return {};
  const insumos: Record<string, AjusteDeRodagem> = {};
  for (const [chave, valor] of Object.entries(insumosBrutos as Record<string, unknown>)) {
    if (!chave || !valor || typeof valor !== "object" || Array.isArray(valor)) continue;
    const v = valor as Record<string, unknown>;
    const ajuste: AjusteDeRodagem = {};
    const rendimento = Number(v.rendimento);
    if (Number.isFinite(rendimento) && rendimento > 0) ajuste.rendimento = Math.round(rendimento * 100) / 100;
    if (v.rodagem === "sozinho" || v.rodagem === "casado") ajuste.rodagem = v.rodagem;
    if (ajuste.rendimento !== undefined || ajuste.rodagem !== undefined) insumos[chave] = ajuste;
  }
  return Object.keys(insumos).length > 0 ? { insumos } : {};
}
