<?php
namespace Core\Services;

/**
 * PdfSplitterService — Pure-PHP PDF page splitter
 *
 * Works WITHOUT Ghostscript, Imagick, or GD.
 * Strategy:
 *  1. Count total pages by scanning the PDF's Pages tree.
 *  2. If page count > 3, extract individual page content using
 *     fpdi-style byte-range splicing, wrapped in minimal valid PDF shells.
 *  3. Returns an array of temp file paths, one per batch (≤3 pages each).
 *
 * Limitations (inherent to pure-PHP approach):
 *  - Handles standard linearized and cross-reference table PDFs.
 *  - Very complex PDFs (object streams, heavy compression, XRef streams)
 *    fall back to sending the full file and relying on OCR.space partial results.
 */
class PdfSplitterService {

    /**
     * Split a PDF file into batches of up to $batchSize pages.
     * Returns an array of temp file paths. Caller must delete them.
     *
     * @param string $pdfPath   Absolute path to the source PDF.
     * @param int    $batchSize Max pages per batch (free tier = 3).
     * @return string[]         Array of absolute temp file paths.
     */
    public function split(string $pdfPath, int $batchSize = 3): array {
        if (!file_exists($pdfPath)) {
            throw new \Exception("PDF not found: $pdfPath");
        }

        $pageCount = $this->countPages($pdfPath);

        // No splitting needed
        if ($pageCount <= $batchSize) {
            return [$pdfPath];
        }

        // Try to split into page-batched temp files
        $batches = $this->splitByPageRanges($pdfPath, $pageCount, $batchSize);

        // If splitting produced nothing valid, fall back to full file
        if (empty($batches)) {
            return [$pdfPath];
        }

        return $batches;
    }

    // ──────────────────────────────────────────────────────────────
    // PAGE COUNTING — scan for /Count N in Pages dictionary
    // ──────────────────────────────────────────────────────────────

    public function countPages(string $pdfPath): int {
        $content = file_get_contents($pdfPath);
        if ($content === false) return 1;

        // PDF stores page count as /Count N inside the Pages object
        // We grab the FIRST /Count \d+ we find (which belongs to the root Pages node)
        if (preg_match('/\/Count\s+(\d+)/', $content, $m)) {
            return (int)$m[1];
        }
        return 1;
    }

    // ──────────────────────────────────────────────────────────────
    // SPLITTING — extract page objects and wrap them in mini PDFs
    // ──────────────────────────────────────────────────────────────

    private function splitByPageRanges(string $pdfPath, int $totalPages, int $batchSize): array {
        // We use a different approach: instead of re-building the PDF structure
        // (which requires a full PDF writer), we use pdftk-compatible byte tricks.
        //
        // Since we have no external tools, we implement a practical alternative:
        // Use separate API calls per page by sending the PDF with page selection hints
        // via the OCR.space API (it accepts page numbers as a parameter on Pro plans only).
        //
        // For free plan: we'll split the PDF file using raw PDF structure manipulation.
        // We locate each Page object and create minimal PDFs from them.

        $raw = file_get_contents($pdfPath);
        if (!$raw) return [];

        // Find all page objects by looking for /Type /Page (not Pages)
        // Returns array of [obj_num => byte_offset]
        $pageObjects = $this->findPageObjects($raw);

        if (empty($pageObjects)) {
            // Can't parse structure — return original
            return [$pdfPath];
        }

        $batches   = [];
        $tmpDir    = sys_get_temp_dir();
        $pageNums  = array_keys($pageObjects);
        $chunks    = array_chunk($pageNums, $batchSize);

        foreach ($chunks as $chunkIndex => $chunk) {
            $tmpFile = $tmpDir . DIRECTORY_SEPARATOR . 'ocr_batch_' . uniqid() . '.pdf';

            // Build a minimal valid PDF containing just these page objects
            $miniPdf = $this->buildMinimalPdf($raw, $chunk, $pageObjects, $tmpDir);

            if ($miniPdf === null) {
                // Building failed — skip, will fall back to full-file OCR
                continue;
            }

            if (file_put_contents($tmpFile, $miniPdf) !== false) {
                $batches[] = $tmpFile;
            }
        }

        return $batches ?: [$pdfPath];
    }

    /**
     * Find all /Type /Page objects (singular pages, not /Pages tree nodes).
     * Returns [obj_number => ['offset' => int, 'length' => int, 'content' => string]]
     */
    private function findPageObjects(string $raw): array {
        $pages = [];

        // Match "N G obj ... endobj" blocks
        // N = object number, G = generation (usually 0)
        if (!preg_match_all('/(\d+)\s+\d+\s+obj\s+(.*?)endobj/s', $raw, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
            return [];
        }

        foreach ($matches as $m) {
            $objNum  = (int)$m[1][0];
            $content = $m[2][0];

            // Only keep singular /Page dictionaries, not /Pages (tree)
            if (preg_match('/\/Type\s*\/Page\b/', $content) &&
                !preg_match('/\/Type\s*\/Pages\b/', $content)) {
                $fullMatch = $m[0][0];
                $pages[$objNum] = [
                    'offset'  => $m[0][1],
                    'length'  => strlen($fullMatch),
                    'content' => $fullMatch,
                    'dict'    => $content,
                ];
            }
        }

        return $pages;
    }

    /**
     * Build a minimal, self-contained PDF for a subset of pages.
     * This collects the page objects plus their referenced resource/font/content objects.
     */
    private function buildMinimalPdf(string $raw, array $pageObjNums, array $allPages, string $tmpDir): ?string {
        // Collect all objects referenced by these pages (fonts, contents, resources)
        $allObjects = $this->extractAllObjects($raw);
        if (empty($allObjects)) return null;

        $needed = $this->collectDependencies($pageObjNums, $allObjects);

        // Build a new PDF with renumbered objects
        $body     = "%PDF-1.4\n";
        $offsets  = [];
        $newObjId = 1;
        $idMap    = []; // old_id => new_id

        // First pass: assign new IDs
        foreach ($needed as $oldId => $_) {
            $idMap[$oldId] = $newObjId++;
        }

        // Add page objects first (so they get low IDs), then deps
        $pageNewIds = [];
        foreach ($pageObjNums as $oldId) {
            if (isset($idMap[$oldId])) {
                $pageNewIds[] = $idMap[$oldId];
            }
        }

        // Second pass: emit objects with renumbered references
        foreach ($needed as $oldId => $objContent) {
            $newId      = $idMap[$oldId];
            $offsets[]  = strlen($body);
            $rewritten  = $this->renumberRefs($objContent, $idMap, $oldId);
            $body      .= "$newId 0 obj\n$rewritten\nendobj\n";
        }

        // Build Pages dictionary
        $pagesId    = $newObjId++;
        $kidsStr    = implode(' 0 R ', $pageNewIds) . ' 0 R';
        $pagesOff   = strlen($body);
        $body      .= "$pagesId 0 obj\n<</Type /Pages /Kids [$kidsStr] /Count " . count($pageNewIds) . ">>\nendobj\n";

        // Update each page to point to new /Parent
        // (We do a string replace in the body — Pages entries already emitted above,
        //  so we need to post-process /Parent refs in the page dicts)
        $body = preg_replace('/\/Parent\s+\d+\s+\d+\s+R/', "/Parent $pagesId 0 R", $body);

        // Build Catalog
        $catalogId  = $newObjId++;
        $catalogOff = strlen($body);
        $body      .= "$catalogId 0 obj\n<</Type /Catalog /Pages $pagesId 0 R>>\nendobj\n";

        // XRef table
        $xrefOffset = strlen($body);
        $totalObjs  = $catalogId + 1; // 0 (free) + all objects

        $body .= "xref\n";
        $body .= "0 $totalObjs\n";
        $body .= "0000000000 65535 f \n"; // free entry

        // We only have valid offsets for some objects (those in $needed + pages + catalog)
        // Build a complete offset table
        $fullOffsets = array_fill(0, $totalObjs, null);
        $objIdx = 0;
        foreach ($needed as $oldId => $_) {
            $newId = $idMap[$oldId];
            if ($newId < $totalObjs) {
                $fullOffsets[$newId] = $offsets[$objIdx];
            }
            $objIdx++;
        }
        $fullOffsets[$pagesId]   = $pagesOff;
        $fullOffsets[$catalogId] = $catalogOff;

        for ($i = 1; $i < $totalObjs; $i++) {
            $off = $fullOffsets[$i] ?? 0;
            $body .= sprintf("%010d 00000 n \n", $off);
        }

        $body .= "trailer\n<</Size $totalObjs /Root $catalogId 0 R>>\n";
        $body .= "startxref\n$xrefOffset\n%%EOF\n";

        return $body;
    }

    /** Extract all objects from raw PDF as [obj_num => content_string] */
    private function extractAllObjects(string $raw): array {
        $objects = [];
        if (!preg_match_all('/(\d+)\s+\d+\s+obj\s+(.*?)endobj/s', $raw, $matches, PREG_SET_ORDER)) {
            return [];
        }
        foreach ($matches as $m) {
            $objects[(int)$m[1]] = $m[2];
        }
        return $objects;
    }

    /** BFS: collect page objects + all objects they reference */
    private function collectDependencies(array $startIds, array $allObjects): array {
        $visited = [];
        $queue   = $startIds;

        while (!empty($queue)) {
            $id = array_shift($queue);
            if (isset($visited[$id]) || !isset($allObjects[$id])) continue;
            $visited[$id] = $allObjects[$id];

            // Find all N G R references in this object's content
            preg_match_all('/(\d+)\s+\d+\s+R\b/', $allObjects[$id], $refs);
            foreach ($refs[1] as $refId) {
                $refId = (int)$refId;
                if (!isset($visited[$refId]) && isset($allObjects[$refId])) {
                    // Skip /Pages tree references (they'd pull in all pages)
                    if (preg_match('/\/Type\s*\/Pages\b/', $allObjects[$refId])) continue;
                    $queue[] = $refId;
                }
            }
        }

        return $visited;
    }

    /** Replace all "old_id 0 R" references with "new_id 0 R" */
    private function renumberRefs(string $content, array $idMap, int $selfId): string {
        return preg_replace_callback('/(\d+)\s+\d+\s+R\b/', function($m) use ($idMap) {
            $oldId = (int)$m[1];
            $newId = $idMap[$oldId] ?? $oldId;
            return "$newId 0 R";
        }, $content);
    }

    /** Cleanup temp files created during splitting */
    public function cleanup(array $paths, string $originalPath): void {
        foreach ($paths as $path) {
            if ($path !== $originalPath && file_exists($path)) {
                @unlink($path);
            }
        }
    }
}
