/**
 * Do pedido para a nota: quais notas saem, com que itens, valores e impostos.
 *
 * Tudo aqui é conta pura, sem banco e sem rede, para poder ser testado linha a
 * linha. Quem busca os dados (pedido, cliente, perfis, pagamentos) é o
 * servidor; quem manda para a Spedy também.
 *
 * As três regras que mais derrubam nota e que moram aqui:
 *
 * 1. O pedido guarda só o valor final. A diferença entre a soma dos itens e o
 *    total é desconto (para menos) ou frete/acréscimo (para mais). Ela é
 *    rateada em CENTAVOS pelas linhas, pelo maior resto, e a soma fecha no
 *    centavo com o total do pedido.
 * 2. Mercadoria vai na NF-e (ou NFC-e no balcão); serviço vai na NFS-e. Um
 *    produto marcado como serviço leva a linha inteira; um serviço opcional
 *    marcado como serviço sai da linha do produto pelo valor que ele cobrou.
 * 3. Cada NFS-e tem um código de serviço só: serviços de perfis diferentes
 *    saem em NFS-e separadas.
 */

import { unidadeDoAdicional, type UnidadeDoAdicional } from "../adicionais.ts";
import { cronograma, normalizarCondicoes, situacaoDasParcelas } from "../condicoes-comerciais.ts";
import { validateCNPJ, validateCPF } from "../document-utils.ts";
import { quantidadeDoItem } from "../orders/quantidade-do-item.ts";
import { arredondar, centavos, ratear, reais } from "./dinheiro.ts";
import { textoParaNota } from "./devolucao-regras.ts";
import { aliquotaEntreEstados, UFS } from "./tabelas.ts";
import {
  ALIQUOTAS_DA_REFORMA,
  CST_PIS_COFINS,
  faltasDaMercadoria,
  faltasDoServico,
  lerRegrasDeMercadoria,
  lerRegrasDeServico,
  regimeUsaCsosn,
  type ExigenciasDaCidade,
  type Regime,
  type RegrasDeMercadoria,
  type RegrasDeServico,
} from "./regras.ts";

/* ================================================================== */
/* Entradas                                                            */
/* ================================================================== */

export type EnderecoFiscal = {
  cep: string | null;
  logradouro: string | null;
  numero: string | null;
  complemento: string | null;
  bairro: string | null;
  cidade: string | null;
  uf: string | null;
  ibge: string | null;
};

export type EmissorDaNota = {
  cnpj: string;
  razaoSocial: string;
  regime: Regime;
  uf: string;
  cidadeIbge: string | null;
  inscricaoEstadual: string | null;
  inscricaoMunicipal: string | null;
};

export type ContribuinteIcms = "contribuinte" | "isento" | "nao_contribuinte";

export type DestinatarioDaNota = {
  tipo: "pf" | "pj" | null;
  nome: string;
  documento: string | null;
  email: string | null;
  telefone: string | null;
  inscricaoEstadual: string | null;
  contribuinte: ContribuinteIcms | null;
  inscricaoMunicipal?: string | null;
  endereco: EnderecoFiscal | null;
};

export type OpcaoDoItem = {
  optionId: string | null;
  grupo: string;
  rotulo: string;
  preco: number;
  price_unit?: string | null;
  price_applies_per_quantity?: boolean | null;
};

export type ItemDaNota = {
  id: string;
  titulo: string;
  produtoId: string | null;
  sku: string | null;
  codigoDeBarras: string | null;
  unitType: string | null;
  quantidade: number;
  larguraM: number | null;
  alturaM: number | null;
  comprimentoM: number | null;
  valorTotal: number;
  opcoes: OpcaoDoItem[];
  fiscalTipo: "mercadoria" | "servico" | null;
  ncm: string | null;
  cest: string | null;
  origem: number | null;
  perfilId: string | null;
  /** Nota sem pedido: a unidade e a quantidade vêm como a pessoa digitou (M2, KG, CX...). */
  unidadeFixa?: string | null;
};

export type ServicoDoCatalogo = {
  id: string;
  titulo: string;
  fiscalTipo: "produto" | "servico" | null;
  perfilId: string | null;
};

export type PerfilFiscal = {
  id: string;
  nome: string;
  tipo: "mercadoria" | "servico";
  padrao: boolean;
  ativo: boolean;
  regras: unknown;
};

export type PagamentoDaNota = { metodo: string; valor: number };

export type PedidoDaNota = {
  id: string;
  numero: number | null;
  total: number;
  criadoEm: string | null;
  entregaEm: string | null;
  itens: ItemDaNota[];
  /** Frete e desconto do orçamento, quando eles explicam a diferença exata. */
  freteDoOrcamento: number;
  descontoDoOrcamento: number;
  /** Houve entrega (frete cobrado ou transportadora escolhida). */
  temEntrega: boolean;
  pagamentos: PagamentoDaNota[];
  condicoes: unknown;
  /** Como o valor em aberto vai ser pago (boleto, a prazo...), quando não há condições comerciais. */
  formaEmAberto?: string | null;
  /** Vencimentos informados na hora (nota sem pedido). Valem quando somam exatamente o que falta. */
  duplicatas?: Array<{ vencimento: string; valor: number }> | null;
  /** As observações do pedido (ou, enquanto ele não tem as dele, as do orçamento): vão nas informações complementares. */
  observacoes?: string | null;
};

/* ================================================================== */
/* Pendências                                                          */
/* ================================================================== */

export type OndeResolver = "cliente" | "produto" | "servico" | "empresa" | "perfil" | "pedido" | "pagamento";

export type Pendencia = {
  codigo: string;
  nivel: "bloqueio" | "aviso";
  onde: OndeResolver;
  titulo: string;
  detalhe?: string;
  alvoId?: string | null;
  alvoNome?: string | null;
};

const bloqueio = (p: Omit<Pendencia, "nivel">): Pendencia => ({ ...p, nivel: "bloqueio" });
const aviso = (p: Omit<Pendencia, "nivel">): Pendencia => ({ ...p, nivel: "aviso" });

export function temBloqueio(pendencias: Pendencia[]) {
  return pendencias.some((p) => p.nivel === "bloqueio");
}

/* ================================================================== */
/* Utilidades                                                          */
/* ================================================================== */

export const soDigitos = (valor: unknown) =>
  typeof valor === "string" || typeof valor === "number" ? String(valor).replace(/\D/g, "") : "";

/* Troca quebra de linha, tabulação e outros caracteres de controle por espaço. */
const semControle = (valor: string) =>
  Array.from(valor, (letra) => (letra.charCodeAt(0) < 32 || letra.charCodeAt(0) === 127 ? " " : letra)).join("");

const texto = (valor: unknown) => (typeof valor === "string" ? semControle(valor).replace(/\s+/g, " ").trim() : "");

export function limitar(valor: string, maximo: number) {
  const limpo = texto(valor);
  if (limpo.length <= maximo) return limpo;
  return limpo.slice(0, maximo).trim();
}

export { UFS };

export function ufValida(uf: string | null | undefined): string | null {
  const limpa = texto(uf).toUpperCase();
  return UFS.includes(limpa) ? limpa : null;
}

/**
 * Quantos números a inscrição estadual tem, no mínimo, em cada estado. Serve para
 * pegar o erro mais comum: o zero da frente que some ao copiar de uma planilha
 * ou do cartão (PE "086549219" vira "86549219" e a SEFAZ diz que não existe).
 */
export const TAMANHO_MINIMO_DA_IE: Record<string, number> = {
  AC: 13, AL: 9, AM: 9, AP: 9, BA: 8, CE: 9, DF: 13, ES: 9, GO: 9, MA: 9, MG: 13, MS: 9, MT: 11, PA: 9,
  PB: 9, PE: 9, PI: 9, PR: 10, RJ: 8, RN: 9, RO: 9, RR: 9, RS: 10, SC: 9, SE: 9, SP: 12, TO: 9,
};

/** Texto do problema com a inscrição estadual, ou null quando o tamanho está certo. */
export function problemaNaInscricaoEstadual(inscricao: string | null | undefined, uf: string | null | undefined): string | null {
  const digitos = soDigitos(inscricao);
  const sigla = String(uf ?? "").toUpperCase();
  const minimo = TAMANHO_MINIMO_DA_IE[sigla];
  if (!digitos || !minimo || digitos.length >= minimo) return null;
  return `A inscrição estadual de ${sigla} tem pelo menos ${minimo} números e esta tem ${digitos.length}. Confira se não faltou um zero no começo.`;
}

export function documentoValido(documento: string | null | undefined): "cpf" | "cnpj" | null {
  const digitos = soDigitos(documento);
  if (digitos.length === 11 && validateCPF(digitos)) return "cpf";
  if (digitos.length === 14 && validateCNPJ(digitos)) return "cnpj";
  return null;
}

/** GTIN-8, 12, 13 ou 14 com dígito verificador certo. */
export function gtinValido(codigo: string | null | undefined): string | null {
  const digitos = soDigitos(codigo);
  if (![8, 12, 13, 14].includes(digitos.length)) return null;
  if (/^0+$/.test(digitos)) return null;
  const corpo = digitos.slice(0, -1);
  let soma = 0;
  for (let i = 0; i < corpo.length; i += 1) {
    const d = Number(corpo[corpo.length - 1 - i]);
    soma += d * (i % 2 === 0 ? 3 : 1);
  }
  const verificador = (10 - (soma % 10)) % 10;
  return verificador === Number(digitos[digitos.length - 1]) ? digitos : null;
}

/** A unidade comercial da nota (uCom, até 6 letras). */
export function unidadeComercial(unitType: string | null | undefined, temMedida: boolean): string {
  if (temMedida && unitType === "square_meter") return "M2";
  if (temMedida && (unitType === "meter" || unitType === "linear_meter")) return "M";
  if (unitType === "ream") return "RESMA";
  if (unitType === "box") return "CX";
  return "UN";
}

function formatarNumero(valor: number, casas = 3) {
  return new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: casas }).format(valor);
}

function formatarReaisCurto(emCentavos: number) {
  return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(reais(emCentavos)).replace(/\s/g, " ");
}

/* ================================================================== */
/* 1. Planejar: que notas saem e com o quê                             */
/* ================================================================== */

export type LinhaPlanejada = {
  chave: string;
  itemId: string;
  produtoId: string | null;
  tipo: "mercadoria" | "servico";
  perfilId: string | null;
  descricao: string;
  codigo: string;
  gtin: string | null;
  ncm: string | null;
  cest: string | null;
  origem: number | null;
  unidade: string;
  quantidade: number;
  /** Centavos. Valor bruto da linha (vProd, ou valor do serviço). */
  valor: number;
  desconto: number;
  frete: number;
  outros: number;
};

export type GrupoDeServico = {
  perfilId: string | null;
  linhas: LinhaPlanejada[];
};

export type PlanoDoPedido = {
  mercadorias: LinhaPlanejada[];
  servicos: GrupoDeServico[];
  /** Centavos. */
  subtotal: number;
  desconto: number;
  frete: number;
  outros: number;
  total: number;
  /** A diferença não fechou (ex.: total negativo). */
  inconsistente: boolean;
};

/** Quantas vezes o adicional entra na linha. */
export function vezesDoAdicional(unidade: UnidadeDoAdicional, item: Pick<ItemDaNota, "quantidade" | "unitType" | "larguraM" | "alturaM" | "comprimentoM">) {
  if (unidade === "order") return 1;
  const q = quantidadeDoItem({
    quantity: item.quantidade,
    unit_type: item.unitType,
    width_m: item.larguraM,
    height_m: item.alturaM,
    length_m: item.comprimentoM,
  });
  const pecas = q.pecas > 0 ? q.pecas : 1;
  if (unidade === "piece") return pecas;
  return q.totalNumero && q.totalNumero > 0 ? q.totalNumero : pecas;
}

function codigoDoItem(item: ItemDaNota, indice: number) {
  const sku = limitar(item.sku ?? "", 60);
  if (sku) return sku;
  if (item.produtoId) return `P${item.produtoId.replace(/-/g, "").slice(0, 10).toUpperCase()}`;
  return `ITEM${indice + 1}`;
}

function descricaoDaMercadoria(item: ItemDaNota, opcoesQueFicam: OpcaoDoItem[]) {
  const q = quantidadeDoItem({
    quantity: item.quantidade,
    unit_type: item.unitType,
    width_m: item.larguraM,
    height_m: item.alturaM,
    length_m: item.comprimentoM,
  });
  const partes = [texto(item.titulo) || "Produto"];
  if (q.medida && (item.unitType === "square_meter" || item.unitType === "meter" || item.unitType === "linear_meter")) {
    partes.push(`${formatarNumero(q.pecas)} ${q.pecas === 1 ? "pç" : "pçs"} ${q.medida}`);
  }
  const escolhas = opcoesQueFicam
    .map((opcao) => {
      const grupo = texto(opcao.grupo);
      const rotulo = texto(opcao.rotulo);
      if (!rotulo) return "";
      return grupo ? `${grupo}: ${rotulo}` : rotulo;
    })
    .filter(Boolean);
  if (escolhas.length) partes.push(escolhas.join(", "));
  return limitar(partes.join(" - "), 120);
}

/**
 * Divide o pedido em linhas de mercadoria e grupos de serviço, e rateia
 * desconto, frete e acréscimo em centavos.
 */
export function planejarNotas(
  pedido: PedidoDaNota,
  contexto: { perfis: PerfilFiscal[]; servicosDoCatalogo: ServicoDoCatalogo[] }
): PlanoDoPedido {
  const perfisAtivos = contexto.perfis.filter((perfil) => perfil.ativo);
  const padraoMercadoria = perfisAtivos.find((perfil) => perfil.tipo === "mercadoria" && perfil.padrao)?.id ?? null;
  const padraoServico = perfisAtivos.find((perfil) => perfil.tipo === "servico" && perfil.padrao)?.id ?? null;
  const perfilExiste = (id: string | null, tipo: "mercadoria" | "servico") =>
    Boolean(id && perfisAtivos.some((perfil) => perfil.id === id && perfil.tipo === tipo));
  const servicoPorId = new Map(contexto.servicosDoCatalogo.map((servico) => [servico.id, servico]));

  const linhas: LinhaPlanejada[] = [];

  pedido.itens.forEach((item, indice) => {
    const valorDaLinha = Math.max(0, centavos(item.valorTotal));
    const codigo = codigoDoItem(item, indice);

    if (item.fiscalTipo === "servico") {
      const perfilId = perfilExiste(item.perfilId, "servico") ? item.perfilId : padraoServico;
      linhas.push({
        chave: item.id,
        itemId: item.id,
        produtoId: item.produtoId,
        tipo: "servico",
        perfilId,
        descricao: descricaoDaMercadoria(item, item.opcoes),
        codigo,
        gtin: null,
        ncm: null,
        cest: null,
        origem: null,
        unidade: "UN",
        quantidade: Math.max(1, Number(item.quantidade) || 1),
        valor: valorDaLinha,
        desconto: 0,
        frete: 0,
        outros: 0,
      });
      return;
    }

    /* Serviços opcionais marcados como serviço saem da linha. */
    const opcoesQueFicam: OpcaoDoItem[] = [];
    const servicosDaLinha: LinhaPlanejada[] = [];
    let restante = valorDaLinha;
    for (const opcao of item.opcoes) {
      const doCatalogo = opcao.optionId ? servicoPorId.get(opcao.optionId) : undefined;
      if (!doCatalogo || doCatalogo.fiscalTipo !== "servico") {
        opcoesQueFicam.push(opcao);
        continue;
      }
      const unidade = unidadeDoAdicional(
        { price: opcao.preco, price_unit: opcao.price_unit ?? null, price_applies_per_quantity: opcao.price_applies_per_quantity ?? null },
        item.unitType
      );
      const cobrado = Math.min(restante, Math.max(0, centavos((Number(opcao.preco) || 0) * vezesDoAdicional(unidade, item))));
      if (cobrado <= 0) continue;
      restante -= cobrado;
      const perfilId = perfilExiste(doCatalogo.perfilId, "servico") ? doCatalogo.perfilId : padraoServico;
      servicosDaLinha.push({
        chave: `${item.id}:${opcao.optionId}`,
        itemId: item.id,
        produtoId: item.produtoId,
        tipo: "servico",
        perfilId,
        descricao: limitar(`${texto(opcao.rotulo) || doCatalogo.titulo} - ${texto(item.titulo)}`, 120),
        codigo,
        gtin: null,
        ncm: null,
        cest: null,
        origem: null,
        unidade: "UN",
        quantidade: 1,
        valor: cobrado,
        desconto: 0,
        frete: 0,
        outros: 0,
      });
    }

    if (restante > 0 || servicosDaLinha.length === 0) {
      const q = quantidadeDoItem({
        quantity: item.quantidade,
        unit_type: item.unitType,
        width_m: item.larguraM,
        height_m: item.alturaM,
        length_m: item.comprimentoM,
      });
      const temMedida = q.totalNumero !== null && q.totalNumero > 0;
      const unidadeFixa = texto(item.unidadeFixa).toUpperCase().slice(0, 6);
      const quantidade = unidadeFixa
        ? arredondar(Number(item.quantidade) > 0 ? Number(item.quantidade) : 1, 4)
        : arredondar(temMedida ? (q.totalNumero as number) : q.pecas > 0 ? q.pecas : 1, 4);
      linhas.push({
        chave: item.id,
        itemId: item.id,
        produtoId: item.produtoId,
        tipo: "mercadoria",
        perfilId: perfilExiste(item.perfilId, "mercadoria") ? item.perfilId : padraoMercadoria,
        descricao: descricaoDaMercadoria(item, opcoesQueFicam),
        codigo,
        gtin: gtinValido(item.codigoDeBarras),
        ncm: soDigitos(item.ncm) || null,
        cest: soDigitos(item.cest) || null,
        origem: item.origem,
        unidade: unidadeFixa || unidadeComercial(item.unitType, temMedida),
        quantidade: quantidade > 0 ? quantidade : 1,
        valor: restante,
        desconto: 0,
        frete: 0,
        outros: 0,
      });
    }
    linhas.push(...servicosDaLinha);
  });

  const subtotal = linhas.reduce((acc, linha) => acc + linha.valor, 0);
  const total = Math.max(0, centavos(pedido.total));
  const diferenca = total - subtotal;

  let desconto = 0;
  let frete = 0;
  let outros = 0;
  const freteDoOrcamento = Math.max(0, centavos(pedido.freteDoOrcamento));
  const descontoDoOrcamento = Math.max(0, centavos(pedido.descontoDoOrcamento));
  if ((freteDoOrcamento > 0 || descontoDoOrcamento > 0) && Math.abs(freteDoOrcamento - descontoDoOrcamento - diferenca) <= 1) {
    frete = freteDoOrcamento;
    desconto = descontoDoOrcamento;
    /* O centavo que sobrar do arredondamento vai para o lado que existe. */
    const ajuste = diferenca - (frete - desconto);
    if (ajuste !== 0) {
      if (frete > 0) frete += ajuste;
      else desconto -= ajuste;
    }
  } else if (diferenca < 0) {
    desconto = -diferenca;
  } else if (diferenca > 0) {
    if (pedido.temEntrega) frete = diferenca;
    else outros = diferenca;
  }

  const inconsistente = desconto > subtotal || total <= 0;
  if (!inconsistente) {
    const pesos = linhas.map((linha) => linha.valor);
    ratear(desconto, pesos).forEach((parte, i) => {
      linhas[i].desconto = parte;
    });

    const mercadorias = linhas.filter((linha) => linha.tipo === "mercadoria");
    if (frete > 0) {
      if (mercadorias.length) {
        ratear(frete, mercadorias.map((linha) => linha.valor)).forEach((parte, i) => {
          mercadorias[i].frete = parte;
        });
      } else {
        /* Pedido só de serviço: o frete entra no valor do serviço. */
        ratear(frete, pesos).forEach((parte, i) => {
          linhas[i].valor += parte;
        });
      }
    }
    if (outros > 0) {
      ratear(outros, pesos).forEach((parte, i) => {
        if (linhas[i].tipo === "mercadoria") linhas[i].outros = parte;
        else linhas[i].valor += parte;
      });
    }
  }

  const mercadorias = linhas.filter((linha) => linha.tipo === "mercadoria");
  const porPerfil = new Map<string, GrupoDeServico>();
  for (const linha of linhas) {
    if (linha.tipo !== "servico") continue;
    const chave = linha.perfilId ?? "";
    const grupo = porPerfil.get(chave) ?? { perfilId: linha.perfilId, linhas: [] };
    grupo.linhas.push(linha);
    porPerfil.set(chave, grupo);
  }

  return {
    mercadorias,
    servicos: [...porPerfil.values()],
    subtotal,
    desconto,
    frete,
    outros,
    total,
    inconsistente,
  };
}

/** Valor líquido de uma linha de mercadoria (sem IPI). */
export function liquidoDaLinha(linha: LinhaPlanejada) {
  return linha.valor - linha.desconto + linha.frete + linha.outros;
}

/** Valor da NFS-e de um grupo: serviços menos desconto. */
export function liquidoDoGrupo(grupo: GrupoDeServico) {
  return grupo.linhas.reduce((acc, linha) => acc + linha.valor - linha.desconto, 0);
}

/* ================================================================== */
/* 2. Impostos por linha de mercadoria                                 */
/* ================================================================== */

export type ImpostosDaLinha = {
  taxes: Record<string, unknown>;
  /** Centavos de IPI, que somam no total da nota. */
  ipi: number;
};

const taxa = (valor: number | null | undefined) => (Number.isFinite(Number(valor)) ? Number(valor) : 0);

/**
 * Venda para consumidor final de outro estado (a "partilha do ICMS", ou DIFAL).
 *
 * A diferença entre a alíquota interna do estado de destino e a interestadual
 * é devida ao estado de destino desde 2019 — o remetente não fica com nada. A
 * base é calculada "por dentro" com a alíquota do destino, como manda a Lei
 * Complementar 190/2022. As alíquotas de cada estado vêm do perfil fiscal,
 * informadas pelo contador: o sistema não inventa alíquota de imposto.
 */
export function partilhaDoIcms(input: {
  base: number;
  ufOrigem: string;
  ufDestino: string;
  origemDaMercadoria: number;
  aliquotaInterna: number;
  fcp?: number;
}) {
  const interna = Number(input.aliquotaInterna);
  if (!Number.isFinite(interna) || interna <= 0 || interna >= 100) return null;
  const entreEstados = aliquotaEntreEstados(input.ufOrigem, input.ufDestino, input.origemDaMercadoria);
  const fcp = Number.isFinite(Number(input.fcp)) ? Math.max(0, Number(input.fcp)) : 0;

  const semIcmsDaOrigem = input.base - (input.base * entreEstados) / 100;
  const baseNoDestino = Math.round(semIcmsDaOrigem / (1 - (interna + fcp) / 100));
  const icmsDoDestino = Math.round((baseNoDestino * interna) / 100) - Math.round((input.base * entreEstados) / 100);
  const fcpDoDestino = fcp > 0 ? Math.round((baseNoDestino * fcp) / 100) : 0;

  return {
    baseNoDestino,
    entreEstados,
    interna,
    fcp,
    icmsDoDestino: Math.max(0, icmsDoDestino),
    fcpDoDestino,
  };
}

export function impostosDaLinha(
  linha: LinhaPlanejada,
  regras: RegrasDeMercadoria,
  regime: Regime,
  opcoes: {
    consumidorFinal: boolean;
    /** Venda para outro estado: o ICMS próprio sai pela alíquota entre estados, não pela do perfil. */
    entreEstados?: { ufOrigem: string; ufDestino: string } | null;
    difal?: { ufOrigem: string; ufDestino: string } | null;
  }
): ImpostosDaLinha {
  const origem = linha.origem !== null && linha.origem >= 0 && linha.origem <= 8 ? linha.origem : regras.origem;
  /* A alíquota do perfil é a de dentro do estado. Para outro estado vale a do
     Senado (12%, 7% ou 4%): mandar a interna dá "alíquota superior à definida
     para a operação interestadual" (693). */
  const aliquotaDoIcms = opcoes.entreEstados
    ? aliquotaEntreEstados(opcoes.entreEstados.ufOrigem, opcoes.entreEstados.ufDestino, origem)
    : taxa(regras.aliquotaIcms);
  const baseDaOperacao = liquidoDaLinha(linha);
  const taxes: Record<string, unknown> = {};

  /* IPI (só quando o perfil pede). Entra na base do ICMS do consumidor final. */
  let ipi = 0;
  if (regras.ipiCst !== null) {
    const bloco: Record<string, unknown> = {
      cst: regras.ipiCst,
      classificationCode: regras.ipiEnquadramento || "999",
    };
    if ((regras.ipiCst === 50 || regras.ipiCst === 99) && taxa(regras.ipiAliquota) > 0) {
      ipi = Math.round((baseDaOperacao * taxa(regras.ipiAliquota)) / 100);
      bloco.baseTax = reais(baseDaOperacao);
      bloco.rate = taxa(regras.ipiAliquota);
      bloco.amount = reais(ipi);
    }
    taxes.ipi = bloco;
  }

  const baseDoIcms = baseDaOperacao + (opcoes.consumidorFinal ? ipi : 0);

  if (regimeUsaCsosn(regime)) {
    const csosn = regras.csosn ?? 102;
    const icms: Record<string, unknown> = { origin: origem, csosn };
    if (csosn === 101 && taxa(regras.aliquotaCreditoSimples) > 0) {
      icms.snCreditRate = taxa(regras.aliquotaCreditoSimples);
      icms.snCreditAmount = reais(Math.round((baseDaOperacao * taxa(regras.aliquotaCreditoSimples)) / 100));
    }
    if (csosn === 900 && taxa(regras.aliquotaIcms) > 0) {
      icms.baseTaxModality = 3;
      icms.baseTax = reais(baseDoIcms);
      icms.rate = aliquotaDoIcms;
      icms.amount = reais(Math.round((baseDoIcms * aliquotaDoIcms) / 100));
    }
    taxes.icms = icms;
  } else {
    const cst = regras.cstIcms ?? 0;
    const icms: Record<string, unknown> = { origin: origem, cst };
    if (cst === 0 || cst === 20) {
      const reducao = cst === 20 ? Math.min(100, Math.max(0, taxa(regras.reducaoBaseIcms))) : 0;
      const base = Math.round((baseDoIcms * (100 - reducao)) / 100);
      icms.baseTaxModality = 3;
      icms.baseTax = reais(base);
      if (cst === 20) icms.baseTaxReduction = reducao;
      icms.rate = aliquotaDoIcms;
      icms.amount = reais(Math.round((base * aliquotaDoIcms) / 100));
    }
    taxes.icms = icms;
  }

  const basePisCofins = linha.valor - linha.desconto;
  const blocoDeContribuicao = (cst: number | null, aliquota: number | null) => {
    const codigo = cst ?? 49;
    const comAliquota = CST_PIS_COFINS.find((item) => item.codigo === codigo)?.comAliquota ?? false;
    if (!comAliquota) return { cst: codigo };
    return {
      cst: codigo,
      baseTax: reais(basePisCofins),
      rate: taxa(aliquota),
      amount: reais(Math.round((basePisCofins * taxa(aliquota)) / 100)),
    };
  };
  taxes.pis = blocoDeContribuicao(regras.pisCst, regras.pisAliquota);
  taxes.cofins = blocoDeContribuicao(regras.cofinsCst, regras.cofinsAliquota);

  /* IBS/CBS da reforma tributária. Para o regime normal o grupo já é obrigatório: sem ele a
     SEFAZ recusa com "IBS/CBS não informado" (1115). O padrão é tributação integral — CST 000 e
     classificação 000001 —, e o contador troca em Ajustes do contador quando o caso é outro.
     Simples e MEI ainda não têm data de obrigatoriedade, então lá só vai o que for preenchido. */
  /* Partilha do ICMS: só existe em venda interestadual a consumidor final do
     regime normal, e só quando o ICMS da operação é tributado. */
  if (opcoes.difal && !regimeUsaCsosn(regime) && [0, 20].includes(Number(regras.cstIcms ?? 0))) {
    const partilha = partilhaDoIcms({
      base: basePisCofins,
      ufOrigem: opcoes.difal.ufOrigem,
      ufDestino: opcoes.difal.ufDestino,
      origemDaMercadoria: origem,
      aliquotaInterna: regras.aliquotaInternaPorUf[opcoes.difal.ufDestino] ?? 0,
      fcp: regras.fcpPorUf[opcoes.difal.ufDestino] ?? 0,
    });
    if (partilha) {
      const bloco: Record<string, unknown> = {
        destBaseTax: reais(partilha.baseNoDestino),
        destinationRate: partilha.entreEstados,
        internalOperationRate: partilha.interna,
        destinationAmount: reais(partilha.icmsDoDestino),
        issuerStateAmount: 0,
      };
      if (partilha.fcp > 0) {
        bloco.fcpBaseTax = reais(partilha.baseNoDestino);
        bloco.fcpAdicRate = partilha.fcp;
        bloco.fcpDestinationAmount = reais(partilha.fcpDoDestino);
      }
      taxes.icmsDestination = bloco;
    }
  }

  const ibsCbsCst = regras.ibsCbsCst ?? (regimeUsaCsosn(regime) ? null : 0);
  const ibsCbsClassificacao = regras.ibsCbsClassificacao ?? (regimeUsaCsosn(regime) ? null : 1);
  if (ibsCbsCst !== null && ibsCbsClassificacao !== null) {
    /* Só o CST e a classificação não bastam: sem base, alíquota e valor a SEFAZ responde
       "Grupo IBS/CBS não informado". A base é a mesma do PIS/COFINS. */
    const ibsUf = ALIQUOTAS_DA_REFORMA.ibsUf;
    const ibsMunicipio = ALIQUOTAS_DA_REFORMA.ibsMunicipio;
    const cbs = ALIQUOTAS_DA_REFORMA.cbs;
    const valorIbsUf = Math.round((basePisCofins * ibsUf) / 100);
    const valorIbsMunicipio = Math.round((basePisCofins * ibsMunicipio) / 100);
    taxes.ibsCbs = {
      cst: ibsCbsCst,
      classification: ibsCbsClassificacao,
      baseTax: reais(basePisCofins),
      ibsStateRate: ibsUf,
      ibsStateAmount: reais(valorIbsUf),
      ibsCityRate: ibsMunicipio,
      ibsCityAmount: reais(valorIbsMunicipio),
      ibsAmount: reais(valorIbsUf + valorIbsMunicipio),
      cbsRate: cbs,
      cbsAmount: reais(Math.round((basePisCofins * cbs) / 100)),
    };
  }

  return { taxes, ipi };
}

/* ================================================================== */
/* 3. Pagamentos, fatura e duplicatas                                  */
/* ================================================================== */

export type MeioDePagamentoSpedy =
  | "money"
  | "check"
  | "creditCard"
  | "debitCard"
  | "storeCredit"
  | "commercialDuplicate"
  | "billetBanking"
  | "bankDeposit"
  | "dynamicPix"
  | "bankTransfer"
  | "pix"
  | "noPayment";

export function meioDePagamento(metodo: string | null | undefined): MeioDePagamentoSpedy {
  switch (String(metodo || "").trim().toLowerCase()) {
    case "pix":
      return "pix";
    case "pix_dinamico":
    case "pix_mp":
      return "dynamicPix";
    case "cash":
    case "dinheiro":
      return "money";
    case "credit_card":
    case "link":
      return "creditCard";
    case "debit_card":
      return "debitCard";
    case "transfer":
      return "bankTransfer";
    case "boleto":
      return "billetBanking";
    case "check":
    case "cheque":
      return "check";
    case "deposit":
      return "bankDeposit";
    default:
      return "commercialDuplicate";
  }
}

const COM_GRUPO_DE_CARTAO = new Set<MeioDePagamentoSpedy>(["creditCard", "debitCard", "dynamicPix"]);

export type PagamentosDaNota = {
  payments: Array<Record<string, unknown>>;
  duplicates: Array<{ number: string; dueDate: string; amount: number }>;
  billing: { number: string; originalAmount: number; discountAmount: number; netAmount: number } | null;
  /** Centavos pagos e em aberto. */
  pago: number;
  emAberto: number;
};

function hojeEmSaoPaulo(agora: Date) {
  return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo", year: "numeric", month: "2-digit", day: "2-digit" }).format(agora);
}

export function montarPagamentos(
  pedido: Pick<PedidoDaNota, "numero" | "pagamentos" | "condicoes" | "criadoEm" | "entregaEm" | "formaEmAberto" | "duplicatas">,
  totalDaNota: number,
  opcoes: { modelo: "nfe" | "nfce"; agora?: Date }
): PagamentosDaNota {
  const agora = opcoes.agora ?? new Date();
  const porMeio = new Map<MeioDePagamentoSpedy, number>();
  let acumulado = 0;
  for (const pagamento of pedido.pagamentos) {
    const valor = centavos(pagamento.valor);
    if (valor <= 0) continue;
    const cabe = Math.min(valor, totalDaNota - acumulado);
    if (cabe <= 0) break;
    const meio = meioDePagamento(pagamento.metodo);
    porMeio.set(meio, (porMeio.get(meio) ?? 0) + cabe);
    acumulado += cabe;
  }
  const pago = acumulado;
  const emAberto = Math.max(0, totalDaNota - pago);

  const condicoes = normalizarCondicoes(pedido.condicoes);
  const duplicates: PagamentosDaNota["duplicates"] = [];

  if (emAberto > 0) {
    const meio = pedido.formaEmAberto
      ? meioDePagamento(pedido.formaEmAberto)
      : condicoes?.formas?.length
        ? meioDePagamento(condicoes.formas[0])
        : "commercialDuplicate";
    porMeio.set(meio, (porMeio.get(meio) ?? 0) + emAberto);

    const informadas = (pedido.duplicatas ?? [])
      .map((duplicata) => ({ valor: centavos(duplicata.valor), vencimento: texto(duplicata.vencimento).slice(0, 10) }))
      .filter((duplicata) => duplicata.valor > 0 && /^\d{4}-\d{2}-\d{2}$/.test(duplicata.vencimento));
    const somaInformada = informadas.reduce((acc, duplicata) => acc + duplicata.valor, 0);

    if (opcoes.modelo === "nfe" && informadas.length && somaInformada === emAberto) {
      const hoje = hojeEmSaoPaulo(agora);
      informadas.forEach((duplicata, indice) => {
        const dia = duplicata.vencimento > hoje ? duplicata.vencimento : hoje;
        duplicates.push({ number: String(indice + 1).padStart(3, "0"), dueDate: `${dia}T12:00:00-03:00`, amount: reais(duplicata.valor) });
      });
    } else if (opcoes.modelo === "nfe") {
      const hoje = hojeEmSaoPaulo(agora);
      const parcelas = condicoes
        ? situacaoDasParcelas(
            cronograma(condicoes, reais(totalDaNota), { aprovacao: pedido.criadoEm, entrega: pedido.entregaEm }),
            reais(pago),
            hoje
          )
        : [];
      const abertas = parcelas
        .map((parcela) => ({ valor: centavos(parcela.valor) - centavos(parcela.pago), vencimento: parcela.vencimento }))
        .filter((parcela) => parcela.valor > 0);
      const somaAberta = abertas.reduce((acc, parcela) => acc + parcela.valor, 0);
      /* Duplicata só quando o cronograma explica o que falta, centavo a centavo. */
      if (abertas.length && somaAberta === emAberto) {
        abertas.forEach((parcela, indice) => {
          const dia = parcela.vencimento && parcela.vencimento > hoje ? parcela.vencimento : hoje;
          duplicates.push({
            number: String(indice + 1).padStart(3, "0"),
            dueDate: `${dia}T12:00:00-03:00`,
            amount: reais(parcela.valor),
          });
        });
      } else {
        duplicates.push({ number: "001", dueDate: `${hoje}T12:00:00-03:00`, amount: reais(emAberto) });
      }
    }
  }

  /*
    Cobrança só existe em venda a prazo. Desde 01/09/2025 (NT 2025.001) a SEFAZ
    recusa com a rejeição 853 ("Dados de cobrança não devem ser informados para
    pagamento à vista") a nota que leva uma parcela vencendo no próprio dia da
    emissão — e era exatamente isso que saía de todo pedido ainda não pago:
    nenhuma nota com duplicata tinha sido autorizada até 18/09/2026.

    Se nada do que falta vence DEPOIS de hoje, a venda é à vista: a nota sai só
    com o meio de pagamento, sem fatura e sem duplicata. Conferido na SEFAZ, em
    teste: à vista sem duplicata passa, inclusive como duplicata mercantil;
    parcela para amanhã passa; entrada vencendo hoje com saldo no futuro passa.
  */
  const diaDaEmissao = hojeEmSaoPaulo(agora);
  if (!duplicates.some((duplicata) => duplicata.dueDate.slice(0, 10) > diaDaEmissao)) duplicates.length = 0;

  const payments = [...porMeio.entries()].map(([method, valor]) => {
    const pagamento: Record<string, unknown> = { method, amount: reais(valor) };
    if (COM_GRUPO_DE_CARTAO.has(method)) pagamento.card = { isIntegratedPayment: false };
    return pagamento;
  });
  if (!payments.length) payments.push({ method: "noPayment", amount: 0 });

  const billing =
    opcoes.modelo === "nfe" && duplicates.length
      ? {
          number: String(pedido.numero ?? "").slice(0, 60) || "1",
          originalAmount: reais(emAberto),
          discountAmount: 0,
          netAmount: reais(emAberto),
        }
      : null;

  return { payments, duplicates, billing, pago, emAberto };
}

/* ================================================================== */
/* 4. Destinatário                                                     */
/* ================================================================== */

export const NOME_EM_HOMOLOGACAO = "NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL";
export const ITEM_EM_HOMOLOGACAO = "NOTA FISCAL EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL";

export function enderecoCompleto(endereco: EnderecoFiscal | null | undefined) {
  if (!endereco) return false;
  return Boolean(
    texto(endereco.logradouro) &&
      texto(endereco.bairro) &&
      soDigitos(endereco.cep).length === 8 &&
      texto(endereco.cidade) &&
      ufValida(endereco.uf)
  );
}

function enderecoParaNota(endereco: EnderecoFiscal, modelo: "nfe" | "nfce" | "nfse") {
  const ibge = soDigitos(endereco.ibge);
  const cidade: Record<string, unknown> = { name: limitar(endereco.cidade ?? "", 60), state: ufValida(endereco.uf) };
  if (ibge.length === 7) cidade.code = Number(ibge);
  const saida: Record<string, unknown> = {
    street: limitar(endereco.logradouro ?? "", 60),
    number: limitar(endereco.numero ?? "", 10) || "S/N",
    district: limitar(endereco.bairro ?? "", 60),
    postalCode: soDigitos(endereco.cep),
    city: cidade,
  };
  /* O país só vai na nota de produto, onde a SEFAZ o pede e "BRA" funciona.
     Na nota de serviço ele fica de fora, como na nota de Campinas que a Spedy
     dá de exemplo. Não faz diferença para a prefeitura: a Spedy preenche "BRA"
     sozinha quando falta (conferido na resposta dela, MK5, 21/09/2026), e
     tirar o país não resolveu a recusa do tomador em Campinas. */
  if (modelo !== "nfse") saida.country = "BRA";
  const complemento = limitar(endereco.complemento ?? "", 60);
  if (complemento) saida.additionalInformation = complemento;
  return saida;
}

export function destinatarioParaNota(
  destinatario: DestinatarioDaNota,
  opcoes: { modelo: "nfe" | "nfce" | "nfse"; homologacao: boolean }
): Record<string, unknown> | null {
  const documento = soDigitos(destinatario.documento);
  const tipoDoc = documentoValido(documento);
  const receiver: Record<string, unknown> = {};

  if (opcoes.modelo === "nfce") {
    if (!tipoDoc) return null;
    receiver.federalTaxNumber = documento;
    const nome = opcoes.homologacao ? NOME_EM_HOMOLOGACAO : limitar(destinatario.nome, 60);
    if (nome) receiver.name = nome;
    return receiver;
  }

  receiver.name = opcoes.homologacao && opcoes.modelo === "nfe" ? NOME_EM_HOMOLOGACAO : limitar(destinatario.nome, opcoes.modelo === "nfse" ? 115 : 60);
  if (tipoDoc) receiver.federalTaxNumber = documento;

  if (opcoes.modelo === "nfe" && tipoDoc === "cnpj") {
    if (destinatario.contribuinte === "contribuinte") {
      const ie = soDigitos(destinatario.inscricaoEstadual);
      if (ie) receiver.stateTaxNumber = ie;
    } else if (destinatario.contribuinte === "isento") {
      receiver.stateTaxNumber = "ISENTO";
    }
  }
  if (opcoes.modelo === "nfse") {
    const im = soDigitos(destinatario.inscricaoMunicipal);
    if (im) receiver.cityTaxNumber = im;
  }

  const email = texto(destinatario.email);
  if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) receiver.email = limitar(email, 60);
  const telefone = soDigitos(destinatario.telefone);
  if (telefone.length >= 10 && telefone.length <= 14) receiver.phoneNumber = telefone;

  if (destinatario.endereco && enderecoCompleto(destinatario.endereco)) {
    receiver.address = enderecoParaNota(destinatario.endereco, opcoes.modelo);
  }
  return receiver;
}

/**
 * Dentro do estado ou interestadual, pela UF do endereço do cliente — menos na
 * venda presencial sem entrega: o cliente de outro estado que retira a
 * mercadoria no balcão compra dentro do estado. É a exceção que a SEFAZ aceita
 * para operação interna com destinatário de fora (indPres 1).
 */
export function destinoDaOperacao(
  emissor: EmissorDaNota,
  destinatario: DestinatarioDaNota | null,
  venda?: { presenca?: string | null; temEntrega?: boolean }
): "internal" | "interstate" {
  const ufDoCliente = ufValida(destinatario?.endereco?.uf);
  const ufDaEmpresa = ufValida(emissor.uf);
  if (!ufDoCliente || !ufDaEmpresa) return "internal";
  if (ufDoCliente === ufDaEmpresa) return "internal";
  if (venda?.presenca === "presence" && !venda.temEntrega) return "internal";
  return "interstate";
}

/* ================================================================== */
/* 5. Validação                                                        */
/* ================================================================== */

export type ModeloDeProduto = "nfe" | "nfce";

export const LIMITE_NFCE_SEM_DOCUMENTO = 1000000; // R$ 10.000,00 em centavos

export function pendenciasDaNotaDeProduto(input: {
  modelo: ModeloDeProduto;
  emissor: EmissorDaNota;
  destinatario: DestinatarioDaNota | null;
  linhas: LinhaPlanejada[];
  perfis: PerfilFiscal[];
  consumidorFinal: boolean;
  total: number;
  /** Centavos ainda não pagos. NFC-e só sai de venda paga. */
  emAberto?: number;
  /** Como foi a venda e se tem entrega: decidem se cliente de fora compra dentro do estado. */
  presenca?: string | null;
  temEntrega?: boolean;
}): Pendencia[] {
  const pendencias: Pendencia[] = [];
  const { modelo, emissor, destinatario, linhas } = input;

  if (!linhas.length) {
    pendencias.push(bloqueio({ codigo: "pedido.sem_mercadoria", onde: "pedido", titulo: "Nenhum produto para esta nota" }));
    return pendencias;
  }
  if (input.total <= 0) {
    pendencias.push(bloqueio({ codigo: "pedido.total", onde: "pedido", titulo: "O valor da nota precisa ser maior que zero" }));
  }

  if (!ufValida(emissor.uf)) {
    pendencias.push(bloqueio({ codigo: "empresa.uf", onde: "empresa", titulo: "Estado da empresa não informado" }));
  }
  if (!soDigitos(emissor.inscricaoEstadual)) {
    pendencias.push(
      bloqueio({
        codigo: "empresa.ie",
        onde: "empresa",
        titulo: "Inscrição estadual da empresa",
        detalhe: "Nota de produto e cupom fiscal precisam do número da inscrição estadual da empresa. Empresa isenta emite só nota de serviço.",
      })
    );
  }

  /* Destinatário */
  const documento = soDigitos(destinatario?.documento);
  const tipoDoc = documentoValido(documento);
  if (modelo === "nfe") {
    if (!destinatario) {
      pendencias.push(bloqueio({ codigo: "cliente.ausente", onde: "cliente", titulo: "Falta o cliente", detalhe: "A nota de produto sai com os dados do cliente." }));
    } else {
      if (!texto(destinatario.nome)) {
        pendencias.push(bloqueio({ codigo: "cliente.nome", onde: "cliente", titulo: "Nome do cliente" }));
      }
      if (!documento) {
        pendencias.push(
          bloqueio({ codigo: "cliente.documento", onde: "cliente", titulo: "CPF ou CNPJ do cliente", detalhe: "A NF-e precisa identificar o cliente." })
        );
      } else if (!tipoDoc) {
        pendencias.push(bloqueio({ codigo: "cliente.documento_invalido", onde: "cliente", titulo: "CPF ou CNPJ do cliente é inválido" }));
      }
      if (!enderecoCompleto(destinatario.endereco)) {
        const faltando: string[] = [];
        const e = destinatario.endereco;
        if (!texto(e?.logradouro)) faltando.push("rua");
        if (!texto(e?.bairro)) faltando.push("bairro");
        if (soDigitos(e?.cep).length !== 8) faltando.push("CEP");
        if (!texto(e?.cidade)) faltando.push("cidade");
        if (!ufValida(e?.uf)) faltando.push("estado");
        pendencias.push(
          bloqueio({
            codigo: "cliente.endereco",
            onde: "cliente",
            titulo: "Endereço do cliente incompleto",
            detalhe: `Falta: ${faltando.join(", ")}.`,
          })
        );
      }
      if (tipoDoc === "cnpj" && destinatario.contribuinte === "contribuinte" && !soDigitos(destinatario.inscricaoEstadual)) {
        pendencias.push(
          bloqueio({
            codigo: "cliente.ie",
            onde: "cliente",
            titulo: "Inscrição estadual do cliente",
            detalhe: "O cliente está marcado como contribuinte de ICMS, mas a inscrição estadual está vazia.",
          })
        );
      }
      if (tipoDoc === "cnpj" && !destinatario.contribuinte) {
        pendencias.push(
          aviso({
            codigo: "cliente.contribuinte",
            onde: "cliente",
            titulo: "Situação do cliente no ICMS",
            detalhe: "Cliente empresa sem a informação de contribuinte. A nota sai como não contribuinte.",
          })
        );
      }
    }
  } else {
    if (documento && !tipoDoc) {
      pendencias.push(
        aviso({
          codigo: "cliente.documento_invalido",
          onde: "cliente",
          titulo: "CPF ou CNPJ inválido",
          detalhe: "A NFC-e sai sem identificar o consumidor.",
        })
      );
    }
    if ((input.emAberto ?? 0) > 0) {
      pendencias.push(
        bloqueio({
          codigo: "pagamento.nfce_em_aberto",
          onde: "pagamento",
          titulo: "NFC-e só para venda paga",
          detalhe: "Registre os pagamentos que faltam ou emita uma NF-e.",
        })
      );
    }
    if (!tipoDoc && input.total >= LIMITE_NFCE_SEM_DOCUMENTO) {
      pendencias.push(
        bloqueio({
          codigo: "cliente.documento_nfce",
          onde: "cliente",
          titulo: "CPF ou CNPJ obrigatório",
          detalhe: "NFC-e a partir de R$ 10.000,00 precisa identificar o consumidor.",
        })
      );
    }
  }

  const destino = modelo === "nfce" ? "internal" : destinoDaOperacao(emissor, destinatario, input);
  const ufDoCliente = ufValida(destinatario?.endereco?.uf);
  if (modelo === "nfe" && destino === "internal" && ufDoCliente && ufValida(emissor.uf) && ufDoCliente !== ufValida(emissor.uf)) {
    pendencias.push(
      aviso({
        codigo: "venda.retirada_no_balcao",
        onde: "pedido",
        titulo: "Sai como venda dentro do estado",
        detalhe: `O cliente é de ${ufDoCliente}, mas a venda está como presencial: a mercadoria é retirada aqui e a nota sai com o ICMS de dentro do estado. Se for enviar para o cliente, troque a forma da venda.`,
      })
    );
  }
  /* Venda interestadual a consumidor final não contribuinte, no regime normal:
     a nota leva a partilha do ICMS. O sistema calcula, mas a alíquota interna
     do estado de destino é lei estadual e muda: quem informa é o contador. */
  if (
    modelo === "nfe" &&
    destino === "interstate" &&
    emissor.regime === "regimeNormal" &&
    input.consumidorFinal &&
    destinatario?.contribuinte !== "contribuinte"
  ) {
    const ufDestino = ufValida(destinatario?.endereco?.uf) ?? "";
    const perfisDaNota = new Map<string, PerfilFiscal>();
    for (const linha of linhas) {
      const perfil = input.perfis.find((p) => p.id === linha.perfilId);
      if (perfil) perfisDaNota.set(perfil.id, perfil);
    }
    for (const perfil of perfisDaNota.values()) {
      const regras = lerRegrasDeMercadoria(perfil.regras);
      const tributado = [0, 20].includes(Number(regras.cstIcms ?? 0));
      const aliquota = Number(regras.aliquotaInternaPorUf?.[ufDestino] ?? 0);
      if (!tributado || aliquota > 0) continue;
      pendencias.push(
        bloqueio({
          codigo: `perfil.difal:${perfil.id}`,
          onde: "perfil",
          alvoId: perfil.id,
          alvoNome: perfil.nome,
          titulo: `Falta a alíquota de ICMS de ${ufDestino || "destino"}`,
          detalhe: `Venda para consumidor final de outro estado divide o ICMS com o estado de destino. Informe em Ajustes do contador, na tributação "${perfil.nome}", a alíquota interna de ${ufDestino || "destino"} — o seu contador tem esse número.`,
        })
      );
    }
  }

  /* Itens e perfis */
  const perfisPorId = new Map(input.perfis.map((perfil) => [perfil.id, perfil]));
  const semPerfil = linhas.filter((linha) => !linha.perfilId || !perfisPorId.has(linha.perfilId));
  if (semPerfil.length) {
    pendencias.push(
      bloqueio({
        codigo: "perfil.mercadoria_padrao",
        onde: "perfil",
        titulo: "Tributação dos produtos",
        detalhe: "Nenhum perfil de tributação de produto foi escolhido nem marcado como padrão.",
      })
    );
  }
  const produtosVistos = new Set<string>();
  for (const linha of linhas) {
    const chaveDoProduto = linha.produtoId ?? linha.itemId;
    if (produtosVistos.has(chaveDoProduto)) continue;
    produtosVistos.add(chaveDoProduto);
    if (!linha.ncm || linha.ncm.length !== 8) {
      pendencias.push(
        bloqueio({
          codigo: `produto.ncm:${chaveDoProduto}`,
          onde: "produto",
          titulo: linha.ncm ? "NCM do produto inválido" : "NCM do produto",
          /* Item digitado no pedido, sem produto do catálogo: o NCM é informado aqui mesmo, na janela da nota. */
          detalhe: linha.produtoId ? linha.descricao : `${linha.descricao} (item digitado no pedido, sem produto do catálogo)`,
          alvoId: linha.produtoId,
          alvoNome: linha.descricao,
        })
      );
    }
    if (linha.cest && linha.cest.length !== 7) {
      pendencias.push(
        bloqueio({
          codigo: `produto.cest:${chaveDoProduto}`,
          onde: "produto",
          titulo: "CEST do produto precisa ter 7 dígitos",
          detalhe: linha.descricao,
          alvoId: linha.produtoId,
          alvoNome: linha.descricao,
        })
      );
    }
  }
  const perfisUsados = new Set(linhas.map((linha) => linha.perfilId).filter((id): id is string => Boolean(id)));
  for (const perfilId of perfisUsados) {
    const perfil = perfisPorId.get(perfilId);
    if (!perfil) continue;
    const faltas = faltasDaMercadoria(lerRegrasDeMercadoria(perfil.regras), emissor.regime);
    if (faltas.length) {
      pendencias.push(
        bloqueio({
          codigo: `perfil.incompleto:${perfilId}`,
          onde: "perfil",
          titulo: `Tributação "${perfil.nome}" incompleta`,
          detalhe: `Falta: ${faltas.join(", ")}.`,
          alvoId: perfilId,
          alvoNome: perfil.nome,
        })
      );
    }
  }

  return pendencias;
}

export function pendenciasDaNotaDeServico(input: {
  emissor: EmissorDaNota;
  destinatario: DestinatarioDaNota | null;
  grupo: GrupoDeServico;
  perfis: PerfilFiscal[];
  exige: ExigenciasDaCidade;
}): Pendencia[] {
  const pendencias: Pendencia[] = [];
  const { grupo, destinatario } = input;
  if (!grupo.linhas.length) return pendencias;

  if (!soDigitos(input.emissor.inscricaoMunicipal)) {
    pendencias.push(
      bloqueio({
        codigo: "empresa.im",
        onde: "empresa",
        titulo: "Inscrição municipal da empresa",
        detalhe: "A NFS-e precisa da inscrição municipal da empresa emissora.",
      })
    );
  }

  const documento = soDigitos(destinatario?.documento);
  if (documento && !documentoValido(documento)) {
    pendencias.push(bloqueio({ codigo: "cliente.documento_invalido", onde: "cliente", titulo: "CPF ou CNPJ do cliente é inválido" }));
  }
  if (!destinatario || !texto(destinatario.nome)) {
    pendencias.push(bloqueio({ codigo: "cliente.nome", onde: "cliente", titulo: "Nome do tomador do serviço" }));
  }

  if (liquidoDoGrupo(grupo) <= 0) {
    pendencias.push(bloqueio({ codigo: "pedido.total_servico", onde: "pedido", titulo: "O valor do serviço precisa ser maior que zero" }));
  }

  const perfil = grupo.perfilId ? input.perfis.find((item) => item.id === grupo.perfilId && item.ativo) : null;
  if (!perfil) {
    pendencias.push(
      bloqueio({
        codigo: "perfil.servico_padrao",
        onde: "perfil",
        titulo: "Tributação dos serviços",
        detalhe: "Nenhum perfil de serviço foi escolhido nem marcado como padrão.",
      })
    );
    return pendencias;
  }
  const regras = lerRegrasDeServico(perfil.regras);
  const faltas = faltasDoServico(regras, input.exige);
  if (faltas.length) {
    pendencias.push(
      bloqueio({
        codigo: `perfil.incompleto:${perfil.id}`,
        onde: "perfil",
        titulo: `Serviço "${perfil.nome}" incompleto`,
        detalhe: `Falta: ${faltas.join(", ")}.`,
        alvoId: perfil.id,
        alvoNome: perfil.nome,
      })
    );
  }
  /* Em Campinas a prefeitura só conhece códigos de 9 números. O item da lista
     (13.05) ou a atividade sozinha voltam recusados com uma mensagem que não
     diz o que corrigir ("tipoIncidencia is null"). */
  const codigoDigitado = texto(regras.codigoMunicipal);
  if (
    soDigitos(input.emissor.cidadeIbge) === CAMPINAS &&
    soDigitos(codigoDigitado).length !== 9 &&
    !faltas.includes("código do serviço no município")
  ) {
    pendencias.push(
      bloqueio({
        codigo: `perfil.codigo_de_campinas:${perfil.id}`,
        onde: "perfil",
        titulo: "Código do serviço na prefeitura de Campinas",
        detalhe: `Em Campinas, o código do serviço tem 9 números: a atividade (CNAE) com mais dois no fim, como 181309900 para a impressão 1813-0/99.${codigoDigitado ? ` "${codigoDigitado}" não tem esse formato.` : ""} O código certo está na tabela de atividades da prefeitura.`,
        alvoId: perfil.id,
        alvoNome: perfil.nome,
      })
    );
  }
  if (regras.issRetido === "sempre" && documentoValido(documento) !== "cnpj") {
    pendencias.push(
      aviso({
        codigo: "servico.retencao_pf",
        onde: "perfil",
        titulo: "ISS retido com tomador pessoa física",
        detalhe: "Retenção de ISS costuma valer só quando o tomador é empresa.",
        alvoId: perfil.id,
        alvoNome: perfil.nome,
      })
    );
  }
  return pendencias;
}

/* ================================================================== */
/* 6. Payloads                                                         */
/* ================================================================== */

export type OpcoesDaNotaDeProduto = {
  modelo: ModeloDeProduto;
  integrationId: string;
  emitir: boolean;
  homologacao: boolean;
  consumidorFinal: boolean;
  presenca: "presence" | "internet" | "telephone" | "delivery" | "othersNonPresenceOperation";
  enviarEmail: boolean;
  numeroDoPedido: number | null;
  observacao?: string | null;
  agora?: Date;
};

export type NotaMontada = {
  payload: Record<string, unknown>;
  /** Centavos. */
  valor: number;
  resumo: {
    itens: Array<{ descricao: string; quantidade: number; unidade: string; valor: number }>;
    desconto: number;
    frete: number;
    outros: number;
    impostoDestacado: number;
    pago: number;
    emAberto: number;
  };
};

function cfopDaLinha(regras: RegrasDeMercadoria, destino: "internal" | "interstate", consumidorNaoContribuinte: boolean) {
  if (destino === "internal") return regras.cfopInterno ?? 5102;
  if (consumidorNaoContribuinte && regras.cfopInterestadualConsumidor) return regras.cfopInterestadualConsumidor;
  return regras.cfopInterestadual ?? 6102;
}

function naturezaDaOperacao(regrasUsadas: RegrasDeMercadoria[]) {
  const naturezas = [...new Set(regrasUsadas.map((regras) => texto(regras.natureza)).filter(Boolean))];
  if (naturezas.length === 1) return limitar(naturezas[0], 60);
  return "Venda de mercadorias";
}

/**
 * A observação escrita por gente (a do pedido, que vem do orçamento, ou a
 * digitada na gaveta) até caber na regra de formato da SEFAZ.
 *
 * As informações complementares só aceitam uma linha e só caracteres do
 * alfabeto latino básico: um marcador "•", um travessão ou um emoji colado do
 * WhatsApp derruba a nota inteira por "falha de esquema". Cada linha do texto
 * vira um trecho separado por " | ", o marcador vira hífen, e o que não tem
 * equivalente some. Acento passa.
 */
export function observacaoParaNota(bruto: string | null | undefined): string {
  const linhas = String(bruto ?? "")
    .split(/\r?\n/)
    .map((linha) =>
      textoParaNota(linha.replace(/^\s*[•·▪◦‣●○■□–—-]\s*/u, "- "))
        .replace(/[^\u0020-\u00FF]/g, " ")
        .replace(/\s+/g, " ")
        .trim()
    )
    .filter(Boolean);
  return linhas.join(" | ");
}

function juntarInformacoes(partes: Array<string | null | undefined>, maximo: number) {
  const vistas = new Set<string>();
  const unicas: string[] = [];
  for (const parte of partes) {
    const limpa = texto(parte);
    if (!limpa || vistas.has(limpa)) continue;
    vistas.add(limpa);
    unicas.push(limpa);
  }
  return limitar(unicas.join(" | "), maximo);
}

export function montarNotaDeProduto(input: {
  emissor: EmissorDaNota;
  destinatario: DestinatarioDaNota | null;
  pedido: Pick<PedidoDaNota, "numero" | "pagamentos" | "condicoes" | "criadoEm" | "entregaEm" | "temEntrega">;
  linhas: LinhaPlanejada[];
  perfis: PerfilFiscal[];
  opcoes: OpcoesDaNotaDeProduto;
}): NotaMontada {
  const { emissor, destinatario, linhas, opcoes } = input;
  const perfisPorId = new Map(input.perfis.map((perfil) => [perfil.id, perfil]));
  const destino =
    opcoes.modelo === "nfce"
      ? "internal"
      : destinoDaOperacao(emissor, destinatario, { presenca: opcoes.presenca, temEntrega: input.pedido.temEntrega });
  const naoContribuinte = destinatario?.contribuinte !== "contribuinte";
  /* Partilha do ICMS: regime normal vendendo para consumidor final não contribuinte de outro estado. */
  const ufDoEmissor = ufValida(emissor.uf);
  const ufDoDestinatario = ufValida(destinatario?.endereco?.uf);
  const entreEstados =
    destino === "interstate" && ufDoEmissor && ufDoDestinatario ? { ufOrigem: ufDoEmissor, ufDestino: ufDoDestinatario } : null;
  const partilhaDoDestino =
    destino === "interstate" && opcoes.consumidorFinal && naoContribuinte && ufDoEmissor && ufDoDestinatario
      ? { ufOrigem: ufDoEmissor, ufDestino: ufDoDestinatario }
      : null;

  const regrasUsadas: RegrasDeMercadoria[] = [];
  let totalDaNota = 0;
  let totalDeIpi = 0;

  const items = linhas.map((linha, indice) => {
    const perfil = linha.perfilId ? perfisPorId.get(linha.perfilId) : undefined;
    const regras = lerRegrasDeMercadoria(perfil?.regras);
    regrasUsadas.push(regras);
    const { taxes, ipi } = impostosDaLinha(linha, regras, emissor.regime, {
      consumidorFinal: opcoes.consumidorFinal,
      entreEstados,
      difal: partilhaDoDestino,
    });
    totalDeIpi += ipi;
    totalDaNota += liquidoDaLinha(linha) + ipi;

    const quantidade = linha.quantidade;
    const unitario = arredondar(reais(linha.valor) / quantidade, 10);
    const descricao =
      opcoes.modelo === "nfce" && opcoes.homologacao && indice === 0 ? ITEM_EM_HOMOLOGACAO : linha.descricao;

    const item: Record<string, unknown> = {
      code: linha.codigo,
      description: descricao,
      ncm: linha.ncm,
      cfop: cfopDaLinha(regras, destino, opcoes.consumidorFinal && naoContribuinte),
      unit: linha.unidade,
      quantity: quantidade,
      unitAmount: unitario,
      totalAmount: reais(linha.valor),
      unitTax: linha.unidade,
      quantityTax: quantidade,
      unitTaxAmount: unitario,
      makeupTotal: true,
      taxes,
    };
    if (linha.gtin) item.gtinCode = linha.gtin;
    if (linha.cest) item.cest = linha.cest;
    if (linha.desconto > 0) item.discountAmount = reais(linha.desconto);
    if (linha.frete > 0) item.freightAmount = reais(linha.frete);
    if (linha.outros > 0) item.othersAmount = reais(linha.outros);
    return item;
  });

  const pagamentos = montarPagamentos(input.pedido, totalDaNota, { modelo: opcoes.modelo, agora: opcoes.agora });

  const payload: Record<string, unknown> = {
    integrationId: opcoes.integrationId,
    issue: opcoes.emitir,
    operationNature: naturezaDaOperacao(regrasUsadas),
    operationType: "outgoing",
    purposeType: "normal",
    destination: destino,
    presenceType: opcoes.modelo === "nfce" ? "presence" : opcoes.presenca,
    isFinalCustomer: opcoes.modelo === "nfce" ? true : opcoes.consumidorFinal,
    sendEmailToCustomer: opcoes.modelo === "nfe" ? opcoes.enviarEmail : false,
    items,
    payments: pagamentos.payments,
    transport: {
      freightModality: opcoes.modelo === "nfe" && input.pedido.temEntrega && linhas.some((linha) => linha.frete > 0) ? "byIssuer" : "free",
    },
  };
  if (pagamentos.duplicates.length) payload.duplicates = pagamentos.duplicates;
  if (pagamentos.billing) payload.billing = pagamentos.billing;

  if (destinatario) {
    const receiver = destinatarioParaNota(destinatario, { modelo: opcoes.modelo, homologacao: opcoes.homologacao });
    if (receiver) payload.receiver = receiver;
  }

  const informacoes = juntarInformacoes(
    [
      input.pedido.numero ? `Pedido ${input.pedido.numero}` : null,
      ...regrasUsadas.map((regras) => regras.informacoesAdicionais),
      observacaoParaNota(opcoes.observacao),
    ],
    opcoes.modelo === "nfce" ? 1000 : 2000
  );
  if (informacoes) payload.additionalInformation = informacoes;

  return {
    payload,
    valor: totalDaNota,
    resumo: {
      itens: linhas.map((linha) => ({
        descricao: linha.descricao,
        quantidade: linha.quantidade,
        unidade: linha.unidade,
        valor: linha.valor,
      })),
      desconto: linhas.reduce((acc, linha) => acc + linha.desconto, 0),
      frete: linhas.reduce((acc, linha) => acc + linha.frete, 0),
      outros: linhas.reduce((acc, linha) => acc + linha.outros, 0),
      impostoDestacado: totalDeIpi,
      pago: pagamentos.pago,
      emAberto: pagamentos.emAberto,
    },
  };
}

export type OpcoesDaNotaDeServico = {
  integrationId: string;
  emitir: boolean;
  enviarEmail: boolean;
  numeroDoPedido: number | null;
  observacao?: string | null;
  exigeAnexo?: boolean;
  /* A cidade emite pelo portal nacional. */
  padraoNacional?: boolean;
};

/** "01.06" e "106" viram "1.06"; "13.05" fica "13.05". */
export function normalizarCodigoLc116(codigo: string | null | undefined): string | null {
  const limpo = texto(codigo).replace(/\s/g, "");
  if (!limpo) return null;
  const comPonto = limpo.match(/^(\d{1,2})\.(\d{2})$/);
  if (comPonto) return `${Number(comPonto[1])}.${comPonto[2]}`;
  const digitos = soDigitos(limpo);
  if (digitos.length === 3 || digitos.length === 4) return `${Number(digitos.slice(0, -2))}.${digitos.slice(-2)}`;
  return limpo;
}

/**
 * Campinas/SP (sistema da DSF) recebe os códigos do serviço num arranjo
 * próprio: o código de 9 números da prefeitura (a atividade com mais dois
 * números, como 181309900) vai no campo do CNAE, o item da lista nacional vai
 * sem o ponto ("13.05" vira "1305" e "4.22" vira "422", como na tabela da
 * prefeitura) e o campo do código municipal fica de fora. Foi o arranjo que o
 * suporte da Spedy indicou e com que a primeira nota da MK5 saiu autorizada
 * (NFS-e 6136, 21/09/2026). Com o código no campo municipal e a atividade de 7
 * números no CNAE — o arranjo do exemplo que a própria Spedy mostra para
 * Campinas —, a prefeitura recusava.
 */
export const CAMPINAS = "3509502";

type CodigosDoServico = { federalServiceCode?: string; cityServiceCode?: string; cnaeCode?: string };

export function codigosDoServico(
  regras: Pick<RegrasDeServico, "codigoLc116" | "codigoMunicipal" | "cnae">,
  cidadeIbge: string | null
): CodigosDoServico {
  const lc116 = normalizarCodigoLc116(regras.codigoLc116);
  const codigos: CodigosDoServico = {};
  if (soDigitos(cidadeIbge) === CAMPINAS) {
    if (lc116) codigos.federalServiceCode = lc116.replace(".", "");
    const codigo = soDigitos(regras.codigoMunicipal);
    if (codigo) codigos.cnaeCode = codigo;
    return codigos;
  }
  if (lc116) codigos.federalServiceCode = lc116;
  if (regras.codigoMunicipal) codigos.cityServiceCode = regras.codigoMunicipal;
  if (regras.cnae) codigos.cnaeCode = regras.cnae;
  return codigos;
}

export function issFoiRetido(regras: RegrasDeServico, destinatario: DestinatarioDaNota | null) {
  if (regras.issRetido === "sempre") return true;
  if (regras.issRetido === "cliente_pj") return documentoValido(destinatario?.documento) === "cnpj";
  return false;
}

export function montarNotaDeServico(input: {
  emissor: EmissorDaNota;
  destinatario: DestinatarioDaNota | null;
  grupo: GrupoDeServico;
  perfil: PerfilFiscal;
  opcoes: OpcoesDaNotaDeServico;
}): NotaMontada {
  const { grupo, destinatario, opcoes } = input;
  const regras = lerRegrasDeServico(input.perfil.regras);

  const bruto = grupo.linhas.reduce((acc, linha) => acc + linha.valor, 0);
  const desconto = grupo.linhas.reduce((acc, linha) => acc + linha.desconto, 0);
  const base = Math.max(0, bruto - desconto);
  const aliquota = taxa(regras.aliquotaIss);
  const iss = Math.round((base * aliquota) / 100);
  const retido = issFoiRetido(regras, destinatario);

  const linhasDaDescricao = grupo.linhas.map((linha) => {
    const quantidade = linha.quantidade > 1 ? `${formatarNumero(linha.quantidade)} x ` : "";
    return `${quantidade}${linha.descricao} - ${formatarReaisCurto(linha.valor)}`;
  });
  const descricao = limitar(
    [
      ...linhasDaDescricao,
      opcoes.numeroDoPedido ? `Pedido ${opcoes.numeroDoPedido}` : "",
      regras.informacoesAdicionais ?? "",
      observacaoParaNota(opcoes.observacao),
    ]
      .filter((parte) => texto(parte))
      .join(" | "),
    2000
  );

  const total: Record<string, unknown> = {
    invoiceAmount: reais(bruto),
    issBaseTax: reais(base),
    issRate: aliquota,
    issAmount: reais(iss),
    issWithheld: retido,
    netAmount: reais(base - (retido ? iss : 0)),
  };
  /* No portal nacional, a alíquota de quem é do regime normal é a que a
     prefeitura cadastrou lá: o portal põe sozinho e a nota vai sem ela
     (orientação da Spedy, Parauapebas, 02/10/2026). Quem é do Simples continua
     informando a da sua faixa, e cidade com sistema próprio também. */
  if (opcoes.padraoNacional && !regimeUsaCsosn(input.emissor.regime)) delete total.issRate;
  if (desconto > 0) total.discountUnconditionedAmount = reais(desconto);

  const payload: Record<string, unknown> = {
    integrationId: opcoes.integrationId,
    issue: opcoes.emitir,
    description: descricao,
    sendEmailToCustomer: opcoes.enviarEmail,
    taxationType: regras.tipoTributacao,
    total,
  };
  Object.assign(payload, codigosDoServico(regras, input.emissor.cidadeIbge));
  if (regras.nbs) payload.nbsCode = regras.nbs;
  if (regras.codigoTributacaoNacional) payload.nationalTaxationCode = regras.codigoTributacaoNacional;
  /* Onde o ISS é devido vai SEMPRE, com a cidade da empresa como padrão. A
     Spedy diz que assume a cidade da empresa quando o campo falta, mas em
     Campinas (sistema da DSF) não assumiu: a prefeitura recebeu o campo vazio
     e recusou com "tipoIncidencia is null" (MK5, 21/09/2026, a primeira nota
     de serviço de verdade do sistema). Dito com todas as letras, vale para
     qualquer cidade e não muda a incidência de ninguém. */
  payload.taxLocation = regras.localIncidencia ?? "companyMunicipality";
  if (regras.cstPisCofins) payload.cstPisCofins = regras.cstPisCofins;
  if (regras.anexoSimples && opcoes.exigeAnexo) payload.simplesNacionalAnnex = regras.anexoSimples;
  /* Reforma tributária: na nota de serviço o grupo é da nota inteira, não do
     item. Vale a mesma regra da nota de produto — obrigatório para o regime
     normal desde 2026, e o padrão é tributação integral. */
  const ibsCbsDoServico = regras.ibsCbsCst ?? (regimeUsaCsosn(input.emissor.regime) ? null : 0);
  const classificacaoDoServico = regras.ibsCbsClassificacao ?? (regimeUsaCsosn(input.emissor.regime) ? null : 1);
  if (ibsCbsDoServico !== null && classificacaoDoServico !== null) {
    const ibsCbs: Record<string, unknown> = { cst: ibsCbsDoServico, classification: classificacaoDoServico };
    if (regras.ibsCbsIndicadorOperacao) ibsCbs.operationIndicatorCode = regras.ibsCbsIndicadorOperacao;
    payload.ibsCbs = ibsCbs;
  }
  if (destinatario) {
    const receiver = destinatarioParaNota(destinatario, { modelo: "nfse", homologacao: false });
    if (receiver) payload.receiver = receiver;
  }

  return {
    payload,
    valor: base,
    resumo: {
      itens: grupo.linhas.map((linha) => ({
        descricao: linha.descricao,
        quantidade: linha.quantidade,
        unidade: linha.unidade,
        valor: linha.valor,
      })),
      desconto,
      frete: 0,
      outros: 0,
      impostoDestacado: iss,
      pago: 0,
      emAberto: 0,
    },
  };
}
