<?php
declare(strict_types=1);

/**
 * Leitor de planilhas .xlsx / .xlsm sem bibliotecas externas.
 *
 * Lê as abas em fluxo (XMLReader), uma linha por vez, sem carregar a planilha inteira na
 * memória. Serve tanto para os arquivos dos aliados (alguns milhares de linhas) quanto para a
 * tabela de preços, que tem abas de dezenas de MB.
 *
 * Valores devolvidos por célula:
 *  - número  -> int ou float
 *  - texto   -> string
 *  - erro do Excel (#N/A, #N/D...) -> string com o código do erro
 *  - booleano -> bool
 *  - vazia   -> a célula não aparece no array da linha
 */
final class XlsxErro extends RuntimeException {}

final class XlsxLeitor
{
    private const NS_REL = 'relationships';

    private ZipArchive $zip;
    private string $caminho;
    /** @var list<array{nome:string, entrada:string, oculta:bool}> */
    private array $abas = [];
    private string $entradaTextos = '';
    /** @var list<string>|null */
    private ?array $textos = null;
    private int $limiteAba;

    /**
     * @param int $limiteAbaBytes tamanho máximo (descompactado) de cada aba e da lista de textos,
     *                            proteção contra arquivos "bomba" que se expandem demais.
     */
    public function __construct(string $caminho, int $limiteAbaBytes = 150_000_000)
    {
        $real = realpath($caminho);
        if ($real === false || !is_file($real)) {
            throw new XlsxErro('arquivo_inexistente');
        }
        if (!is_readable($real)) {
            throw new XlsxErro('sem_permissao');
        }
        $this->caminho = $real;
        $this->limiteAba = $limiteAbaBytes;

        $zip = new ZipArchive();
        if ($zip->open($real, ZipArchive::RDONLY) !== true) {
            throw new XlsxErro('nao_e_xlsx');
        }
        $this->zip = $zip;

        $workbook = $this->leEntradaPequena('xl/workbook.xml');
        if ($workbook === null) {
            throw new XlsxErro('nao_e_xlsx');
        }
        $rels = $this->leEntradaPequena('xl/_rels/workbook.xml.rels') ?? '';
        $alvos = $this->leRelacionamentos($rels);

        $doc = $this->domSeguro($workbook);
        foreach ($doc->getElementsByTagNameNS('*', 'sheet') as $sheet) {
            /** @var DOMElement $sheet */
            $rid = '';
            foreach ($sheet->attributes as $atr) {
                if ($atr->localName === 'id' && str_contains((string) $atr->namespaceURI, self::NS_REL)) {
                    $rid = $atr->value;
                }
            }
            $entrada = $alvos['ids'][$rid] ?? '';
            if ($entrada === '' || $this->zip->statName($entrada) === false) {
                continue;
            }
            $estado = $sheet->getAttribute('state');
            $this->abas[] = [
                'nome' => $sheet->getAttribute('name'),
                'entrada' => $entrada,
                'oculta' => $estado === 'hidden' || $estado === 'veryHidden',
            ];
        }
        $this->entradaTextos = $alvos['textos'] !== '' ? $alvos['textos'] : 'xl/sharedStrings.xml';
        if (!$this->abas) {
            throw new XlsxErro('sem_abas');
        }
    }

    public function __destruct()
    {
        $this->zip->close();
    }

    /** @return list<array{nome:string, entrada:string, oculta:bool}> */
    public function abas(): array
    {
        return $this->abas;
    }

    /** Índice da aba pelo nome (comparação sem acentos, caixa e separadores), ou -1. */
    public function indiceDaAba(string $nome): int
    {
        $alvo = self::normaliza($nome);
        foreach ($this->abas as $i => $aba) {
            if (self::normaliza($aba['nome']) === $alvo) {
                return $i;
            }
        }
        return -1;
    }

    /**
     * Percorre as linhas de uma aba. Cada item é [número da linha no Excel => [coluna (0 = A) => valor]].
     * Linhas totalmente vazias podem vir como array vazio.
     *
     * @return Generator<int, array<int, int|float|string|bool>>
     */
    public function linhas(int $indiceAba): Generator
    {
        if (!isset($this->abas[$indiceAba])) {
            throw new XlsxErro('aba_inexistente');
        }
        $entrada = $this->abas[$indiceAba]['entrada'];
        $this->confereTamanho($entrada);

        $r = new XMLReader();
        if (!$r->open($this->uri($entrada), null, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE)) {
            throw new XlsxErro('aba_ilegivel');
        }
        $numLinha = 0;
        $celulas = [];
        $col = -1;
        $proximaCol = 0;
        $tipo = 'n';
        $valor = null;
        $textoInline = '';
        $dentroDeLinha = false;

        while (@$r->read()) {
            $no = $r->nodeType;
            if ($no === XMLReader::ELEMENT) {
                switch ($r->localName) {
                    case 'row':
                        $ref = $r->getAttribute('r');
                        $numLinha = ($ref !== null && $ref !== '') ? (int) $ref : $numLinha + 1;
                        $celulas = [];
                        $proximaCol = 0;
                        if ($r->isEmptyElement) {
                            yield $numLinha => [];
                        } else {
                            $dentroDeLinha = true;
                        }
                        break;
                    case 'c':
                        $ref = $r->getAttribute('r');
                        $col = ($ref !== null && $ref !== '') ? self::colunaDaReferencia($ref) : $proximaCol;
                        $proximaCol = $col + 1;
                        $tipo = $r->getAttribute('t') ?? 'n';
                        $valor = null;
                        $textoInline = '';
                        if ($r->isEmptyElement) {
                            $col = -1; // célula só com formatação
                        }
                        break;
                    case 'v':
                        if ($col >= 0) {
                            $valor = $r->readString();
                        }
                        break;
                    case 't':
                        if ($col >= 0 && $tipo === 'inlineStr') {
                            $textoInline .= $r->readString();
                        }
                        break;
                }
            } elseif ($no === XMLReader::END_ELEMENT) {
                $nome = $r->localName;
                if ($nome === 'c' && $col >= 0) {
                    $v = $this->converte($tipo, $valor, $textoInline);
                    if ($v !== null) {
                        $celulas[$col] = $v;
                    }
                    $col = -1;
                } elseif ($nome === 'row' && $dentroDeLinha) {
                    $dentroDeLinha = false;
                    yield $numLinha => $celulas;
                }
            }
        }
        $r->close();
    }

    /** "B12" -> 1; "AA3" -> 26. */
    public static function colunaDaReferencia(string $ref): int
    {
        $n = 0;
        $len = strlen($ref);
        for ($i = 0; $i < $len; $i++) {
            $c = ord($ref[$i]);
            if ($c >= 65 && $c <= 90) {
                $n = $n * 26 + ($c - 64);
            } elseif ($c >= 97 && $c <= 122) {
                $n = $n * 26 + ($c - 96);
            } else {
                break;
            }
        }
        return max(0, $n - 1);
    }

    /** 0 -> "A"; 26 -> "AA". */
    public static function letraDaColuna(int $indice): string
    {
        $s = '';
        $n = $indice + 1;
        while ($n > 0) {
            $m = ($n - 1) % 26;
            $s = chr(65 + $m) . $s;
            $n = intdiv($n - 1, 26);
        }
        return $s;
    }

    /** Nome comparável: sem acentos, maiúsculas, separadores viram um espaço. */
    public static function normaliza(string $s): string
    {
        return Texto::normalizaCabecalho($s);
    }

    // ------------------------------------------------------------------------------------------

    private function converte(string $tipo, ?string $valor, string $textoInline): int|float|string|bool|null
    {
        switch ($tipo) {
            case 's':
                if ($valor === null || $valor === '') {
                    return null;
                }
                $textos = $this->textos();
                return $textos[(int) $valor] ?? '';
            case 'inlineStr':
                return $textoInline;
            case 'str':
                return $valor ?? '';
            case 'b':
                return $valor === '1';
            case 'e':
                return $valor ?? '#N/A';
            case 'd':
                return $valor;
            default:
                if ($valor === null || $valor === '') {
                    return null;
                }
                if (preg_match('/^-?\d{1,15}$/', $valor)) {
                    return (int) $valor;
                }
                if (is_numeric($valor)) {
                    return (float) $valor;
                }
                return $valor;
        }
    }

    /** @return list<string> */
    private function textos(): array
    {
        if ($this->textos !== null) {
            return $this->textos;
        }
        $this->textos = [];
        if ($this->zip->statName($this->entradaTextos) === false) {
            return $this->textos;
        }
        $this->confereTamanho($this->entradaTextos);
        $r = new XMLReader();
        if (!$r->open($this->uri($this->entradaTextos), null, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE)) {
            return $this->textos;
        }
        $atual = null;
        $foneticos = 0; // <rPh>: guia fonética (japonês), não faz parte do texto
        while (@$r->read()) {
            if ($r->nodeType === XMLReader::ELEMENT) {
                $nome = $r->localName;
                if ($nome === 'si') {
                    if ($r->isEmptyElement) {
                        $this->textos[] = '';
                    } else {
                        $atual = '';
                    }
                } elseif ($nome === 'rPh' && !$r->isEmptyElement) {
                    $foneticos++;
                } elseif ($nome === 't' && $atual !== null && $foneticos === 0) {
                    $atual .= $r->readString();
                }
            } elseif ($r->nodeType === XMLReader::END_ELEMENT) {
                $nome = $r->localName;
                if ($nome === 'si') {
                    $this->textos[] = $atual ?? '';
                    $atual = null;
                } elseif ($nome === 'rPh') {
                    $foneticos = max(0, $foneticos - 1);
                }
            }
        }
        $r->close();
        return $this->textos;
    }

    /** @return array{ids: array<string,string>, textos: string} */
    private function leRelacionamentos(string $xml): array
    {
        $ids = [];
        $textos = '';
        if ($xml === '') {
            return ['ids' => $ids, 'textos' => $textos];
        }
        $doc = $this->domSeguro($xml);
        foreach ($doc->getElementsByTagNameNS('*', 'Relationship') as $rel) {
            /** @var DOMElement $rel */
            $alvo = $rel->getAttribute('Target');
            if ($alvo === '' || preg_match('#^[a-z]+://#i', $alvo)) {
                continue; // relacionamento externo
            }
            $entrada = str_starts_with($alvo, '/') ? ltrim($alvo, '/') : self::resolveCaminho('xl/' . $alvo);
            $ids[$rel->getAttribute('Id')] = $entrada;
            if (str_ends_with($rel->getAttribute('Type'), '/sharedStrings')) {
                $textos = $entrada;
            }
        }
        return ['ids' => $ids, 'textos' => $textos];
    }

    private static function resolveCaminho(string $p): string
    {
        $partes = [];
        foreach (explode('/', $p) as $seg) {
            if ($seg === '' || $seg === '.') {
                continue;
            }
            if ($seg === '..') {
                array_pop($partes);
            } else {
                $partes[] = $seg;
            }
        }
        return implode('/', $partes);
    }

    private function leEntradaPequena(string $entrada): ?string
    {
        $st = $this->zip->statName($entrada);
        if ($st === false) {
            return null;
        }
        if ($st['size'] > 20_000_000) {
            throw new XlsxErro('arquivo_grande_demais');
        }
        $s = $this->zip->getFromName($entrada);
        return $s === false ? null : $s;
    }

    private function confereTamanho(string $entrada): void
    {
        $st = $this->zip->statName($entrada);
        if ($st !== false && $st['size'] > $this->limiteAba) {
            throw new XlsxErro('arquivo_grande_demais');
        }
    }

    private function domSeguro(string $xml): DOMDocument
    {
        $doc = new DOMDocument();
        $ok = @$doc->loadXML($xml, LIBXML_NONET | LIBXML_COMPACT);
        if (!$ok) {
            throw new XlsxErro('nao_e_xlsx');
        }
        return $doc;
    }

    private function uri(string $entrada): string
    {
        return 'zip://' . $this->caminho . '#' . $entrada;
    }
}
