<?php
// =============================================================
// DASHBOARD/LIB/XLSX — минимальный писатель/читатель XLSX
// =============================================================
// Без composer-зависимостей: xlsx = zip с XML (SpreadsheetML), хватает
// ext-zip + SimpleXML. Поддерживается ровно то, что нужно для
// экспорта/импорта табличных настроек: один лист, строки/числа.
// Писатель кладёт строки как inline strings (без sharedStrings),
// читатель понимает оба варианта (Excel при сохранении использует
// sharedStrings) + inlineStr/str/b/n.

/**
 * Пишет XLSX-файл с одним листом.
 *
 * @param string $file      путь к результату (перезаписывается)
 * @param array  $rows      массив строк; строка = массив скаляров
 *                          (string|int|float|bool|null, null = пустая ячейка)
 * @param string $sheetName имя листа
 * @param array  $colWidths ширины колонок в символах (по индексу с 0), опционально
 */
function xlsx_write(string $file, array $rows, string $sheetName = 'Sheet1', array $colWidths = []): bool
{
    $zip = new ZipArchive();
    if ($zip->open($file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
        return false;
    }

    $esc = fn($s) => htmlspecialchars((string) $s, ENT_XML1 | ENT_QUOTES, 'UTF-8');

    // --- sheet1.xml ---
    $sheetXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">';
    if (!empty($colWidths)) {
        $sheetXml .= '<cols>';
        foreach ($colWidths as $i => $w) {
            $n = $i + 1;
            $sheetXml .= '<col min="' . $n . '" max="' . $n . '" width="' . (float) $w . '" customWidth="1"/>';
        }
        $sheetXml .= '</cols>';
    }
    $sheetXml .= '<sheetData>';
    foreach ($rows as $r => $row) {
        $sheetXml .= '<row r="' . ($r + 1) . '">';
        $c = 0;
        foreach ($row as $value) {
            $ref = xlsx_col_letter($c) . ($r + 1);
            $c++;
            if ($value === null || $value === '') {
                continue; // пустая ячейка не пишется
            }
            if (is_bool($value)) {
                $value = $value ? 1 : 0;
            }
            if ((is_int($value) || is_float($value)) && is_finite((float) $value)) {
                // Числа — числовыми ячейками, чтобы Excel сортировал/считал их как числа
                $sheetXml .= '<c r="' . $ref . '"><v>' . $value . '</v></c>';
            } else {
                $sheetXml .= '<c r="' . $ref . '" t="inlineStr"><is><t xml:space="preserve">'
                    . $esc($value) . '</t></is></c>';
            }
        }
        $sheetXml .= '</row>';
    }
    $sheetXml .= '</sheetData></worksheet>';

    $zip->addFromString('[Content_Types].xml',
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
        . '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
        . '<Default Extension="xml" ContentType="application/xml"/>'
        . '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
        . '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
        . '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
        . '</Types>');

    $zip->addFromString('_rels/.rels',
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
        . '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
        . '</Relationships>');

    $zip->addFromString('xl/workbook.xml',
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
        . ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
        . '<sheets><sheet name="' . $esc($sheetName) . '" sheetId="1" r:id="rId1"/></sheets>'
        . '</workbook>');

    $zip->addFromString('xl/_rels/workbook.xml.rels',
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
        . '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
        . '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
        . '</Relationships>');

    // Минимальный styles.xml — Excel требует его наличия с базовыми наборами
    $zip->addFromString('xl/styles.xml',
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        . '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
        . '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
        . '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
        . '<borders count="1"><border/></borders>'
        . '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
        . '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
        . '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
        . '</styleSheet>');

    $zip->addFromString('xl/worksheets/sheet1.xml', $sheetXml);

    return $zip->close();
}

/**
 * Читает первый лист XLSX. Возвращает массив строк (каждая — плотный массив
 * строковых значений с '' в пропущенных ячейках) или null, если файл не
 * читается как xlsx.
 */
function xlsx_read_rows(string $file): ?array
{
    $zip = new ZipArchive();
    if ($zip->open($file) !== true) {
        return null;
    }

    $mainNs = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
    $relNs  = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';

    $loadXml = function (string $name) use ($zip) {
        $data = $zip->getFromName($name);
        if ($data === false) return null;
        return simplexml_load_string($data, 'SimpleXMLElement', LIBXML_NONET);
    };

    // Путь к первому листу — через workbook.xml + rels (порядок вкладок,
    // а не порядок файлов в архиве). Fallback — каноничный sheet1.xml.
    $sheetPath = 'xl/worksheets/sheet1.xml';
    $workbook  = $loadXml('xl/workbook.xml');
    if ($workbook !== null) {
        $sheets = $workbook->children($mainNs)->sheets ?? null;
        $firstSheet = $sheets ? ($sheets->children($mainNs)->sheet[0] ?? null) : null;
        $rid = $firstSheet ? (string) ($firstSheet->attributes($relNs)['id'] ?? '') : '';
        if ($rid !== '') {
            $rels = $loadXml('xl/_rels/workbook.xml.rels');
            if ($rels !== null) {
                foreach ($rels->children('http://schemas.openxmlformats.org/package/2006/relationships') as $rel) {
                    $relAttrs = $rel->attributes();
                    if ((string) ($relAttrs['Id'] ?? '') === $rid) {
                        $target = (string) ($relAttrs['Target'] ?? '');
                        $sheetPath = str_starts_with($target, '/')
                            ? ltrim($target, '/')
                            : 'xl/' . $target;
                        break;
                    }
                }
            }
        }
    }

    // sharedStrings (необязателен; собираем текст включая rich-text runs)
    $shared = [];
    $ss = $loadXml('xl/sharedStrings.xml');
    if ($ss !== null) {
        foreach ($ss->children($mainNs) as $si) {
            $shared[] = xlsx_collect_text($si, $mainNs);
        }
    }

    $sheet = $loadXml($sheetPath);
    if ($sheet === null) {
        $zip->close();
        return null;
    }

    $rows = [];
    $sheetData = $sheet->children($mainNs)->sheetData ?? null;
    if ($sheetData !== null) {
        foreach ($sheetData->children($mainNs) as $row) {
            $cells  = [];
            $maxCol = -1;
            $autoCol = 0; // если у ячейки нет r-атрибута — идём по порядку
            foreach ($row->children($mainNs) as $c) {
                // После children($ns) атрибуты без префикса доступны только
                // через attributes() — $c['r'] возвращает null.
                $attrs = $c->attributes();
                $ref = (string) ($attrs['r'] ?? '');
                $col = $ref !== '' ? xlsx_col_index($ref) : $autoCol;
                $autoCol = $col + 1;

                $type     = (string) ($attrs['t'] ?? '');
                $children = $c->children($mainNs);
                $v        = isset($children->v) ? (string) $children->v : '';

                if ($type === 's') {
                    $value = $shared[(int) $v] ?? '';
                } elseif ($type === 'inlineStr') {
                    $value = isset($children->is) ? xlsx_collect_text($children->is, $mainNs) : '';
                } elseif ($type === 'b') {
                    $value = $v === '1' ? '1' : '0';
                } else { // n, str, e, без типа
                    $value = $v;
                }

                $cells[$col] = $value;
                if ($col > $maxCol) $maxCol = $col;
            }
            $dense = [];
            for ($i = 0; $i <= $maxCol; $i++) {
                $dense[] = $cells[$i] ?? '';
            }
            $rows[] = $dense;
        }
    }

    $zip->close();
    return $rows;
}

/** Рекурсивно собирает текст всех <t> внутри элемента (plain + rich-text). */
function xlsx_collect_text(SimpleXMLElement $el, string $ns): string
{
    $out = '';
    foreach ($el->children($ns) as $child) {
        if ($child->getName() === 't') {
            $out .= (string) $child;
        } else {
            $out .= xlsx_collect_text($child, $ns);
        }
    }
    return $out;
}

/** Индекс колонки (0-based) → буквенное имя: 0 → A, 26 → AA. */
function xlsx_col_letter(int $index): string
{
    $letter = '';
    $index++;
    while ($index > 0) {
        $mod    = ($index - 1) % 26;
        $letter = chr(65 + $mod) . $letter;
        $index  = intdiv($index - $mod - 1, 26);
    }
    return $letter;
}

/** Ссылка ячейки "BC12" → индекс колонки (0-based). */
function xlsx_col_index(string $ref): int
{
    $col = 0;
    $len = strlen($ref);
    for ($i = 0; $i < $len; $i++) {
        $ch = ord($ref[$i]);
        if ($ch < 65 || $ch > 90) break; // до первой цифры
        $col = $col * 26 + ($ch - 64);
    }
    return $col - 1;
}
