<?php

namespace Src\Infra\Google;

use Google\Service\Docs;
use Google\Service\Docs\Request;
use Google\Service\Docs\BatchUpdateDocumentRequest;

final class DocTemplateRenderer
{
    private DriveClient $gc;
    private \Google\Service\Drive $drive;
    private \Google\Service\Docs $docs;

    public function __construct(DriveClient $gc)
    {
        $this->gc    = $gc;
        $this->drive = $gc->drive();
        $this->docs  = $gc->docs();
    }

    /** Orquestra: valida, copia o template, aplica substituições e retorna metadados */
    public function renderTemplate(
        string $templateId,
        string $destParentId,
        string $newName,
        ?string $subpath,
        array $replacements
    ): array {
        // valida template
        $tpl = $this->gc->getFile($templateId, 'id,name,mimeType');

        if (($tpl['mimeType'] ?? '') !== 'application/vnd.google-apps.document') {
            throw new \InvalidArgumentException('O template precisa ser um Google Document. ' . $tpl['mimeType']);
        }


        // garante pasta destino
        $finalParentId = $this->gc->ensureFolderChain($destParentId, $subpath);

        // copia
        $copied = $this->gc->copyFile($templateId, $newName, $finalParentId, 'id,name,parents,webViewLink');
        $docId  = $copied['id'];

        // aplica substituições (simples + listas + blocos)
        $this->applyReplacements($docId, $replacements);

        // retorna metadados finais
        return $this->gc->getFile($docId, 'id,name,mimeType,parents,webViewLink,webContentLink');
    }

    /** Aplica todas as substituições no documento (Docs API) */
    public function applyReplacements(string $docId, array $replacements): void
    {
        $requests = $this->buildReplaceRequestsWithLoops($docId, $replacements);
        if (!$requests) return;

        // Fatiar em blocos para evitar payloads enormes
        $chunks = array_chunk($requests, 200); // ajuste fino conforme uso

        foreach ($chunks as $ix => $chunk) {
            $batch = new BatchUpdateDocumentRequest(['requests' => $chunk]);
            $this->callWithRetry(function () use ($docId, $batch) {
                $this->docs->documents->batchUpdate($docId, $batch);
            });
        }

        // >>> PASSADA DE ESTILOS <<<
        $this->applyStyleMarkersAndCleanup($docId);
    }

    /** Retry simples com backoff exponencial para 429/5xx */
    private function callWithRetry(callable $fn, int $maxAttempts = 5): void
    {
        $delayMs = 250;
        $attempt = 0;
        while (true) {
            try {
                $fn();
                return;
            } catch (\Google\Service\Exception $e) {
                $code = $e->getCode();
                if ($code === 429 || ($code >= 500 && $code <= 599)) {
                    if (++$attempt >= $maxAttempts) throw $e;
                    usleep($delayMs * 1000);
                    $delayMs = min(4000, $delayMs * 2);
                    continue;
                }
                throw $e;
            }
        }
    }


    /** Constrói os Requests: blocos {#K}…{/K} ou {{#K}}…{{/K}}, listas e simples {{CHAVE}} */
    private function buildReplaceRequestsWithLoops(string $docId, array $replacements): array
    {
        $reqs = [];

        // Texto "flat" do Docs (body/headers/footers/tables/footnotes)
        $full = $this->getDocPlainText($docId);

        // --- 1) BLOcos com array de objetos (com suporte a loops aninhados) ---
        foreach ($replacements as $key => $val) {
            if (!is_array($val) || empty($val) || !isset($val[0]) || !is_array($val[0])) continue;

            // aceitamos {#k}…{/k} e {{#k}}…{{/k}}
            $patterns = [
                ['open' => '{#' . $key . '}',   'close' => '{/' . $key . '}'],
                ['open' => '{{#' . $key . '}}', 'close' => '{{/' . $key . '}}'],
            ];

            foreach ($patterns as $p) {
                $open  = $p['open'];
                $close = $p['close'];

                $searchPos = 0;
                while (true) {
                    $start = strpos($full, $open, $searchPos);
                    if ($start === false) break;
                    $end   = strpos($full, $close, $start + strlen($open));
                    if ($end === false) break;

                    $inner = substr($full, $start + strlen($open), $end - ($start + strlen($open)));
                    $wholeBlock = $open . $inner . $close;

                    // Renderiza o conteúdo do bloco com suporte a loops aninhados
                    $rendered = [];
                    $i = 1;
                    foreach ($val as $row) {
                        // contexto do item + herda chaves globais
                        $ctx = $this->mergeContexts($replacements, $row, $i);
                        // resolve loops aninhados dentro do fragmento 'inner' e substitui escalares
                        $piece = $this->renderLoopsInFragment($inner, $ctx);
                        $rendered[] = trim($piece);
                        $i++;
                    }
                    $finalText = implode("\n", $rendered);

                    $reqs[] = new Request([
                        'replaceAllText' => [
                            'containsText' => ['text' => $wholeBlock, 'matchCase' => false],
                            'replaceText'  => $finalText,
                        ]
                    ]);

                    // Avança além do bloco encontrado para procurar o próximo
                    $searchPos = $end + strlen($close);
                }
            }
        }

        // --- 2) listas simples: CHAVE => ["A","B"] => "1) A;\n2) B;" ---
        foreach ($replacements as $key => $val) {
            if (!is_array($val)) continue;
            if (isset($val[0]) && is_array($val[0])) continue; // já tratado acima (loops)

            $onlyScalars = $this->isListOfScalars($val);
            if (!$onlyScalars) continue;

            $lines = [];
            $n = 1;
            foreach ($val as $s) {
                $s = trim((string)$s);
                if ($s !== '') $lines[] = ($n++) . ') ' . $s . ';';
            }
            $listText = implode("\n", $lines);

            $reqs[] = new Request([
                'replaceAllText' => [
                    'containsText' => ['text' => '{{' . $key . '}}', 'matchCase' => false],
                    'replaceText'  => $listText,
                ]
            ]);
        }

        // --- 3) substituições simples ---
        foreach ($replacements as $key => $val) {
            if (is_array($val)) continue;
            $reqs[] = new Request([
                'replaceAllText' => [
                    'containsText' => ['text' => '{{' . $key . '}}', 'matchCase' => false],
                    'replaceText'  => (string)$val,
                ]
            ]);
        }

        return $reqs;
    }


    /** Renderiza loops aninhados dentro de um FRAGMENTO de template + substitui escalares. */
    private function renderLoopsInFragment(string $fragment, array $ctx): string
    {
        // resolve {#k}...{/k} e {{#k}}...{{/k}} recursivamente
        $patterns = [
            '/\{\#(\w+)\}(.*?)\{\/\1\}/s',
            '/\{\{\#(\w+)\}\}(.*?)\{\{\/\1\}\}/s',
        ];

        $replaced = true;
        while ($replaced) {
            $replaced = false;
            foreach ($patterns as $rx) {
                $fragment = preg_replace_callback($rx, function ($m) use ($ctx, &$replaced) {
                    $key   = $m[1];
                    $inner = $m[2];
                    $val   = $ctx[$key] ?? null;

                    if (!is_array($val) || empty($val)) {
                        $replaced = true;
                        return ''; // sem dados, apaga bloco
                    }

                    $out = [];
                    $i = 1;
                    foreach ($val as $row) {
                        $childCtx = $this->mergeContexts($ctx, $row, $i);
                        $renderedInner = $this->renderLoopsInFragment($inner, $childCtx); // recursão
                        $out[] = $this->replaceScalars($renderedInner, $childCtx);
                        $i++;
                    }
                    $replaced = true;
                    return implode("\n", $out);
                }, $fragment);
            }
        }

        // substitui escalares restantes
        return $this->replaceScalars($fragment, $ctx);
    }



    /** Expande loops (inclusive aninhados) no texto do template (string), com dados do array $ctx */
    private function renderLoopsToString(string $tpl, array $ctx): string
    {
        // Aceita {#k}...{/k} e {{#k}}...{{/k}}
        $patterns = [
            '/\{\#(\w+)\}(.*?)\{\/\1\}/s',         // {#k}...{/k}
            '/\{\{\#(\w+)\}\}(.*?)\{\{\/\1\}\}/s', // {{#k}}...{{/k}}
        ];

        $replaced = true;
        while ($replaced) {
            $replaced = false;
            foreach ($patterns as $rx) {
                $tpl = preg_replace_callback($rx, function ($m) use ($ctx, &$replaced) {
                    $key = $m[1];
                    $inner = $m[2];
                    $val = $ctx[$key] ?? null;

                    if (!is_array($val) || empty($val)) {
                        // Sem dados: apaga bloco
                        $replaced = true;
                        return '';
                    }

                    // Se for array de objetos/arrays: renderiza cada item
                    $out = [];
                    $i = 1;
                    foreach ($val as $row) {
                        // Contexto filho: permite acessar pai + item atual
                        $childCtx = $this->mergeContexts($ctx, $row, $i);
                        // Recursão: resolve loops aninhados primeiro
                        $renderedInner = $this->renderLoopsToString($inner, $childCtx);
                        // Depois faz substituições simples no resultado
                        $out[] = $this->replaceScalars($renderedInner, $childCtx);
                        $i++;
                    }
                    $replaced = true;
                    return implode("\n", $out);
                }, $tpl);
            }
        }

        // Ao final, substitui quaisquer escalares remanescentes
        $tpl = $this->replaceScalars($tpl, $ctx);

        return $tpl;
    }

    /** Junta contexto do pai com o do item atual e injeta {{index}} */
    private function mergeContexts(array $parent, $row, int $index): array
    {
        $rowArr = is_array($row) ? $row : (array)$row;
        $ctx = $parent;
        $ctx['index'] = $index;
        // row sobrescreve chaves homônimas
        foreach ($rowArr as $k => $v) $ctx[$k] = $v;
        return $ctx;
    }


    /** Substitui apenas escalares: {chave} e {{chave}} */
    private function replaceScalars(string $s, array $ctx): string
    {
        $s = preg_replace_callback('/\{\{(\w+)\}\}/', function ($m) use ($ctx) {
            $k = $m[1];
            $v = $ctx[$k] ?? '';
            return is_array($v) ? '' : (string)$v;
        }, $s);

        $s = preg_replace_callback('/\{(\w+)\}/', function ($m) use ($ctx) {
            $k = $m[1];
            $v = $ctx[$k] ?? '';
            return is_array($v) ? '' : (string)$v;
        }, $s);

        return $s;
    }



    /** Texto "flat" do Docs suficiente para localizar blocos */
    /** Texto "flat" do Docs: percorre recursivamente parágrafos, tabelas, headers/footers/footnotes */
    private function getDocPlainText(string $docId): string
    {
        $doc = $this->docs->documents->get($docId);
        $out = '';

        $appendParagraph = static function ($paragraph) use (&$out) {
            if (!$paragraph) return;
            foreach ((array)$paragraph->getElements() as $el) {
                $tr = $el->getTextRun();
                if ($tr && $tr->getContent() !== null) $out .= $tr->getContent();
            }
            // Docs normalmente termina parágrafos com '\n'; garantimos quebra:
            if ($out === '' || substr($out, -1) !== "\n") $out .= "\n";
        };

        $walkElements = null;
        $walkElements = function ($elements) use (&$walkElements, $appendParagraph) {
            foreach ((array)$elements as $se) {
                if ($p = $se->getParagraph()) {
                    $appendParagraph($p);
                } elseif ($t = $se->getTable()) {
                    foreach ((array)$t->getTableRows() as $row) {
                        foreach ((array)$row->getTableCells() as $cell) {
                            $walkElements($cell->getContent());
                        }
                    }
                } elseif ($toc = $se->getTableOfContents()) {
                    $walkElements($toc->getContent());
                } elseif ($sb = $se->getSectionBreak()) {
                    // ignora
                } else {
                    // noop
                }
            }
        };

        // Body
        if ($doc->getBody()) $walkElements($doc->getBody()->getContent());

        // Headers/Footers
        foreach ((array)$doc->getHeaders() as $header) {
            $walkElements($header->getContent());
        }
        foreach ((array)$doc->getFooters() as $footer) {
            $walkElements($footer->getContent());
        }

        // Footnotes
        foreach ((array)$doc->getFootnotes() as $fn) {
            $walkElements($fn->getContent());
        }

        return $out;
    }

    private function isListOfScalars(array $arr): bool
    {
        foreach ($arr as $v) {
            if (is_array($v) || is_object($v)) return false;
        }
        return true;
    }

    private function objToArray($gObj): array
    {
        return json_decode(json_encode($gObj), true);
    }

    /** Aplica estilos marcados por ⟦B⟧ ... ⟦/B⟧, ⟦I⟧ ... ⟦/I⟧, ⟦U⟧ ... ⟦/U⟧ e remove marcadores */
    private function applyStyleMarkersAndCleanup(string $docId): void
    {
        $doc = $this->docs->documents->get($docId);
        $requests = [];

        // índices lineares do Docs (startIndex/endIndex)
        $text = '';
        $map = []; // cada char do texto -> (startIndex, endIndex)
        $this->buildIndexMap($doc, $text, $map);

        // Procura pares por tipo
        $apply = function (string $open, string $close, callable $styler) use ($text, $map, &$requests) {
            $pos = 0;
            while (true) {
                $a = mb_strpos($text, $open, $pos);
                if ($a === false) break;
                $b = mb_strpos($text, $close, $a + mb_strlen($open));
                if ($b === false) break;

                // Intervalo de conteúdo interno (exclui marcadores)
                $contentStart = $a + mb_strlen($open);
                $contentEnd   = $b;

                // Ignora vazio
                if ($contentEnd > $contentStart) {
                    $rangeStart = $map[$contentStart][0];
                    $rangeEnd   = $map[$contentEnd - 1][1];

                    $requests[] = new Request([
                        'updateTextStyle' => [
                            'range' => ['startIndex' => $rangeStart, 'endIndex' => $rangeEnd],
                            'textStyle' => $styler([]),
                            'fields' => implode(',', array_keys($styler([])))
                        ]
                    ]);
                }

                // Remove CLOSE
                $closeStart = $map[$b][0];
                $closeEnd   = $map[$b + mb_strlen($close) - 1][1];
                $requests[] = new Request(['deleteContentRange' => ['range' => ['startIndex' => $closeStart, 'endIndex' => $closeEnd]]]);

                // Remove OPEN
                $openStart = $map[$a][0];
                $openEnd   = $map[$a + mb_strlen($open) - 1][1];
                $requests[] = new Request(['deleteContentRange' => ['range' => ['startIndex' => $openStart, 'endIndex' => $openEnd]]]);

                // Avança
                $pos = $a; // após deletes, índices mudam, mas seguimos em lotes no final
            }
        };

        // Define marcadores e estilos
        $apply('⟦B⟧', '⟦/B⟧', fn($s) => ['bold' => true] + $s);
        $apply('⟦I⟧', '⟦/I⟧', fn($s) => ['italic' => true] + $s);
        $apply('⟦U⟧', '⟦/U⟧', fn($s) => ['underline' => true] + $s);

        if ($requests) {
            // Faça em lotes com retry/backoff que você já tem
            $chunks = array_chunk($requests, 200);
            foreach ($chunks as $chunk) {
                $this->callWithRetry(function () use ($docId, $chunk) {
                    $this->docs->documents->batchUpdate($docId, new BatchUpdateDocumentRequest(['requests' => $chunk]));
                });
            }
        }
    }

    /** Constrói um mapa (índice linear do texto -> índices Doc start/end) */
    private function buildIndexMap($doc, string &$text, array &$map): void
    {
        $text = '';
        $map = [];

        $appendRun = function ($content, $startIndex, $endIndex) use (&$text, &$map) {
            $len = mb_strlen($content);
            for ($i = 0; $i < $len; $i++) {
                $text .= mb_substr($content, $i, 1);
                $map[mb_strlen($text) - 1] = [$startIndex + $i, $startIndex + $i + 1];
            }
        };

        // $appendRun(string $content, int $startIndex, int $endIndex)

        $visit = function ($elements) use (&$visit, $appendRun) {
            foreach ((array) $elements as $se) {

                // Parágrafo (inclui texto normal e itens de lista)
                if (($p = $se->getParagraph())) {
                    foreach ((array) $p->getElements() as $el) {
                        // $el é ParagraphElement; os índices vêm dele:
                        $startIndex = $el->getStartIndex();
                        $endIndex   = $el->getEndIndex();

                        // Conteúdo de texto (TextRun) é opcional
                        if ($tr = $el->getTextRun()) {
                            $content = $tr->getContent() ?? '';

                            // Garante que só chamamos o callback quando tudo está disponível
                            if ($content !== '' && $startIndex !== null && $endIndex !== null) {
                                $appendRun($content, $startIndex, $endIndex);
                            }
                        }

                        // (Opcional) outros tipos de ParagraphElement que também podem aparecer:
                        // if ($el->getFootnoteReference()) { ... }
                        // if ($el->getAutoText()) { ... }
                        // if ($el->getEquation()) { ... }
                        // etc.
                    }
                }

                // Tabela
                elseif ($t = $se->getTable()) {
                    foreach ((array) $t->getTableRows() as $row) {
                        foreach ((array) $row->getTableCells() as $cell) {
                            $visit($cell->getContent());
                        }
                    }
                }

                // Sumário (Table of Contents)
                elseif ($toc = $se->getTableOfContents()) {
                    $visit($toc->getContent());
                }

                // (Opcional) Quebra de seção, imagens e outros tipos podem ser ignorados ou tratados aqui
                // elseif ($se->getSectionBreak()) { ... }
                // elseif ($se->getInlineObjectElement()) { ... }
                // elseif ($se->getPageBreak()) { ... }
            }
        };


        if ($doc->getBody()) $visit($doc->getBody()->getContent());
        foreach ((array)$doc->getHeaders() as $header) $visit($header->getContent());
        foreach ((array)$doc->getFooters() as $footer) $visit($footer->getContent());
        foreach ((array)$doc->getFootnotes() as $fn)   $visit($fn->getContent());
    }
}
