<?php
declare(strict_types=1);

/**
 * Confere o arquivo do aliado contra o modelo de colunas e, se estiver certo, prepara a versão
 * que fica guardada (a única): colunas com o nome padrão, números como números, textos padronizados e
 * Unidade/Segmento/Família completados pela tabela de preços e pelas bases de códigos.
 *
 * Quando falta uma coluna ou há células vazias que o site sabe completar, cada erro leva uma sugestão
 * (como no dashboard: o valor de 90% ou mais das células preenchidas da coluna; ou o mês escrito a partir do
 * CD_MES, o valor calculado, o código e os dados do aliado). O aliado vê as sugestões e, se der OK em todas,
 * o arquivo é enviado de novo com a lista do que completar; só então o arquivo completado fica guardado.
 *
 * As mensagens de erro são em espanhol, porque aparecem para o aliado.
 */
final class Validacao
{
    private const MAX_LINHAS_LISTADAS = 15;

    /** Colunas que só existem no sellout: se aparecem num arquivo enviado como estoque, o tipo está trocado. */
    private const SO_SELLOUT = ['NOMBRE CLIENTE', 'NOME CLIENTE', 'TIPO CLIENTE', 'RUT CLIENTE', 'REGION CLIENTE'];
    private const SO_ESTOQUE = ['QNT ESTOQUE', 'QTD ESTOQUE', 'STOCK'];

    /**
     * Colunas que dizem se a linha é de fato uma venda (ou um item em estoque). Uma linha sem nada nelas,
     * só com um valor solto em outra coluna (um número esquecido em TX_MES, por exemplo), é ignorada com aviso.
     */
    private const PRINCIPAIS = [
        'sellout' => ['CD_ITEM', 'TX_ITEM', 'NOMBRE_CLIENTE', 'CANTIDAD', 'VALOR UNIT USD', 'VALOR TOT USD'],
        'estoque' => ['CD_ITEM', 'TX_ITEM', 'QNT_ESTOQUE', 'VALOR UNIT USD', 'VALOR TOT USD'],
    ];

    /** Colunas em que o valor mais comum não serve de sugestão: identificam a linha ou são quantidades e valores. */
    private const SEM_DOMINANTE = ['CD_ITEM', 'TX_ITEM', 'NOMBRE_CLIENTE', 'RUT_CLIENTE', 'CANTIDAD', 'QNT_ESTOQUE',
        'VALOR UNIT USD', 'VALOR TOT USD', 'UNIDADE', 'SEGMENTO', 'FAMILIA'];
    /** Parte das células preenchidas que precisa ter o mesmo valor para ele virar sugestão (a mesma regra do dashboard). */
    private const COBERTURA_SUGESTAO = 0.9;

    /**
     * @param array{completar?:list<string>, aliado?:array{nome?:string, pais?:string}} $opcoes
     *        completar: colunas cujas sugestões o aliado aceitou; aliado: nome e país do cadastro
     * @return array{ok:bool, erros:list<array{titulo:string, detalhe:string, sugestao?:array}>, avisos:list<string>,
     *               resumo:array, saida:?array, classif:array, completavel:bool, completou:list<array>}
     */
    public static function processa(string $caminho, string $tipo, string $codigoAliado, ?Classificacao $class, array $opcoes = []): array
    {
        try {
            return self::confere($caminho, $tipo, $codigoAliado, $class, $opcoes);
        } catch (XlsxErro $e) {
            // problema na leitura do Excel (em qualquer etapa)
            $erro = $e->getMessage() === 'arquivo_grande_demais'
                ? ['titulo' => 'El archivo es demasiado grande.',
                   'detalhe' => 'Envíe solo los datos de un mes (hasta ' . number_format((int) Config::get('linhas_max'), 0, ',', '.') . ' filas).']
                : ['titulo' => 'El archivo no es una planilla de Excel (.xlsx) válida.',
                   'detalhe' => 'Si está en .xls o .csv, o protegido con contraseña, ábralo en Excel y use «Guardar como» → «Libro de Excel (.xlsx)».'];
            return ['ok' => false, 'erros' => [$erro], 'avisos' => [], 'resumo' => [], 'saida' => null,
                    'classif' => ['completados' => 0, 'sem_classificacao' => 0, 'codigos_fora' => []],
                    'completavel' => false, 'completou' => []];
        }
    }

    private static function confere(string $caminho, string $tipo, string $codigoAliado, ?Classificacao $class, array $opcoes): array
    {
        $res = ['ok' => false, 'erros' => [], 'avisos' => [], 'resumo' => [], 'saida' => null,
                'classif' => ['completados' => 0, 'sem_classificacao' => 0, 'codigos_fora' => []],
                'completavel' => false, 'completou' => []];
        // $sug: o que o site sabe completar para resolver este erro (vai para o aliado decidir)
        $erro = static function (string $titulo, string $detalhe = '', ?array $sug = null) use (&$res): void {
            $e = ['titulo' => $titulo, 'detalhe' => $detalhe];
            if ($sug !== null) {
                $e['sugestao'] = ['id' => $sug['coluna'], 'texto' => $sug['texto'], 'resolve' => $sug['n'] >= $sug['total']];
            }
            $res['erros'][] = $e;
        };

        $leitor = new XlsxLeitor($caminho, 150_000_000);

        $modelo = Banco::modelo($tipo);
        // nomes aceitos de cada coluna do modelo, já normalizados
        $aceitos = [];
        foreach ($modelo as $i => $col) {
            $aceitos[$i] = array_values(array_unique(array_map([Texto::class, 'normalizaCabecalho'],
                array_merge([$col['nome']], $col['aliases']))));
        }

        // ---------------------------------------------------------------- aba e linha de títulos
        [$iAba, $numCab, $cabecalho] = self::achaCabecalho($leitor, $aceitos);
        if ($iAba < 0) {
            $erro('No encontramos la fila de títulos de las columnas.',
                'La primera fila de la planilla debe tener los nombres de las columnas, por ejemplo: '
                . implode(', ', array_slice(array_column($modelo, 'nome'), 0, 6)) . '…');
            return $res;
        }

        // colunas do arquivo que ficam: com título e sem repetir (o primeiro título repetido vale)
        $colunas = [];           // índice no Excel => título original
        $vistos = [];
        foreach ($cabecalho as $c => $titulo) {
            $n = Texto::normalizaCabecalho($titulo);
            if ($n === '' || isset($vistos[$n])) {
                continue;
            }
            $vistos[$n] = true;
            $colunas[$c] = $titulo;
        }
        // coluna do modelo -> índice no Excel
        $mapa = [];
        foreach ($modelo as $i => $col) {
            foreach ($colunas as $c => $titulo) {
                if (in_array(Texto::normalizaCabecalho($titulo), $aceitos[$i], true)) {
                    $mapa[$col['nome']] = $c;
                    break;
                }
            }
        }

        // tipo trocado (sellout enviado como estoque, ou o contrário)
        $normCols = array_map([Texto::class, 'normalizaCabecalho'], $colunas);
        if ($tipo === 'estoque' && array_intersect($normCols, self::SO_SELLOUT)) {
            $erro('Este archivo parece de Sell out, no de Stock.',
                'Tiene columnas de clientes (' . implode(', ', array_values(array_intersect($normCols, self::SO_SELLOUT)))
                . '). Verifique el tipo de archivo elegido.');
            return $res;
        }
        if ($tipo === 'sellout' && array_intersect($normCols, self::SO_ESTOQUE) && !isset($mapa['NOMBRE_CLIENTE'])) {
            $erro('Este archivo parece de Stock, no de Sell out.',
                'Tiene la columna ' . implode(', ', array_values(array_intersect($normCols, self::SO_ESTOQUE)))
                . ' y no tiene NOMBRE_CLIENTE. Verifique el tipo de archivo elegido.');
            return $res;
        }

        // colunas obrigatórias que faltam (o erro sai depois da leitura, com a sugestão quando o site sabe completar)
        $faltam = [];
        foreach ($modelo as $col) {
            if ($col['obrigatoria'] && !isset($mapa[$col['nome']])) {
                $faltam[] = $col;
            }
        }

        // ---------------------------------------------------------------- leitura das linhas
        $maxLinhas = (int) Config::get('linhas_max');
        $linhas = [];   // [número da linha no Excel, [índice no Excel => valor]]
        $porIndiceNome = array_flip($mapa);   // índice no Excel => nome do modelo
        $principais = [];
        foreach (self::PRINCIPAIS[$tipo] ?? [] as $nome) {
            if (isset($mapa[$nome])) {
                $principais[] = $mapa[$nome];
            }
        }
        $soltas = [];      // linhas ignoradas: sem nada nas colunas principais
        $colsSoltas = [];  // colunas em que essas linhas tinham algum valor
        foreach ($leitor->linhas($iAba) as $num => $cel) {
            if ($num <= $numCab) {
                continue;
            }
            $temDado = false;
            $valores = [];
            foreach ($colunas as $c => $_) {
                $v = $cel[$c] ?? null;
                if (!Texto::vazio($v) || (is_string($v) && Texto::apara($v) !== '')) {
                    $temDado = true;
                }
                $valores[$c] = $v;
            }
            if (!$temDado) {
                continue;
            }
            if ($principais) {
                $temPrincipal = false;
                foreach ($principais as $c) {
                    if (!Texto::vazio($valores[$c] ?? null)) {
                        $temPrincipal = true;
                        break;
                    }
                }
                if (!$temPrincipal) {
                    $soltas[] = $num;
                    foreach ($colunas as $c => $titulo) {
                        $v = $valores[$c] ?? null;
                        if (!Texto::vazio($v) || (is_string($v) && Texto::apara($v) !== '')) {
                            $colsSoltas[$porIndiceNome[$c] ?? $titulo] = true;
                        }
                    }
                    continue;
                }
            }
            $linhas[] = [$num, $valores];
            if (count($linhas) > $maxLinhas) {
                $erro('El archivo tiene más de ' . number_format($maxLinhas, 0, ',', '.') . ' filas.', 'Envíe solo los datos de un mes.');
                return $res;
            }
        }
        if ($soltas) {
            $res['avisos'][] = 'Se ignoraron ' . self::filas($soltas) . ': no tienen datos de ' . ($tipo === 'estoque' ? 'stock' : 'venta')
                . ' (solo tienen algo en ' . implode(', ', array_slice(array_keys($colsSoltas), 0, 5)) . ').';
        }
        if (!$linhas) {
            foreach ($faltam as $col) {
                $outros = $col['aliases'] ? ' (también se acepta: ' . implode(', ', $col['aliases']) . ')' : '';
                $erro('Falta la columna ' . $col['nome'] . '.', 'Agregue esta columna a la planilla' . $outros . '.');
            }
            $erro('La planilla no tiene filas de datos debajo de los títulos.');
            return $res;
        }

        // ---------------------------------------------------------------- o que dá para completar (com o OK do aliado)
        $sugestoes = self::sugestoes($tipo, $modelo, $mapa, $linhas, $faltam, $codigoAliado, (array) ($opcoes['aliado'] ?? []));
        foreach (array_unique(array_map('strval', (array) ($opcoes['completar'] ?? []))) as $id) {
            if (!isset($sugestoes[$id])) {
                continue;
            }
            $s = $sugestoes[$id];
            self::completa($s, $modelo, $colunas, $mapa, $linhas);
            $res['completou'][] = ['coluna' => $s['coluna'], 'n' => $s['n'], 'nova' => $s['nova'], 'como' => $s['como'],
                                   'valor' => $s['valor'] ?? null];
            unset($sugestoes[$id]);
        }
        foreach ($faltam as $col) {
            if (isset($mapa[$col['nome']])) {
                continue;   // completada agora
            }
            $outros = $col['aliases'] ? ' (también se acepta: ' . implode(', ', $col['aliases']) . ')' : '';
            $erro('Falta la columna ' . $col['nome'] . '.', 'Agregue esta columna a la planilla' . $outros . '.', $sugestoes[$col['nome']] ?? null);
        }

        // ---------------------------------------------------------------- conferências por linha
        $vazios = [];      // coluna => [linhas]
        $naoNumero = [];   // coluna => [[linha, valor]]
        $outroAliado = []; // [[linha, valor]]
        $anoInvalido = [];
        $mesInvalido = [];
        $txMesDiferente = [];
        $meses = [];
        $total = 0.0;
        $quantidade = 0.0;
        $clientes = [];
        $itens = [];

        $cAliado = $mapa['CD_ALIADO'] ?? null;
        $cAno = $mapa['CD_ANO'] ?? null;
        $cMes = $mapa['CD_MES'] ?? null;
        $cTot = $mapa['VALOR TOT USD'] ?? null;
        $cQtd = $mapa[$tipo === 'estoque' ? 'QNT_ESTOQUE' : 'CANTIDAD'] ?? null;
        $cCli = $mapa['NOMBRE_CLIENTE'] ?? null;
        $cItem = $mapa['CD_ITEM'] ?? null;
        // mês escrito: a coluna TX_MES do modelo (ou, se ela saiu do modelo, um título parecido)
        $cTxMes = $mapa['TX_MES'] ?? null;
        if ($cTxMes === null) {
            foreach ($colunas as $c => $titulo) {
                if (in_array(Texto::normalizaCabecalho($titulo), ['TX MES', 'MES TEXTO', 'NOMBRE MES'], true)) {
                    $cTxMes = $c;
                    break;
                }
            }
        }
        $txIgualQtd = 0;   // linhas em que o TX_MES diferente é igual à quantidade (quantidade copiada na coluna do mês)
        $codigoChave = Texto::codigoAliado($codigoAliado);

        foreach ($linhas as [$num, $v]) {
            foreach ($modelo as $col) {
                if (!isset($mapa[$col['nome']])) {
                    continue;
                }
                $valor = $v[$mapa[$col['nome']]] ?? null;
                if (Texto::vazio($valor)) {
                    if (!$col['pode_vazio']) {
                        $vazios[$col['nome']][] = $num;
                    }
                    continue;
                }
                if ($col['numerica'] && Texto::numero($valor) === null) {
                    $naoNumero[$col['nome']][] = [$num, $valor];
                }
            }
            if ($cAliado !== null && !Texto::vazio($v[$cAliado] ?? null)
                && Texto::codigoAliado($v[$cAliado]) !== $codigoChave) {
                $outroAliado[] = [$num, Texto::codigo($v[$cAliado])];
            }
            $ano = $cAno !== null ? Texto::numero($v[$cAno] ?? null) : null;
            $mes = $cMes !== null ? Texto::numero($v[$cMes] ?? null) : null;
            if ($cAno !== null && $ano !== null && ($ano != floor($ano) || $ano < 2000 || $ano > 2100)) {
                $anoInvalido[] = [$num, $v[$cAno]];
            }
            if ($cMes !== null && $mes !== null && ($mes != floor($mes) || $mes < 1 || $mes > 12)) {
                $mesInvalido[] = [$num, $v[$cMes]];
            }
            if ($ano !== null && $mes !== null && $ano == floor($ano) && $mes == floor($mes) && $mes >= 1 && $mes <= 12) {
                $ym = sprintf('%04d-%02d', (int) $ano, (int) $mes);
                $meses[$ym] = ($meses[$ym] ?? 0) + 1;
                // cada linha precisa ter o mesmo mês em CD_MES e em TX_MES (TX_MES vazio é conferido como campo em branco)
                $tx = $cTxMes !== null ? ($v[$cTxMes] ?? null) : null;
                if ($cTxMes !== null && !Texto::vazio($tx) && Texto::mesDoTexto($tx) !== (int) $mes) {
                    $txMesDiferente[] = [$num, (int) $mes, self::textoDaCelula($tx)];
                    $q = $cQtd !== null ? Texto::numero($v[$cQtd] ?? null) : null;
                    $t = Texto::numero($tx);
                    if ($q !== null && $t !== null && abs($q - $t) < 1e-9) {
                        $txIgualQtd++;
                    }
                }
            }
            if ($cTot !== null) {
                $total += Texto::numero($v[$cTot] ?? null) ?? 0.0;
            }
            if ($cQtd !== null) {
                $quantidade += Texto::numero($v[$cQtd] ?? null) ?? 0.0;
            }
            if ($cCli !== null && !Texto::vazio($v[$cCli] ?? null)) {
                $clientes[Texto::chave((string) $v[$cCli])] = true;
            }
            if ($cItem !== null && !Texto::vazio($v[$cItem] ?? null)) {
                $itens[Texto::codigo($v[$cItem])] = true;
            }
        }

        $nLinhas = count($linhas);
        foreach ($vazios as $nome => $nums) {
            $erro('La columna ' . $nome . ' tiene ' . self::cuenta(count($nums), 'celda vacía', 'celdas vacías') . '.',
                'Complete ' . self::filas($nums, $nLinhas) . '. Las celdas con error de Excel (como #N/A) también cuentan como vacías.',
                $sugestoes[$nome] ?? null);
        }
        foreach ($naoNumero as $nome => $lista) {
            $erro('La columna ' . $nome . ' tiene ' . self::cuenta(count($lista), 'valor que no es número', 'valores que no son números') . '.',
                'Corrija ' . self::filas(array_column($lista, 0)) . ' (por ejemplo: «' . self::corta((string) $lista[0][1]) . '»).');
        }
        if ($outroAliado) {
            $erro('El código de aliado del archivo no es el suyo (' . $codigoAliado . ').',
                'CD_ALIADO tiene otro código en ' . self::filas(array_column($outroAliado, 0), $nLinhas)
                . ' (por ejemplo: ' . self::corta((string) $outroAliado[0][1]) . '). Envíe solo los datos de su empresa.');
        }
        if ($anoInvalido) {
            $erro('CD_ANO debe ser un año, como 2026.', 'Corrija ' . self::filas(array_column($anoInvalido, 0)) . '.');
        }
        if ($mesInvalido) {
            $erro('CD_MES debe ser el número del mes, de 1 a 12.', 'Corrija ' . self::filas(array_column($mesInvalido, 0)) . '.');
        }
        if ($txMesDiferente) {
            [$n0, $m0, $t0] = $txMesDiferente[0];
            $n = count($txMesDiferente);
            $erro('El mes en número (CD_MES) y el mes escrito (TX_MES) no coinciden en ' . self::cuenta($n, 'fila', 'filas') . '.',
                'Cada fila debe tener el mismo mes en las dos columnas (por ejemplo, CD_MES = 10 y TX_MES = OCTUBRE). Revise '
                . self::filas(array_column($txMesDiferente, 0), $nLinhas) . ' (por ejemplo, la fila ' . $n0 . ' tiene CD_MES = ' . $m0
                . ' y TX_MES = ' . self::corta($t0) . ').'
                . ($txIgualQtd >= 0.8 * $n ? ' En estas filas, TX_MES tiene el mismo valor que ' . ($tipo === 'estoque' ? 'QNT_ESTOQUE' : 'CANTIDAD')
                    . ': parece que se copió la cantidad en la columna del mes.' : ''));
        }

        ksort($meses);
        $mesRef = '';
        if ($meses) {
            $mesRef = array_search(max($meses), $meses, true);
        }
        $res['resumo'] = [
            'aba' => $leitor->abas()[$iAba]['nome'],
            'linha_titulos' => $numCab,
            'linhas' => count($linhas),
            'meses' => $meses,
            'mes_ref' => (string) $mesRef,
            'total_usd' => round($total, 2),
            'quantidade' => round($quantidade, 2),
            'clientes' => count($clientes),
            'itens' => count($itens),
        ];
        if ($res['erros']) {
            // dá para resolver tudo só com as sugestões? então o aliado pode completar e enviar de novo
            $res['completavel'] = true;
            foreach ($res['erros'] as $e) {
                if (empty($e['sugestao']['resolve'])) {
                    $res['completavel'] = false;
                }
            }
            return $res;
        }
        if (count($meses) > 1) {
            $partes = [];
            foreach ($meses as $ym => $n) {
                $partes[] = mes_curto($ym, 'es') . ' (' . self::cuenta($n, 'fila', 'filas') . ')';
            }
            $res['avisos'][] = 'El archivo tiene datos de más de un mes: ' . implode(', ', $partes) . '.';
        }

        // ---------------------------------------------------------------- versão que fica guardada
        $res['saida'] = self::montaSaida($tipo, $modelo, $colunas, $mapa, $linhas, $class, $res['classif']);
        $res['ok'] = true;
        return $res;
    }

    /**
     * O que o site sabe completar, por coluna do modelo (a chave é o nome da coluna):
     * - coluna obrigatória que falta: TX_MES (pelo CD_MES), CD_MES (pelo TX_MES), CD_ALIADO (o código de quem entrou),
     *   TX_ALIADO e PAIS_ALIADO (do cadastro do aliado), VALOR TOT USD e VALOR UNIT USD (pela conta);
     * - células vazias: as mesmas contas e, nas demais colunas, o valor de 90% ou mais das células preenchidas
     *   (a regra do dashboard). Código, descrição, cliente, RUT, quantidades e valores não recebem esse valor.
     * Cada sugestão traz os valores linha a linha (índice em $linhas) e o texto, em espanhol, para o aliado.
     *
     * @return array<string, array{coluna:string, nova:bool, como:string, valor?:mixed, valores:array<int,mixed>, n:int, total:int, texto:string}>
     */
    private static function sugestoes(string $tipo, array $modelo, array $mapa, array $linhas, array $faltam,
                                      string $codigoAliado, array $aliado): array
    {
        $qtdNome = $tipo === 'estoque' ? 'QNT_ESTOQUE' : 'CANTIDAD';
        $cQtd = $mapa[$qtdNome] ?? null;
        $cUnit = $mapa['VALOR UNIT USD'] ?? null;
        $cTot = $mapa['VALOR TOT USD'] ?? null;
        $cMes = $mapa['CD_MES'] ?? null;
        $cTxMes = $mapa['TX_MES'] ?? null;
        $num = static fn (array $v, ?int $c) => $c === null ? null : Texto::numero($v[$c] ?? null);
        $codigo = Texto::codigoAliado($codigoAliado);
        $nomeAliado = Texto::nomePadrao((string) ($aliado['nome'] ?? ''));
        $paisAliado = Texto::nomePadrao((string) ($aliado['pais'] ?? ''));

        // valor de cada linha, conforme a coluna (null = não dá para calcular nesta linha)
        $porLinha = static function (string $nome, array $v) use ($num, $cQtd, $cUnit, $cTot, $cMes, $cTxMes, $codigo): mixed {
            switch ($nome) {
                case 'TX_MES':
                    $m = $num($v, $cMes);
                    return ($m !== null && $m == floor($m) && $m >= 1 && $m <= 12) ? Texto::MESES_PT[(int) $m] : null;
                case 'CD_MES':
                    $m = $cTxMes !== null && !Texto::vazio($v[$cTxMes] ?? null) ? Texto::mesDoTexto($v[$cTxMes]) : null;
                    return $m ?: null;
                case 'VALOR TOT USD':
                    $q = $num($v, $cQtd);
                    $u = $num($v, $cUnit);
                    return ($q !== null && $u !== null) ? round($q * $u, 2) : null;
                case 'VALOR UNIT USD':
                    $q = $num($v, $cQtd);
                    $t = $num($v, $cTot);
                    return ($q !== null && $t !== null && abs($q) > 1e-9) ? round($t / $q, 6) : null;
                case 'CD_ALIADO':
                    return $codigo !== '' ? (ctype_digit($codigo) ? (int) $codigo : $codigo) : null;
            }
            return null;
        };
        $textoConta = [
            'TX_MES' => 'el mes escrito, a partir del número de CD_MES',
            'CD_MES' => 'el número del mes escrito en TX_MES',
            'VALOR TOT USD' => 'VALOR TOT USD = ' . $qtdNome . ' × VALOR UNIT USD',
            'VALOR UNIT USD' => 'VALOR UNIT USD = VALOR TOT USD ÷ ' . $qtdNome,
            'CD_ALIADO' => 'su código de aliado (' . $codigo . ')',
        ];
        $comoConta = ['TX_MES' => 'mes', 'CD_MES' => 'mes', 'VALOR TOT USD' => 'conta', 'VALOR UNIT USD' => 'conta', 'CD_ALIADO' => 'aliado'];
        // a conta só vale se as colunas de que ela depende estão no arquivo
        $podeContar = [
            'TX_MES' => $cMes !== null, 'CD_MES' => $cTxMes !== null, 'VALOR TOT USD' => $cQtd !== null && $cUnit !== null,
            'VALOR UNIT USD' => $cQtd !== null && $cTot !== null, 'CD_ALIADO' => $codigo !== '',
        ];
        $doCadastro = ['TX_ALIADO' => [$nomeAliado, 'el nombre de su empresa'], 'PAIS_ALIADO' => [$paisAliado, 'el país de su empresa']];
        $parcial = static fn (int $n, int $total, bool $nova) => $n >= $total ? ''
            : ' Así se ' . ($n === 1 ? 'completa ' : 'completan ') . number_format($n, 0, ',', '.') . ' de las ' . number_format($total, 0, ',', '.')
              . ($nova ? ' filas' : ' celdas vacías') . '; las demás hay que corregirlas en el archivo.';

        $out = [];
        // colunas que faltam
        foreach ($faltam as $col) {
            $nome = $col['nome'];
            $valores = [];
            if (!empty($podeContar[$nome])) {
                foreach ($linhas as $k => [, $v]) {
                    $x = $porLinha($nome, $v);
                    if ($x !== null) {
                        $valores[$k] = $x;
                    }
                }
                $como = $comoConta[$nome];
                $texto = 'Agregar la columna ' . $nome . ($nome === 'VALOR TOT USD' || $nome === 'VALOR UNIT USD'
                    ? ' calculada: ' . $textoConta[$nome] : ' con ' . $textoConta[$nome]);
            } elseif (isset($doCadastro[$nome]) && $doCadastro[$nome][0] !== '') {
                $valores = array_fill_keys(array_keys($linhas), $doCadastro[$nome][0]);
                $como = 'aliado';
                $texto = 'Agregar la columna ' . $nome . ' con ' . $doCadastro[$nome][1] . ' («' . $doCadastro[$nome][0] . '»)';
            } elseif (in_array($nome, ['UNIDADE', 'SEGMENTO', 'FAMILIA'], true)) {
                // a coluna entra vazia e o site a completa pela tabela de preços e pelas bases de códigos
                $out[$nome] = ['coluna' => $nome, 'nova' => true, 'como' => 'tabela', 'valores' => [], 'n' => count($linhas),
                               'total' => count($linhas), 'texto' => 'Agregar la columna ' . $nome . ' y completarla con nuestra tabla de productos.'];
                continue;
            } else {
                continue;
            }
            if (!$valores) {
                continue;
            }
            $n = count($valores);
            $total = count($linhas);
            $out[$nome] = ['coluna' => $nome, 'nova' => true, 'como' => $como, 'valores' => $valores, 'n' => $n, 'total' => $total,
                           'texto' => $texto . '.' . $parcial($n, $total, true)];
        }
        // células vazias nas colunas que estão no arquivo
        foreach ($modelo as $col) {
            $nome = $col['nome'];
            if (!isset($mapa[$nome]) || $col['pode_vazio']) {
                continue;
            }
            $c = $mapa[$nome];
            $brancos = [];
            foreach ($linhas as $k => [, $v]) {
                if (Texto::vazio($v[$c] ?? null)) {
                    $brancos[] = $k;
                }
            }
            if (!$brancos) {
                continue;
            }
            $valores = [];
            $sug = null;
            if (!empty($podeContar[$nome])) {
                foreach ($brancos as $k) {
                    $x = $porLinha($nome, $linhas[$k][1]);
                    if ($x !== null) {
                        $valores[$k] = $x;
                    }
                }
                if ($valores) {
                    $sug = ['como' => $comoConta[$nome], 'texto' => ($comoConta[$nome] === 'conta' ? 'Calcular ' : 'Completar con ') . $textoConta[$nome]];
                }
            }
            $dominante = !in_array($nome, self::SEM_DOMINANTE, true) && (!$col['numerica'] || in_array($nome, ['CD_ANO', 'CD_MES'], true));
            if ($sug === null && $dominante) {
                // o valor mais comum das células preenchidas, quando ele está em 90% ou mais delas
                $freq = [];
                $exemplo = [];
                $preenchidas = 0;
                foreach ($linhas as [, $v]) {
                    $x = $v[$c] ?? null;
                    if (Texto::vazio($x)) {
                        continue;
                    }
                    $preenchidas++;
                    $chave = (is_int($x) || is_float($x)) ? self::textoDaCelula($x) : Texto::chave((string) $x);
                    $freq[$chave] = ($freq[$chave] ?? 0) + 1;
                    $exemplo[$chave] ??= $x;
                }
                if ($preenchidas > 0) {
                    arsort($freq);
                    $top = (string) array_key_first($freq);
                    $cobre = $freq[$top] / $preenchidas;
                    if ($cobre >= self::COBERTURA_SUGESTAO) {
                        $valor = $exemplo[$top];
                        if (is_string($valor)) {   // como fica no arquivo pronto (país em português só nas colunas de país)
                            $valor = str_starts_with(Texto::normalizaCabecalho($nome), 'PAIS') ? Texto::nomePadrao($valor) : Texto::maiusculaSemAcento($valor);
                        }
                        $valores = array_fill_keys($brancos, $valor);
                        $sug = ['como' => 'valor', 'valor' => self::textoDaCelula($valor),
                                'texto' => 'Completar con «' . self::corta(self::textoDaCelula($valor)) . '» (el valor del '
                                    . (int) round($cobre * 100) . '% de las filas completas de esta columna)'];
                    }
                }
            }
            if ($sug === null && isset($doCadastro[$nome]) && $doCadastro[$nome][0] !== '') {
                $valores = array_fill_keys($brancos, $doCadastro[$nome][0]);
                $sug = ['como' => 'aliado', 'texto' => 'Completar con ' . $doCadastro[$nome][1] . ' («' . $doCadastro[$nome][0] . '»)'];
            }
            if ($sug === null || !$valores) {
                continue;
            }
            $n = count($valores);
            $total = count($brancos);
            $out[$nome] = $sug + ['coluna' => $nome, 'nova' => false, 'valores' => $valores, 'n' => $n, 'total' => $total];
            $out[$nome]['texto'] = $sug['texto'] . '.' . $parcial($n, $total, false);
        }
        return $out;
    }

    /** Aplica uma sugestão aceita: preenche as células vazias ou acrescenta a coluna que faltava (no lugar dela no modelo). */
    private static function completa(array $s, array $modelo, array &$colunas, array &$mapa, array &$linhas): void
    {
        $nome = $s['coluna'];
        if ($s['nova']) {
            $novo = $colunas ? (int) max(array_keys($colunas)) + 1 : 0;
            // logo depois da última coluna do modelo, antes desta, que está no arquivo
            $depois = null;
            foreach ($modelo as $col) {
                if ($col['nome'] === $nome) {
                    break;
                }
                if (isset($mapa[$col['nome']])) {
                    $depois = $mapa[$col['nome']];
                }
            }
            $novas = $depois === null ? [$novo => $nome] : [];
            foreach ($colunas as $c => $t) {
                $novas[$c] = $t;
                if ($c === $depois) {
                    $novas[$novo] = $nome;
                }
            }
            $colunas = $novas;
            $mapa[$nome] = $novo;
            foreach ($linhas as $k => $_) {
                $linhas[$k][1][$novo] = $s['valores'][$k] ?? null;
            }
            return;
        }
        $c = $mapa[$nome];
        foreach ($s['valores'] as $k => $valor) {
            $linhas[$k][1][$c] = $valor;
        }
    }

    /**
     * Procura a aba e a linha de títulos: a primeira aba (visíveis antes das ocultas) em que uma das
     * 20 primeiras linhas tem pelo menos 3 colunas do modelo.
     *
     * @return array{0:int, 1:int, 2:array<int,string>}
     */
    private static function achaCabecalho(XlsxLeitor $leitor, array $aceitos): array
    {
        $todos = array_merge(...array_values($aceitos));
        $ordem = [];
        foreach ($leitor->abas() as $i => $aba) {
            $ordem[$aba['oculta'] ? 1 : 0][] = $i;
        }
        foreach (array_merge($ordem[0] ?? [], $ordem[1] ?? []) as $i) {
            $melhor = [0, 0, []];
            foreach ($leitor->linhas($i) as $num => $cel) {
                if ($num > 20) {
                    break;
                }
                $titulos = [];
                $acertos = 0;
                foreach ($cel as $c => $v) {
                    if (is_string($v) || is_int($v) || is_float($v)) {
                        $t = Texto::apara((string) $v);
                        if ($t !== '') {
                            $titulos[$c] = $t;
                            if (in_array(Texto::normalizaCabecalho($t), $todos, true)) {
                                $acertos++;
                            }
                        }
                    }
                }
                if ($acertos > $melhor[0]) {
                    $melhor = [$acertos, $num, $titulos];
                }
            }
            if ($melhor[0] >= 3) {
                return [$i, $melhor[1], $melhor[2]];
            }
        }
        return [-1, 0, []];
    }

    /**
     * Monta cabeçalho e linhas da planilha que fica guardada (a "pronta"):
     * - colunas do arquivo na mesma ordem, com o nome padrão quando são do modelo;
     * - textos em maiúsculas e sem acento, e país em português (Uruguai, Paraguai, Equador);
     * - números como números e códigos sem ".0" nem espaços;
     * - mês por extenso em português no TX_MES (no sellout, a coluna entra depois do CD_MES se faltar);
     * - Unidade/Segmento/Família vazios completados pela tabela de preços e, depois, pelas bases de códigos.
     */
    private static function montaSaida(string $tipo, array $modelo, array $colunas, array $mapa, array $linhas,
                                       ?Classificacao $class, array &$classif): array
    {
        $porIndice = array_flip($mapa); // índice no Excel => nome do modelo
        $numericas = [];
        foreach ($modelo as $col) {
            if ($col['numerica']) {
                $numericas[$col['nome']] = true;
            }
        }
        $classifCols = ['UNIDADE', 'SEGMENTO', 'FAMILIA'];
        $plano = [];   // [título na planilha pronta, índice no Excel (null = coluna nova)]
        foreach ($colunas as $c => $titulo) {
            $nome = $porIndice[$c] ?? null;
            $plano[] = [$nome ?? Texto::maiusculaSemAcento($titulo), $c];
            if ($nome === 'CD_MES' && $tipo === 'sellout' && !isset($mapa['TX_MES'])) {
                $plano[] = ['TX_MES', null];
            }
        }
        foreach ($classifCols as $k) {
            if (!isset($mapa[$k])) {
                $plano[] = [$k, null];
            }
        }
        $cabecalho = array_column($plano, 0);
        $posicoes = [];
        foreach ($classifCols as $j => $k) {
            $posicoes[$j] = (int) array_search($k, $cabecalho, true);
        }
        $posTxMes = array_search('TX_MES', $cabecalho, true);
        $cMes = $mapa['CD_MES'] ?? null;
        $cItem = $mapa['CD_ITEM'] ?? null;
        $cDesc = $mapa['TX_ITEM'] ?? null;

        // colunas de país (PAIS_ALIADO, PAIS_CLIENTE...): URUGUAY vira URUGUAI, PARAGUAY vira PARAGUAI
        $ehPais = [];
        foreach ($plano as $k => [$titulo]) {
            $ehPais[$k] = str_starts_with(Texto::normalizaCabecalho($titulo), 'PAIS');
        }

        $fora = [];
        $saidaLinhas = [];
        foreach ($linhas as [, $v]) {
            $linha = [];
            foreach ($plano as $k => [, $c]) {
                if ($c === null) {
                    $linha[] = '';
                    continue;
                }
                $nome = $porIndice[$c] ?? null;
                $val = $v[$c] ?? null;
                if ($nome !== null && isset($numericas[$nome]) && !Texto::vazio($val)) {
                    $n = Texto::numero($val);
                    $linha[] = $n === null ? $val : (floor($n) == $n && abs($n) < 1e15 ? (int) $n : $n);
                } elseif ($nome === 'CD_ITEM' || $nome === 'CD_ALIADO') {
                    // códigos: sem espaços invisíveis; 4541073.0 vira 4541073
                    $linha[] = is_string($val) ? Texto::codigo($val)
                        : (is_float($val) && floor($val) == $val && abs($val) < 1e15 ? (int) $val : $val);
                } elseif (is_string($val)) {
                    // textos em maiúsculas e sem acento
                    $linha[] = $ehPais[$k] ? Texto::nomePadrao($val) : Texto::maiusculaSemAcento($val);
                } else {
                    $linha[] = $val;
                }
            }
            // mês por extenso, em português (vem do CD_MES, que já foi conferido com o TX_MES)
            if ($posTxMes !== false && $cMes !== null) {
                $m = Texto::numero($v[$cMes] ?? null);
                if ($m !== null && $m == floor($m) && $m >= 1 && $m <= 12) {
                    $linha[$posTxMes] = Texto::MESES_PT[(int) $m];
                }
            }
            // completa Unidade / Segmento / Família vazios (ou com #N/D): tabela de preços primeiro, depois as bases
            $faltando = false;
            foreach ($posicoes as $p) {
                if (Texto::vazio($linha[$p] ?? null)) {
                    $faltando = true;
                }
            }
            if ($faltando) {
                $cod = $cItem !== null ? Texto::codigo($v[$cItem] ?? '') : '';
                $achado = ($class !== null && $cod !== '') ? $class->busca($cod) : null;
                $aindaFalta = false;
                foreach ($posicoes as $j => $p) {
                    if (Texto::vazio($linha[$p] ?? null)) {
                        if ($achado !== null && $achado[$j] !== '') {
                            $linha[$p] = Texto::maiusculaSemAcento($achado[$j]);
                            $classif['completados']++;
                        } else {
                            $linha[$p] = '';
                            $aindaFalta = true;
                        }
                    }
                }
                if ($aindaFalta) {
                    $classif['sem_classificacao']++;
                    if ($cod !== '' && $achado === null && !isset($fora[$cod])) {
                        $desc = $cDesc !== null ? $v[$cDesc] ?? '' : '';
                        $fora[$cod] = is_string($desc) ? mb_substr(Texto::apara($desc), 0, 120) : (string) $desc;
                    }
                }
            }
            $saidaLinhas[] = $linha;
        }
        // [código, descrição do arquivo], para o administrador completar a tabela de preços
        $classif['codigos_fora'] = [];
        foreach (array_slice($fora, 0, 300, true) as $cod => $desc) {
            $classif['codigos_fora'][] = [(string) $cod, $desc];
        }
        return ['aba' => $tipo === 'estoque' ? 'Estoque' : 'Sellout', 'cabecalho' => $cabecalho, 'linhas' => $saidaLinhas];
    }

    /** Valor de uma célula para mostrar na mensagem: 2.0 vira "2". */
    private static function textoDaCelula(mixed $v): string
    {
        if (is_float($v) && floor($v) == $v && abs($v) < 1e15) {
            return (string) (int) $v;
        }
        return is_string($v) ? Texto::apara($v) : (string) $v;
    }

    /** "las filas 2, 5, 9 y 120 más", ou "todas las filas" quando a lista cobre o arquivo inteiro */
    private static function filas(array $nums, int $totalLinhas = 0): string
    {
        $nums = array_values(array_unique($nums));
        if ($totalLinhas > 1 && count($nums) === $totalLinhas) {
            return 'todas las filas';
        }
        $mostrar = array_slice($nums, 0, self::MAX_LINHAS_LISTADAS);
        $resto = count($nums) - count($mostrar);
        $txt = (count($nums) === 1 ? 'la fila ' : 'las filas ') . implode(', ', $mostrar);
        if ($resto > 0) {
            $txt .= ' y ' . $resto . ' más';
        }
        return $txt;
    }

    private static function cuenta(int $n, string $singular, string $plural): string
    {
        return number_format($n, 0, ',', '.') . ' ' . ($n === 1 ? $singular : $plural);
    }

    private static function corta(string $s, int $max = 40): string
    {
        $s = Texto::apara($s);
        return mb_strlen($s) > $max ? mb_substr($s, 0, $max - 1) . '…' : $s;
    }
}
