<?php

declare(strict_types=1);

namespace App\Libraries;

use App\Models\ChatbotConfigModel;
use App\Models\DocumentChunkModel;
use App\Models\DocumentModel;
use Throwable;
use ZipArchive;

class DocumentProcessor
{
    public function extractText(string $filePath, string $mimeType): string
    {
        $r = $this->extractTextWithLog($filePath, $mimeType);
        return (string) ($r['text'] ?? '');
    }

    public function extractTextWithLog(string $filePath, string $mimeType): array
    {
        $MAX_EXTRACTED_CHARS = 500000;
        $clampText = static function (string $text) use ($MAX_EXTRACTED_CHARS): string {
            if (mb_strlen($text) <= $MAX_EXTRACTED_CHARS) return $text;
            return mb_substr($text, 0, $MAX_EXTRACTED_CHARS);
        };

        $log = [];
        if (!file_exists($filePath)) {
            return ['text' => '', 'log' => ['extractTextWithLog file does not exist: ' . $filePath]];
        }
        $fs = filesize($filePath) ?: 0;
        $log[] = 'file_disk_size=' . $fs . ' mimeType=' . var_export($mimeType, true) . ' path=' . substr($filePath, 0, 180);

        $mimeLower = strtolower($mimeType);

        if ($mimeLower === 'text/plain' || preg_match('/\.txt$/i', $filePath) || preg_match('/\.csv$/i', $filePath)) {
            $content = @file_get_contents($filePath);
            if ($content === false) { return ['text' => '', 'log' => array_merge($log, ['txt file_get_contents failed'])]; }
            $clean = $clampText($this->cleanText($content));
            return ['text' => $clean, 'log' => array_merge($log, ['txt_plain_extracted_chars=' . mb_strlen($clean)])];
        }

        if ($mimeLower === 'application/pdf' || preg_match('/\.pdf$/i', $filePath)) {
            $out = $clampText($this->extractFromPdf($filePath));
            return ['text' => $out, 'log' => array_merge($log, ['pdf_extracted_chars=' . mb_strlen($out)])];
        }

        if (
            $mimeLower === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
            || preg_match('/\.docx$/i', $filePath)
        ) {
            $r = $this->extractFromDocxWithLog($filePath);
            $origText = (string) ($r['text'] ?? '');
            $clamped  = $clampText($origText);
            if ($clamped !== $origText) {
                $r['log'][] = 'clamped_extracted_text orig=' . mb_strlen($origText) . ' -> ' . mb_strlen($clamped);
            }
            $r['text'] = $clamped;
            $r['log'] = array_merge($log, $r['log'] ?? []);
            return $r;
        }

        if (
            $mimeLower === 'application/msword'
            || preg_match('/\.doc$/i', $filePath)
        ) {
            $raw = @file_get_contents($filePath);
            if ($raw === false) {
                return ['text' => '', 'log' => array_merge($log, ['legacy_doc file_get_contents failed'])];
            }
            $clean = $clampText($this->cleanText($raw));
            return ['text' => $clean, 'log' => array_merge($log, ['legacy_doc_raw_clean_chars=' . mb_strlen($clean)])];
        }

        if (preg_match('#^image/#i', $mimeLower) || preg_match('/\.(png|jpe?g|gif|webp|bmp|svg)$/i', $filePath)) {
            return ['text' => '[Image document]', 'log' => array_merge($log, ['image_marker_only'])];
        }

        if (preg_match('#^video/#i', $mimeLower) || preg_match('/\.(mp4|mov|webm|avi|mkv|wmv)$/i', $filePath)) {
            return ['text' => '[Video document]', 'log' => array_merge($log, ['video_marker_only'])];
        }

        return ['text' => '', 'log' => array_merge($log, ['no supported file type extractor matched'])];
    }

    public function extractTextFromUrl(string $url, string $docType): string
    {
        $url = trim($url);
        if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return '';
        }

        $typeLc = strtolower($docType ?: 'link');
        if ($typeLc === 'image') {
            return '[Image URL] Source: ' . $url;
        }
        if ($typeLc === 'video') {
            return '[Video URL] Source: ' . $url . "\nPlatform hints: YouTube, Vimeo, Loom, direct MP4/WebM links are supported for chatbot context.";
        }

        $ch = curl_init($url);
        if ($ch === false) {
            return '[Link URL] Source: ' . $url;
        }
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
        curl_setopt($ch, CURLOPT_TIMEOUT, 25);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
        curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; OutboundCallerBot/1.0; +https://example.com)');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_ENCODING, '');
        $acceptLang = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : 'en-US,en;q=0.9';
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept-Language: ' . $acceptLang]);

        $html = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $ct = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
        $err = curl_error($ch);
        curl_close($ch);

        if ($html === false || $httpCode < 200 || $httpCode >= 400) {
            return '[Link URL] Source: ' . $url . ' (fetch failed: HTTP ' . $httpCode . ' ' . trim($err) . ')';
        }

        if (stripos($ct, 'text/') !== 0 && stripos($ct, 'application/json') === false) {
            return '[Link URL] Source: ' . $url . ' (Content-Type: ' . $ct . ')';
        }

        $text = $this->extractMainTextFromHtml((string) $html, $url);
        if (trim($text) === '') {
            return '[Link URL] Source: ' . $url;
        }
        return "Source URL: {$url}\n\n" . $text;
    }

    public function extractMainTextFromHtml(string $html, string $baseUrl = ''): string
    {
        $html = (string) preg_replace('#<!--.*?-->#s', '', $html);
        $html = (string) preg_replace('#<(script|style|noscript|svg|canvas|form|nav|footer|header|aside)[^>]*>.*?</\1>#si', "\n", $html);
        $html = (string) preg_replace('#<(br|hr|/p|/div|/h[1-6]|/li|/tr|/section|/article|/main)[^>]*>#i', "\n", $html);
        $html = strip_tags($html);
        $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $lines = preg_split('/\r?\n|\r/', $html);
        if ($lines === false) {
            $lines = [];
        }
        $clean = [];
        foreach ($lines as $line) {
            $line = preg_replace('/[ \t]+/', ' ', trim($line));
            if ($line === '') {
                continue;
            }
            if (mb_strlen($line) < 15 && preg_match('/^[\s_\-\.\|\/\\\\,#@\'"!?\(\)\[\]:;]+$/u', $line)) {
                continue;
            }
            $clean[] = $line;
        }
        $joined = implode("\n", $clean);
        $joined = preg_replace('/\n{3,}/', "\n\n", $joined);
        if (mb_strlen($joined) > 30000) {
            $joined = mb_substr($joined, 0, 30000);
        }
        return trim($joined);
    }

    public function chunkText(string $text, int $chunkSize = 800, int $overlap = 100): array
    {
        $text = trim($text);
        if ($text === '') {
            return [];
        }

        $chunkSize = max(50, $chunkSize);
        $overlap   = max(0, $overlap);
        if ($overlap >= $chunkSize) {
            $overlap = (int) floor($chunkSize / 4);
        }

        $text = (string) preg_replace('/\s+/u', ' ', $text);
        $length = mb_strlen($text);

        $MAX_INPUT_CHARS = 200000;
        if ($length > $MAX_INPUT_CHARS) {
            $text = mb_substr($text, 0, $MAX_INPUT_CHARS);
            $length = $MAX_INPUT_CHARS;
        }

        if ($length <= $chunkSize) {
            return [$text];
        }

        $chunks = [];
        $start = 0;
        $maxIterations = 50000;
        $iterations = 0;
        $prevStart = -1;
        $stallCount = 0;

        while ($start < $length) {
            $iterations++;
            if ($iterations > $maxIterations) {
                break;
            }

            if ($start <= $prevStart) {
                $stallCount++;
                if ($stallCount >= 3) {
                    $start = $prevStart + (int) floor($chunkSize / 2);
                    if ($start <= $prevStart) {
                        $start = $prevStart + 1;
                    }
                    $stallCount = 0;
                }
            } else {
                $stallCount = 0;
            }
            $prevStart = $start;

            $end = $start + $chunkSize;
            if ($end > $length) {
                $end = $length;
            }

            $sliceLen = $end - $start;
            if ($sliceLen <= 0) {
                $start++;
                continue;
            }
            $chunk = mb_substr($text, $start, $sliceLen);

            if ($end < $length) {
                $lastSpace = mb_strrpos($chunk, ' ');
                $minBreak  = (int) floor($chunkSize * 0.5);
                if ($lastSpace !== false && $lastSpace > $minBreak && $lastSpace < $sliceLen) {
                    $chunk = mb_substr($chunk, 0, $lastSpace);
                    $end = $start + $lastSpace;
                }
            }

            $chunk = trim((string) $chunk);
            if ($chunk !== '') {
                $chunks[] = $chunk;
            }

            if ($end >= $length) {
                break;
            }

            $nextStart = $end - $overlap;
            if ($nextStart <= $start) {
                $nextStart = $end + 1;
            }
            if ($nextStart < 0) {
                $nextStart = 0;
            }
            $start = $nextStart;
        }

        if ($chunks === [] && $text !== '') {
            $chunks[] = $text;
        }

        return $chunks;
    }

    public function processDocumentId(int $documentId, bool $forceReextract = true): array
    {
        $documentModel = new DocumentModel();
        $doc = $documentModel->find($documentId);
        if ($doc === null) {
            return ['ok' => false, 'message' => 'Document not found.'];
        }

        if (function_exists('set_time_limit')) {
            @set_time_limit(0);
        }
        if (function_exists('ini_set')) {
            @ini_set('max_execution_time', '0');
            @ini_set('memory_limit', '1024M');
        }

        try {
            $documentModel->update($documentId, ['embedding_status' => 'processing']);

            $existingContentText = (string) ($doc['content_text'] ?? '');
            $type = strtolower(trim((string) ($doc['type'] ?? '')));
            $url  = trim((string) ($doc['url'] ?? ''));
            $filePath = trim((string) ($doc['file_path'] ?? ''));
            $mimeType = (string) ($doc['mime_type'] ?? '');

            $contentText = '';
            $extractionSource = 'none';
            $absPath = '';
            $subLog = [];
            if ($filePath !== '') {
                $absPath = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . $filePath;
                $fileExists = file_exists($absPath);
                $subLog[] = 'file_path_column=' . var_export($filePath, true);
                $subLog[] = 'resolved_abs_path=' . var_export($absPath, true) . ' exists=' . ($fileExists ? 'YES' : 'NO') . ' filesize=' . ($fileExists ? (filesize($absPath) ?: 0) : 0);
                if ($fileExists) {
                    if (method_exists($this, 'extractTextWithLog')) {
                        $r = $this->extractTextWithLog($absPath, $mimeType);
                        $contentText = (string) ($r['text'] ?? '');
                        if (!empty($r['log']) && is_array($r['log'])) {
                            foreach ($r['log'] as $l) $subLog[] = $l;
                        }
                    } else {
                        $contentText = $this->extractText($absPath, $mimeType);
                    }
                    $extractionSource = 'file';
                    $subLog[] = 'extractText result chars=' . mb_strlen($contentText) . ' extraction_source=' . $extractionSource;
                }
            }
            if (trim($contentText) === '' && $url !== '') {
                $contentText = $this->extractTextFromUrl($url, $type);
                $extractionSource = 'url';
                $subLog[] = 'fallback_url used url=' . substr($url, 0, 120) . ' chars=' . mb_strlen($contentText);
            }
            if (trim($contentText) === '' && !$forceReextract && trim($existingContentText) !== '') {
                $contentText = $existingContentText;
                $extractionSource = 'existing';
                $subLog[] = 'fallback_existing used chars=' . mb_strlen($contentText);
            }

            $title = trim((string) ($doc['title'] ?? ''));
            $titleHuman = (string) preg_replace('/[\s\-_.]+/u', ' ', $title);
            $titleHuman = trim((string) preg_replace('/\s{2,}/u', ' ', $titleHuman));
            $desc  = trim((string) ($doc['description'] ?? ''));
            $prefixLines = [];
            if ($titleHuman !== '') {
                $prefixLines[] = 'Document title: ' . $titleHuman;
                if ($titleHuman !== $title && $title !== '') {
                    $prefixLines[] = 'Document filename (original): ' . $title;
                }
            } elseif ($title !== '') {
                $prefixLines[] = 'Document title: ' . $title;
            }
            if ($desc  !== '') $prefixLines[] = 'Summary: ' . $desc;
            if ($type  !== '') $prefixLines[] = 'Type: ' . $type;
            if ($url   !== '') $prefixLines[] = 'Source URL: ' . $url;
            $prefix = count($prefixLines) ? implode("\n", $prefixLines) . "\n\n" : '';
            $prefixChars = mb_strlen($prefix);

            $finalText = $prefix . $contentText;
            $finalText = $this->cleanText($finalText);
            $finalLen = mb_strlen($finalText);
            $bodyOnlyLen = $finalLen - $prefixChars;
            if ($bodyOnlyLen < 0) $bodyOnlyLen = 0;

            $subLog[] = 'FINAL prefix_chars=' . $prefixChars . ' body_only_extracted_chars=' . $bodyOnlyLen . ' total_chars=' . $finalLen . ' extraction_source=' . $extractionSource;

            if (trim($finalText) === '') {
                $chunkModel = new DocumentChunkModel();
                try { $chunkModel->where('document_id', $documentId)->delete(); } catch (Throwable $e) {}
                $documentModel->update($documentId, [
                    'content_text'     => null,
                    'embedding_status' => 'trained',
                ]);
                return ['ok' => true, 'message' => 'No extractable content; marked trained.', 'chunks' => 0, 'extracted_chars' => 0, 'extraction_source' => $extractionSource, 'extraction_log' => $subLog];
            }

            $ok = $this->processAndEmbed($documentId, $doc, $finalText);
            $chunkCount = 0;
            try {
                $chunkModel = new DocumentChunkModel();
                $chunkCount = (int) $chunkModel->where('document_id', $documentId)->countAllResults();
            } catch (Throwable $e) {
            }
            $storedText = $finalLen > 500000 ? mb_substr($finalText, 0, 500000) : $finalText;
            $documentModel->update($documentId, [
                'content_text'     => $storedText,
                'embedding_status' => $ok ? 'trained' : 'error',
            ]);
            $logLine = '[' . implode('] [', $subLog) . ']';
            $messageSuffix = '';
            if ($bodyOnlyLen < 50) {
                $messageSuffix = ' ⚠ EXTRACTION PROBLEM — extracted body chars=' . $bodyOnlyLen . ' (prefix only, document content NOT found). If this DOCX was expected to have content, re-save it in Microsoft Word (File → Save As → .docx) and re-upload. Also try opening it in Word to confirm the pages are not protected/restricted view. Extraction log: ' . $logLine;
            }
            return [
                'ok' => $ok,
                'message' => ($ok ? "Trained successfully ({$chunkCount} chunks, {$finalLen} chars, source={$extractionSource}, body-extracted={$bodyOnlyLen} chars)." : 'Training failed.') . $messageSuffix,
                'chunks' => $chunkCount,
                'extracted_chars' => $finalLen,
                'body_extracted_chars' => $bodyOnlyLen,
                'extraction_source' => $extractionSource,
                'extraction_log' => $subLog,
            ];
        } catch (Throwable $e) {
            try {
                $documentModel->update($documentId, ['embedding_status' => 'error']);
            } catch (Throwable $e2) {
            }
            log_message('error', 'processDocumentId(' . $documentId . ') failed: ' . $e->getMessage());
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    public function processAndEmbed(int $documentId, array $documentRow, string $contentText): bool
    {
        $documentModel = new DocumentModel();
        $chunkModel = new DocumentChunkModel();

        try {
            $chunkModel->where('document_id', $documentId)->delete();
        } catch (Throwable $e) {
            log_message('error', 'processAndEmbed delete old chunks: ' . $e->getMessage());
        }

        $MAX_INPUT_CHARS_BEFORE_CHUNK = 300000;
        if (mb_strlen($contentText) > $MAX_INPUT_CHARS_BEFORE_CHUNK) {
            $contentText = mb_substr($contentText, 0, $MAX_INPUT_CHARS_BEFORE_CHUNK);
        }
        $chunks = $this->chunkText($contentText, 800, 100);
        if (count($chunks) === 0) {
            try {
                $documentModel->update($documentId, [
                    'embedding_status' => 'trained',
                ]);
            } catch (Throwable $e) {
                log_message('error', 'processAndEmbed update status (empty chunks): ' . $e->getMessage());
            }
            return true;
        }

        $MAX_CHUNKS_PER_DOC = 200;
        if (count($chunks) > $MAX_CHUNKS_PER_DOC) {
            $everyNth = (int)ceil(count($chunks) / $MAX_CHUNKS_PER_DOC);
            $sampled = [];
            foreach ($chunks as $i => $chunkText) {
                if ($i === 0 || $i === count($chunks) - 1 || ($i % $everyNth) === 0) {
                    $sampled[] = $chunkText;
                }
            }
            if (count($sampled) > $MAX_CHUNKS_PER_DOC) {
                $sampled = array_slice($sampled, 0, $MAX_CHUNKS_PER_DOC);
            }
            $chunks = array_values($sampled);
        }

        try {
            $openAi = new OpenAiService();
        } catch (\Throwable $e) {
            log_message('error', 'processAndEmbed new OpenAiService failed: ' . $e->getMessage());
            $openAi = null;
        }

        $BATCH_SIZE = 48;
        $total = count($chunks);
        $anySucceeded = false;

        for ($offset = 0; $offset < $total; $offset += $BATCH_SIZE) {
            $batch = array_slice($chunks, $offset, $BATCH_SIZE, true);
            $batchIndexMap = array_keys($batch);
            $batchTexts = array_values($batch);

            $embeddings = [];
            if ($openAi !== null) {
                try {
                    $embeddings = $openAi->createEmbeddingBatch($batchTexts);
                } catch (\Throwable $e) {
                    log_message('error', 'createEmbeddingBatch offset=' . $offset . ' failed: ' . $e->getMessage());
                    $embeddings = [];
                }
            }
            if (!is_array($embeddings) || count($embeddings) !== count($batchTexts)) {
                $embeddings = array_fill(0, count($batchTexts), null);
            }

            foreach ($batchTexts as $withinBatchIdx => $chunkText) {
                $globalIndex = (int) ($batchIndexMap[$withinBatchIdx] ?? ($offset + $withinBatchIdx));
                $emb = $embeddings[$withinBatchIdx] ?? null;
                if (is_array($emb) && $emb !== []) {
                    $anySucceeded = true;
                }
                $embeddingJson = is_array($emb) ? json_encode($emb) : null;

                try {
                    $chunkModel->insert([
                        'document_id'  => $documentId,
                        'chunk_index'  => $globalIndex,
                        'content_text' => $chunkText,
                        'embedding'    => $embeddingJson,
                    ]);
                } catch (Throwable $e) {
                    log_message('error', 'chunk insert failed chunk ' . $globalIndex . ': ' . $e->getMessage());
                }
            }
        }

        try {
            $documentModel->update($documentId, [
                'embedding_status' => 'trained',
            ]);
        } catch (Throwable $e) {
            log_message('error', 'processAndEmbed final status update: ' . $e->getMessage());
            return false;
        }

        return true;
    }

    private function extractFromPdf(string $filePath): string
    {
        $content = @file_get_contents($filePath);
        if ($content === false) {
            return '';
        }

        $output = '';
        if (preg_match_all('/BT[\s\S]*?ET/', $content, $matches)) {
            foreach ($matches[0] as $block) {
                if (preg_match_all('/\(([^)]*)\)\s*Tj/', $block, $tj)) {
                    foreach ($tj[1] as $text) {
                        $text = preg_replace('/\\\\x[0-9a-fA-F]{2}/e', 'chr(hexdec("$0"))', $text);
                        $text = preg_replace('/\\\\([0-7]{1,3})/e', 'chr(octdec("$1"))', $text);
                        $text = str_replace(['\\n', '\\r', '\\t', '\\\\'], [' ', ' ', ' ', '\\'], $text);
                        $output .= $text . ' ';
                    }
                }
                if (preg_match_all('/\[(.*?)\]\s*TJ/', $block, $tjArrays)) {
                    foreach ($tjArrays[1] as $arrayContent) {
                        if (preg_match_all('/\(([^)]*)\)/', $arrayContent, $inside)) {
                            foreach ($inside[1] as $text) {
                                $text = preg_replace('/\\\\x[0-9a-fA-F]{2}/e', 'chr(hexdec("$0"))', $text);
                                $text = preg_replace('/\\\\([0-7]{1,3})/e', 'chr(octdec("$1"))', $text);
                                $text = str_replace(['\\n', '\\r', '\\t', '\\\\'], [' ', ' ', ' ', '\\'], $text);
                                $output .= $text;
                            }
                        }
                    }
                    $output .= ' ';
                }
            }
        }

        if (trim($output) === '') {
            $output = $this->cleanText($content);
        }

        return trim((string) preg_replace('/\s+/u', ' ', $output));
    }

    private function extractFromDocx(string $filePath): string
    {
        $r = $this->extractFromDocxWithLog($filePath);
        return (string) ($r['text'] ?? '');
    }

    private function extractFromDocxWithLog(string $filePath): array
    {
        $log = [];
        if (!class_exists(ZipArchive::class)) {
            $raw = @file_get_contents($filePath);
            if ($raw === false) return ['text' => '', 'log' => ['ziparchive_not_available file_get_contents failed']];
            $clean = $this->cleanText($raw);
            return ['text' => $clean, 'log' => ['ziparchive_not_available raw_clean_fallback chars=' . mb_strlen($clean)]];
        }

        $zip = new ZipArchive();
        $zipErrorCode = $zip->open($filePath);
        $zipCodes = [
            ZipArchive::ER_EXISTS => 'ER_EXISTS (file already exists)',
            ZipArchive::ER_INCONS => 'ER_INCONS (zip archive inconsistent)',
            ZipArchive::ER_INVAL => 'ER_INVAL (invalid argument)',
            ZipArchive::ER_MEMORY => 'ER_MEMORY (malloc failure)',
            ZipArchive::ER_NOENT => 'ER_NOENT (no such file)',
            ZipArchive::ER_NOZIP => 'ER_NOZIP (not a zip archive)',
            ZipArchive::ER_OPEN => 'ER_OPEN (cant open file)',
            ZipArchive::ER_READ => 'ER_READ (read error)',
            ZipArchive::ER_SEEK => 'ER_SEEK (seek error)',
        ];
        if ($zipErrorCode !== true) {
            $codeStr = is_int($zipErrorCode) ? (string)$zipErrorCode : var_export($zipErrorCode, true);
            $namedStr = $zipCodes[(int)$zipErrorCode] ?? 'unknown_error_code';
            $log[] = 'zip_open_FAILED code=' . $codeStr . ' name=' . $namedStr;

            $raw = @file_get_contents($filePath);
            $cleanFallback = '';
            if ($raw !== false) {
                $cleanFallback = $this->cleanText($raw);
                $log[] = 'zip_failed_RAW_binary_cleanText_fallback chars=' . mb_strlen($cleanFallback);
            }

            if ($cleanFallback === '' && $raw !== false) {
                $texts = [];
                if (preg_match_all('/<w:t[^>]*>([^<]*)<\/w:t>/i', $raw, $mm1)) {
                    foreach ($mm1[1] as $t) { if (trim($t) !== '') $texts[] = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); }
                }
                if (preg_match_all('/<w:delText[^>]*>([^<]*)<\/w:delText>/i', $raw, $mm2)) {
                    foreach ($mm2[1] as $t) { if (trim($t) !== '') $texts[] = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); }
                }
                if ($texts !== []) {
                    $joined = $this->cleanText(implode(' ', $texts));
                    if (mb_strlen($joined) > mb_strlen($cleanFallback)) {
                        $cleanFallback = $joined;
                        $log[] = 'zip_failed_BINARY_regex_w:t_fallback chars=' . mb_strlen($cleanFallback);
                    }
                }
            }
            return ['text' => $cleanFallback, 'log' => $log];
        }

        $partNames = [];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $st = $zip->statIndex($i);
            if ($st !== false && isset($st['name'])) {
                $partNames[] = (string) $st['name'];
            }
        }
        $log[] = 'zip_opened numFiles=' . $zip->numFiles . ' named_xml_parts_in_word_or_docProps=' . count(array_filter($partNames, static fn($n) => preg_match('#\.xml$#i', $n) && (stripos($n, 'word/') === 0 || stripos($n, 'docProps/') === 0)));

        $textParts = [];
        $scannedParts = 0;
        $domParagraphs = 0;
        $xmlRawConcat = '';
        foreach ($partNames as $name) {
            if (!preg_match('#\.xml$#i', $name)) continue;
            if (stripos($name, 'word/') !== 0 && stripos($name, 'docProps/') !== 0) continue;
            if (stripos($name, 'word/theme/') !== false || stripos($name, 'word/styles.xml') !== false || stripos($name, 'word/settings.xml') !== false || stripos($name, 'word/webSettings.xml') !== false || stripos($name, 'word/fontTable.xml') !== false || stripos($name, 'docProps/app.xml') !== false || stripos($name, 'docProps/core.xml') !== false || stripos($name, 'word/_rels/') !== false) continue;
            $xml = $zip->getFromName($name);
            if ($xml === false || trim((string)$xml) === '') continue;
            $scannedParts++;
            $xmlRawConcat .= $xml . "\n";
            $r = $this->extractDocxXmlPartTextWithLog((string) $xml);
            $extracted = (string) ($r['text'] ?? '');
            if (!empty($r['log']) && is_array($r['log'])) foreach ($r['log'] as $l) $log[] = basename($name) . ': ' . $l;
            $domParagraphs += (int) ($r['paragraphs_found'] ?? 0);
            if ($extracted !== '') $textParts[] = '<!-- ' . $name . " -->\n" . $extracted;
        }
        $log[] = 'scanned_xml_content_parts=' . $scannedParts . ' dom_paragraph_nodes_total=' . $domParagraphs . ' parts_with_extractable_text=' . count($textParts);

        $zip->close();

        $raw = implode("\n\n", $textParts);
        $raw = (string) preg_replace('/\n{3,}/', "\n\n", $raw);
        $clean = trim($raw);
        $log[] = 'PRIMARY_dom_extract chars=' . mb_strlen($clean);

        $tier1RegexChars = 0;
        $tier2RegexChars = 0;
        $tier3RawChars = 0;
        if (mb_strlen($clean) < 200 && $xmlRawConcat !== '') {
            $regexTexts = [];
            if (preg_match_all('/<w:t\b[^>]*>([^<]*)<\/w:t>/i', $xmlRawConcat, $mmT)) {
                foreach ($mmT[1] as $t) {
                    $d = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    if (trim($d) !== '') $regexTexts[] = $d;
                }
            }
            if (preg_match_all('/<w:delText\b[^>]*>([^<]*)<\/w:delText>/i', $xmlRawConcat, $mmD)) {
                foreach ($mmD[1] as $t) {
                    $d = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    if (trim($d) !== '') $regexTexts[] = $d;
                }
            }
            if (preg_match_all('/<a:t\b[^>]*>([^<]*)<\/a:t>/i', $xmlRawConcat, $mmA)) {
                foreach ($mmA[1] as $t) {
                    $d = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    if (trim($d) !== '') $regexTexts[] = $d;
                }
            }
            if ($regexTexts !== []) {
                $joined = $this->cleanText(implode(' ', $regexTexts));
                $tier1RegexChars = mb_strlen($joined);
                if ($tier1RegexChars > mb_strlen($clean)) {
                    $clean = $joined;
                    $log[] = 'FALLBACK TIER 1 WON (regex w:t/delText/a:t over ALL concatenated xml contents) chars=' . $tier1RegexChars;
                } else {
                    $log[] = 'fallback_tier1 (regex w:t) chars=' . $tier1RegexChars . ' → KEPT dom primary';
                }
            }
        }

        if (mb_strlen($clean) < 80) {
            $fallback = @file_get_contents($filePath);
            if ($fallback !== false) {
                $wTexts2 = [];
                if (preg_match_all('/<w:t\b[^>]*>([^<]*)<\/w:t>/i', $fallback, $mmT2)) {
                    foreach ($mmT2[1] as $t2) {
                        $d2 = html_entity_decode($t2, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                        if (trim($d2) !== '') $wTexts2[] = $d2;
                    }
                }
                if (preg_match_all('/<w:delText\b[^>]*>([^<]*)<\/w:delText>/i', $fallback, $mmD2)) {
                    foreach ($mmD2[1] as $t2) {
                        $d2 = html_entity_decode($t2, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                        if (trim($d2) !== '') $wTexts2[] = $d2;
                    }
                }
                if ($wTexts2 !== []) {
                    $joined2 = $this->cleanText(implode(' ', $wTexts2));
                    $tier2RegexChars = mb_strlen($joined2);
                    if ($tier2RegexChars > mb_strlen($clean)) {
                        $clean = $joined2;
                        $log[] = 'FALLBACK TIER 2 WON (raw binary regex w:t over ENTIRE ZIP file as single blob) chars=' . $tier2RegexChars;
                    } else {
                        $log[] = 'fallback_tier2 (raw blob regex w:t) chars=' . $tier2RegexChars . ' → no improvement';
                    }
                }
            }
        }

        if (mb_strlen($clean) < 50) {
            $fallback = @file_get_contents($filePath);
            if ($fallback !== false) {
                $rawClean = $this->cleanText($fallback);
                $tier3RawChars = mb_strlen($rawClean);
                if ($tier3RawChars > mb_strlen($clean) + 200) {
                    $clean = $rawClean;
                    $log[] = 'FALLBACK TIER 3 WON (raw cleanText over ENTIRE docx binary) chars=' . $tier3RawChars;
                } else {
                    $log[] = 'fallback_tier3 (raw cleanText) chars=' . $tier3RawChars . ' → no improvement over current ' . mb_strlen($clean);
                }
            }
        }

        $log[] = 'FINAL docx_extract chars=' . mb_strlen($clean);
        return ['text' => $clean, 'log' => $log, 'paragraphs_found' => $domParagraphs];
    }

    private function extractDocxXmlPartText(string $xml): string
    {
        $r = $this->extractDocxXmlPartTextWithLog($xml);
        return (string) ($r['text'] ?? '');
    }

    private function extractDocxXmlPartTextWithLog(string $xml): array
    {
        $log = [];
        if (trim($xml) === '') return ['text' => '', 'log' => ['empty_input'], 'paragraphs_found' => 0];

        $entityLoader = null;
        if (function_exists('libxml_disable_entity_loader')) {
            $entityLoader = @libxml_disable_entity_loader(true);
        }
        $errState = libxml_use_internal_errors(true);

        $dom = new \DOMDocument('1.0', 'UTF-8');
        $loaded = false;
        $altOut = '';
        $useAlt = false;

        if (stripos($xml, '<?xml') === 0) {
            $loaded = @$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOCDATA | LIBXML_NOBLANKS);
        }
        if (!$loaded) {
            $altOut = $xml;
            $useAlt = true;
            $log[] = 'dom_loadXML_failed_USE_ALT';
        }

        $paragraphBreakerCount = 0;
        if ($loaded) {
            $xpath = new \DOMXPath($dom);
            $ns = [
                'w'   => 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
                'w10' => 'urn:schemas-microsoft-com:office:word',
                'v'   => 'urn:schemas-microsoft-com:vml',
                'a'   => 'http://schemas.openxmlformats.org/drawingml/2006/main',
                'wp'  => 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing',
                'mc'  => 'http://schemas.openxmlformats.org/markup-compatibility/2006',
                'wps' => 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape',
                'pic' => 'http://schemas.openxmlformats.org/drawingml/2006/picture',
                'w14' => 'http://schemas.microsoft.com/office/word/2010/wordml',
            ];
            foreach ($ns as $pfx => $uri) { @$xpath->registerNamespace($pfx, $uri); }

            $paragraphBreakers = ['//w:p', '//w:tr', '//w:tc'];
            $textExpressions = ['//w:t', '//w:delText', '//w:instrText', '//a:t', '//v:TextBody', '//wps:txbx/w:txbxContent//w:t'];

            $paras = [];
            $blockNodes = [];
            foreach ($paragraphBreakers as $expr) {
                $foundCount = 0;
                $found = @$xpath->query($expr);
                if ($found instanceof \DOMNodeList) {
                    $foundCount = $found->length;
                    for ($k = 0; $k < $found->length; $k++) {
                        $n = $found->item($k);
                        if ($n instanceof \DOMNode) $blockNodes[spl_object_id($n)] = $n;
                    }
                }
                $log[] = 'xpath '.$expr.' → '.$foundCount.' nodes';
            }
            if ($blockNodes === []) {
                $found = @$xpath->query('//*');
                if ($found instanceof \DOMNodeList && $found->length > 0) {
                    $blockNodes[spl_object_id($found->item(0))] = $found->item(0);
                    $log[] = 'fallback_wildcard nodes='.$found->length;
                } else {
                    $log[] = 'BLOCKNODES_EMPTY';
                }
            }
            $paragraphBreakerCount = count($blockNodes);
            $totalTextNodesFound = 0;
            foreach ($blockNodes as $node) {
                $local = [];
                foreach ($textExpressions as $expr) {
                    $texts = @$xpath->query($expr, $node);
                    if (!$texts instanceof \DOMNodeList) continue;
                    for ($k = 0; $k < $texts->length; $k++) {
                        $t = $texts->item($k);
                        if (!$t instanceof \DOMNode) continue;
                        $val = $t->nodeValue ?? '';
                        if ($val !== null && trim((string)$val) !== '') {
                            $local[] = trim((string)$val);
                            $totalTextNodesFound++;
                        }
                    }
                }
                if ($local !== []) $paras[] = implode(' ', array_unique($local));
            }
            $log[] = 'total_text_nodes='.$totalTextNodesFound.' blocks_with_text='.count($paras).'/'.$paragraphBreakerCount;
            if ($paras === []) {
                $log[] = 'NO_PARAS_root_scan';
                foreach ($textExpressions as $expr) {
                    $texts = @$xpath->query($expr);
                    if (!$texts instanceof \DOMNodeList) continue;
                    for ($k = 0; $k < $texts->length; $k++) {
                        $t = $texts->item($k);
                        if (!$t instanceof \DOMNode) continue;
                        $val = trim((string)($t->nodeValue ?? ''));
                        if ($val !== '') $paras[] = $val;
                    }
                }
            }
            $paras = array_values(array_filter($paras, static function ($s) { return trim((string)$s) !== ''; }));
            $paragraphBreakerCount = count($paras);
            $altOut = implode("\n\n", $paras);
            $log[] = 'paras_final_count='.$paragraphBreakerCount;
        }

        libxml_clear_errors();
        libxml_use_internal_errors($errState);
        if (function_exists('libxml_disable_entity_loader') && $entityLoader !== null) {
            @libxml_disable_entity_loader($entityLoader);
        }

        if ($useAlt) {
            $alt = (string) preg_replace('/<w:tab[^\/]*\/>/i', "\t", $altOut);
            $alt = (string) preg_replace('/<w:br[^\/]*\/>/i', "\n", $alt);
            $alt = (string) preg_replace('/<w:p\b[^>]*>/i', "\n", $alt);
            $alt = (string) preg_replace('/<\/w:p>/i', "\n", $alt);
            $alt = (string) preg_replace('/<w:tr\b[^>]*>/i', "\n", $alt);
            $alt = (string) preg_replace('/<w:tc\b[^>]*>/i', "\t", $alt);
            $alt = strip_tags($alt);
            $alt = html_entity_decode($alt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            $alt = (string) preg_replace('/\n{3,}/', "\n\n", $alt);
            $alt = trim($alt);
            $log[] = 'alt_regex_mode_final_chars='.mb_strlen($alt);
            return ['text' => $alt, 'log' => $log, 'paragraphs_found' => $paragraphBreakerCount];
        }

        $altOut = html_entity_decode($altOut, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $altOut = (string) preg_replace('/\n{3,}/', "\n\n", $altOut);
        $altOut = trim($altOut);
        $log[] = 'dom_mode_final_chars='.mb_strlen($altOut);
        return ['text' => $altOut, 'log' => $log, 'paragraphs_found' => $paragraphBreakerCount];
    }

    private function cleanText(string $raw): string
    {
        $cleaned = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', ' ', $raw);
        $cleaned = preg_replace('/[^\P{C}\n\t\r]/u', ' ', (string) $cleaned);
        $cleaned = preg_replace('/\s+/u', ' ', (string) $cleaned);
        return trim((string) $cleaned);
    }
}
