<?php
declare(strict_types=1);

/**
 * Unidade, Segmento e Família de cada código, para completar os arquivos dos aliados.
 *
 * Fontes, nesta ordem (cada campo vem da primeira que o tiver):
 * 1. a tabela de preços ("Tabela de Preço.xlsm"): aba Tabela; depois Base_anterior (códigos antigos);
 *    depois Lançamentos e Tabela Uruguai, que só têm Unidade e Segmento;
 * 2. as bases de códigos da pasta dados/bases (por exemplo Base_ARG.csv e BASE_URU_de_Codigos.xlsx),
 *    em ordem alfabética do nome do arquivo: completam o que a tabela não tem.
 * A busca é pelo código do produto. A leitura é feita uma vez e guardada num cache
 * (dados/cache/classificacao.json), refeito sozinho quando algum desses arquivos muda.
 */
final class Classificacao
{
    private const ABAS = ['TABELA', 'LISTA', 'BASE ANTERIOR', 'LANCAMENTOS', 'TABELA URUGUAI', 'TABELA ARGENTINA'];
    private const COLUNAS_CODIGO = ['COD ITEM', 'CD ITEM', 'CODIGO ITEM', 'CODIGO', 'COD PRODUTO', 'CODIGO PRODUTO',
        'COD PRODUCTO', 'CODIGO PRODUCTO'];
    private const VERSAO_CACHE = 2;

    /** @var array<string, array{0:string,1:string,2:string}> */
    private array $codigos;
    private array $info;

    private function __construct(array $codigos, array $info)
    {
        $this->codigos = $codigos;
        $this->info = $info;
    }

    /** Caminho da tabela de preços, procurada na pasta de dados e depois na raiz do projeto. */
    public static function arquivoTabela(): ?string
    {
        foreach ([Config::dirDados(), Config::raiz()] as $dir) {
            $achados = self::arquivosEm($dir);
            if ($achados) {
                return $achados[0];
            }
        }
        return null;
    }

    /**
     * Arquivos de uma pasta com um dos nomes da tabela de preços, na ordem de preferência.
     * O nome é comparado sem acento e sem diferença de maiúsculas ("Tabela de Preco.xlsm" também vale).
     * @return list<string>
     */
    public static function arquivosEm(string $dir): array
    {
        if (!is_dir($dir)) {
            return [];
        }
        $alvos = array_map([Texto::class, 'chave'], (array) Config::get('nomes_tabela_precos'));
        $achados = [];
        foreach (scandir($dir) ?: [] as $f) {
            if ($f === '' || $f[0] === '.' || $f[0] === '~') {
                continue;
            }
            $pos = array_search(Texto::chave($f), $alvos, true);
            if ($pos !== false && is_file($dir . '/' . $f)) {
                $achados[$pos * 1000 + count($achados)] = $dir . '/' . $f;
            }
        }
        ksort($achados);
        return array_values($achados);
    }

    /**
     * Lê uma planilha só para conferir se ela serve como tabela de preços (usado antes de trocar a tabela).
     * @return array{codigos:int, abas:array<string,int>}
     */
    public static function testa(string $arquivo): array
    {
        [$codigos, $abas] = self::leTabela($arquivo);
        return ['codigos' => count($codigos), 'abas' => $abas];
    }

    /** Pasta das bases de códigos. */
    public static function dirBases(): string
    {
        $d = Config::dirDados() . '/bases';
        if (!is_dir($d)) {
            @mkdir($d, 0750, true);
        }
        return $d;
    }

    /** Bases de códigos (.csv, .xlsx ou .xlsm), na ordem em que são usadas: alfabética, sem diferença de maiúsculas. */
    public static function arquivosBases(): array
    {
        $dir = self::dirBases();
        $out = [];
        foreach (scandir($dir) ?: [] as $f) {
            if ($f !== '' && $f[0] !== '.' && $f[0] !== '~' && preg_match('/\.(csv|xlsx|xlsm)$/i', $f) && is_file($dir . '/' . $f)) {
                $out[] = $dir . '/' . $f;
            }
        }
        usort($out, static fn ($a, $b) => strcasecmp(basename($a), basename($b)));
        return $out;
    }

    /** Lê uma base de códigos só para conferir se ela serve (usado antes de guardá-la). */
    public static function testaBase(string $arquivo): array
    {
        return ['codigos' => count(self::leBase($arquivo))];
    }

    /** Carrega a classificação (do cache, ou relendo as fontes se alguma mudou). Null sem nenhuma fonte. */
    public static function carrega(bool $forcar = false): ?self
    {
        $tabela = self::arquivoTabela();
        $bases = self::arquivosBases();
        if ($tabela === null && !$bases) {
            return null;
        }
        $dirCache = Config::dirDados() . '/cache';
        if (!is_dir($dirCache)) {
            @mkdir($dirCache, 0750, true);
        }
        $cache = $dirCache . '/classificacao.json';
        // muda quando qualquer fonte muda (inclusive a permissão de leitura, que o administrador pode acertar depois)
        $partes = [];
        foreach (array_merge($tabela !== null ? [$tabela] : [], $bases) as $f) {
            $partes[] = basename($f) . '|' . @filesize($f) . '|' . @filemtime($f) . '|' . (is_readable($f) ? 'r' : '-');
        }
        $assinatura = self::VERSAO_CACHE . ':' . implode('||', $partes);
        if (!$forcar && is_file($cache)) {
            $dados = json_decode((string) file_get_contents($cache), true);
            if (is_array($dados) && ($dados['assinatura'] ?? '') === $assinatura) {
                return new self($dados['codigos'], $dados['info']);
            }
        }
        // só um processo relê as fontes por vez
        $trava = fopen($dirCache . '/classificacao.lock', 'c');
        flock($trava, LOCK_EX);
        try {
            if (!$forcar && is_file($cache)) {
                $dados = json_decode((string) file_get_contents($cache), true);
                if (is_array($dados) && ($dados['assinatura'] ?? '') === $assinatura) {
                    return new self($dados['codigos'], $dados['info']);
                }
            }
            @set_time_limit(300);
            $inicio = microtime(true);
            $codigos = [];
            $abas = [];
            $fontes = [];
            if ($tabela !== null) {
                try {
                    [$codigos, $abas] = self::leTabela($tabela);
                    $fontes[] = ['arquivo' => basename($tabela), 'tipo' => 'tabela', 'codigos' => count($codigos)];
                } catch (Throwable $e) {
                    $fontes[] = ['arquivo' => basename($tabela), 'tipo' => 'tabela', 'erro' => self::explicaErro($e, $tabela)];
                }
            }
            foreach ($bases as $arqBase) {
                try {
                    $base = self::leBase($arqBase);
                } catch (Throwable $e) {
                    $fontes[] = ['arquivo' => basename($arqBase), 'tipo' => 'base', 'erro' => self::explicaErro($e, $arqBase)];
                    continue;
                }
                $novos = 0;
                $campos = 0;
                foreach ($base as $cod => $tri) {
                    $existia = isset($codigos[$cod]);
                    $atual = $codigos[$cod] ?? ['', '', ''];
                    $mudou = false;
                    for ($j = 0; $j < 3; $j++) {
                        if ($atual[$j] === '' && $tri[$j] !== '') {
                            $atual[$j] = $tri[$j];
                            $campos++;
                            $mudou = true;
                        }
                    }
                    if ($mudou) {
                        $codigos[$cod] = $atual;
                        if (!$existia) {
                            $novos++;
                        }
                    }
                }
                $fontes[] = ['arquivo' => basename($arqBase), 'tipo' => 'base', 'codigos' => count($base),
                             'novos' => $novos, 'campos' => $campos, 'modificada_em' => date('Y-m-d H:i:s', (int) filemtime($arqBase))];
            }
            $info = [
                'arquivo' => $tabela !== null ? basename($tabela) : null,
                'modificada_em' => $tabela !== null ? date('Y-m-d H:i:s', (int) filemtime($tabela)) : null,
                'lida_em' => date('Y-m-d H:i:s'),
                'segundos' => round(microtime(true) - $inicio, 1),
                'codigos' => count($codigos),
                'abas' => $abas,
                'fontes' => $fontes,
            ];
            $json = json_encode(['assinatura' => $assinatura, 'info' => $info, 'codigos' => $codigos], JSON_UNESCAPED_UNICODE);
            file_put_contents($cache . '.tmp', $json);
            rename($cache . '.tmp', $cache);
            return new self($codigos, $info);
        } finally {
            flock($trava, LOCK_UN);
            fclose($trava);
        }
    }

    /** Explicação, para o administrador, de por que a tabela não pôde ser lida. */
    public static function explicaErro(Throwable $e, ?string $arquivo = null): string
    {
        return match ($e->getMessage()) {
            'sem_permissao' => 'o servidor não tem permissão para ler o arquivo. No servidor, rode: sudo chown www-data:www-data "'
                . ($arquivo ?? 'Tabela de Preço.xlsm') . '" && sudo chmod 640 "' . ($arquivo ?? 'Tabela de Preço.xlsm') . '"',
            'nao_e_xlsx' => 'o arquivo não é uma planilha .xlsm/.xlsx válida (está protegido com senha ou corrompido?). Abra no Excel e salve de novo',
            'arquivo_grande_demais' => 'uma das abas é grande demais para ler',
            'sem_abas', 'arquivo_inexistente' => 'o arquivo está vazio ou sumiu',
            'sem_colunas' => 'não achei o cabeçalho com a coluna do código (CD_ITEM ou COD ITEM) e as de Unidade, Segmento ou Família',
            default => $e->getMessage(),
        };
    }

    /** @return array{0:string,1:string,2:string}|null [Unidade, Segmento, Família] */
    public function busca(string $codigo): ?array
    {
        return $this->codigos[$codigo] ?? null;
    }

    public function info(): array
    {
        return $this->info;
    }

    /** @return array{0: array<string, array{0:string,1:string,2:string}>, 1: array<string,int>} */
    private static function leTabela(string $arquivo): array
    {
        $leitor = new XlsxLeitor($arquivo, 400_000_000);
        $codigos = [];
        $abasUsadas = [];
        foreach (self::ABAS as $nomeAba) {
            $i = $leitor->indiceDaAba($nomeAba);
            if ($i < 0) {
                continue;
            }
            $colCod = $colU = $colS = $colF = -1;
            $n = 0;
            foreach ($leitor->linhas($i) as $numLinha => $cel) {
                if ($colCod < 0) {
                    if ($numLinha > 40) {
                        break; // sem cabeçalho reconhecível nesta aba
                    }
                    $achou = self::colunasDoCabecalho($cel);
                    if ($achou !== null) {
                        [$colCod, $colU, $colS, $colF] = $achou;
                    }
                    continue;
                }
                $cod = Texto::codigo($cel[$colCod] ?? '');
                if ($cod === '') {
                    continue;
                }
                $atual = $codigos[$cod] ?? ['', '', ''];
                $novo = [
                    $atual[0] !== '' ? $atual[0] : self::texto($cel[$colU] ?? ''),
                    $atual[1] !== '' ? $atual[1] : self::texto($cel[$colS] ?? ''),
                    $atual[2] !== '' ? $atual[2] : self::texto($cel[$colF] ?? ''),
                ];
                if ($novo !== $atual) {
                    $codigos[$cod] = $novo;
                    $n++;
                }
            }
            if ($colCod >= 0) {
                $abasUsadas[$leitor->abas()[$i]['nome']] = $n;
            }
        }
        // só fica quem tem pelo menos um dos três campos
        $codigos = array_filter($codigos, static fn ($x) => $x[0] !== '' || $x[1] !== '' || $x[2] !== '');
        return [$codigos, $abasUsadas];
    }

    /**
     * Linha de cabeçalho: tem a coluna do código e pelo menos uma de Unidade, Segmento e Família.
     * @return array{0:int,1:int,2:int,3:int}|null índices de código, Unidade, Segmento e Família (-1 se faltar)
     */
    private static function colunasDoCabecalho(array $cel): ?array
    {
        $cod = $u = $s = $f = -1;
        foreach ($cel as $c => $v) {
            if (!is_string($v)) {
                continue;
            }
            $k = Texto::normalizaCabecalho($v);
            if ($cod < 0 && in_array($k, self::COLUNAS_CODIGO, true)) {
                $cod = $c;
            } elseif ($k === 'UNIDADE' && $u < 0) {
                $u = $c;
            } elseif ($k === 'SEGMENTO' && $s < 0) {
                $s = $c;
            } elseif ($k === 'FAMILIA' && $f < 0) {
                $f = $c;
            }
        }
        return ($cod >= 0 && ($u >= 0 || $s >= 0 || $f >= 0)) ? [$cod, $u, $s, $f] : null;
    }

    /**
     * Base de códigos em CSV ou Excel: código do produto + Unidade, Segmento e Família.
     * No Excel, vale a primeira aba que tiver o cabeçalho. Um código repetido fica com o primeiro valor de cada campo.
     * @return array<string, array{0:string,1:string,2:string}>
     */
    private static function leBase(string $arquivo): array
    {
        if (!is_readable($arquivo)) {
            throw new XlsxErro('sem_permissao');
        }
        $ext = strtolower(pathinfo($arquivo, PATHINFO_EXTENSION));
        if ($ext === 'csv') {
            $fontes = [self::linhasCsv($arquivo)];
        } else {
            $leitor = new XlsxLeitor($arquivo, 150_000_000);
            $fontes = [];
            foreach (array_keys($leitor->abas()) as $i) {
                $fontes[] = $leitor->linhas($i);   // geradores: cada aba só é lida se a anterior não servir
            }
        }
        foreach ($fontes as $linhas) {
            $col = null;
            $codigos = [];
            foreach ($linhas as $num => $cel) {
                if ($col === null) {
                    if ($num > 40) {
                        break;
                    }
                    $col = self::colunasDoCabecalho($cel);
                    continue;
                }
                $cod = Texto::codigo($cel[$col[0]] ?? '');
                if ($cod === '') {
                    continue;
                }
                $tri = [self::texto($cel[$col[1]] ?? ''), self::texto($cel[$col[2]] ?? ''), self::texto($cel[$col[3]] ?? '')];
                $atual = $codigos[$cod] ?? ['', '', ''];
                for ($j = 0; $j < 3; $j++) {
                    if ($atual[$j] === '' && $tri[$j] !== '') {
                        $atual[$j] = $tri[$j];
                    }
                }
                $codigos[$cod] = $atual;
            }
            if ($col !== null) {
                return array_filter($codigos, static fn ($x) => $x[0] !== '' || $x[1] !== '' || $x[2] !== '');
            }
        }
        throw new XlsxErro('sem_colunas');
    }

    /**
     * Linhas de um CSV (separador ; , ou tabulação; UTF-8 ou Windows-1252), no mesmo formato do leitor de Excel.
     * @return Generator<int, array<int, string>>
     */
    private static function linhasCsv(string $arquivo): Generator
    {
        $bruto = (string) file_get_contents($arquivo);
        if (str_starts_with($bruto, "\xEF\xBB\xBF")) {
            $bruto = substr($bruto, 3);
        }
        if (!mb_check_encoding($bruto, 'UTF-8')) {
            $bruto = mb_convert_encoding($bruto, 'UTF-8', 'Windows-1252');
        }
        $primeira = (string) strtok($bruto, "\n");
        $cont = [';' => substr_count($primeira, ';'), ',' => substr_count($primeira, ','), "\t" => substr_count($primeira, "\t")];
        arsort($cont);
        $sep = (string) array_key_first($cont);
        $h = fopen('php://temp', 'r+');
        fwrite($h, $bruto);
        rewind($h);
        $n = 0;
        while (($r = fgetcsv($h, 0, $sep, '"', '')) !== false) {
            $n++;
            if ($r === [null]) {
                continue; // linha em branco
            }
            yield $n => $r;
        }
        fclose($h);
    }

    private static function texto(mixed $v): string
    {
        if ($v === null || is_bool($v)) {
            return '';
        }
        $s = Texto::apara((string) $v);
        return Texto::vazio($s) ? '' : $s;
    }
}
