<?php

namespace App\Services;

use Illuminate\Support\Facades\Log;
use Smalot\PdfParser\Config as PdfConfig;
use Smalot\PdfParser\Parser as PdfParser;
use Symfony\Component\Process\Process;

/**
 * Local OCR for scanned PDFs. Rasterizes each page (poppler's pdftoppm, or ghostscript as a
 * fallback) and reads it with tesseract in TSV mode so we get per-word bounding boxes — which
 * lets the caller draw real highlight overlays and attribute quotes to the correct page.
 *
 * Binaries are configurable in config/ocr.php so the same code runs on a Linux server and a
 * Windows dev box.
 */
class OcrService
{
    public function isAvailable(): bool
    {
        return (bool) config('ocr.enabled', true)
            && $this->binaryExists((string) config('ocr.tesseract_bin'))
            && $this->rasterEngine() !== null;
    }

    /** Total page count. Uses the bundled PDF parser (no pdfinfo dependency); falls back to pdfinfo. */
    public function pageCount(string $pdfPath): ?int
    {
        try {
            $config = new PdfConfig();
            $config->setRetainImageContent(false);
            $config->setDecodeMemoryLimit(64 * 1024 * 1024);
            $pdf = (new PdfParser([], $config))->parseFile($pdfPath);
            return count($pdf->getPages());
        } catch (\Throwable $e) {
            Log::warning("OcrService: pdfparser page count failed: {$e->getMessage()}");
        }

        try {
            $p = new Process([(string) config('ocr.pdfinfo_bin'), $pdfPath]);
            $p->setTimeout(60);
            $p->run();
            if ($p->isSuccessful() && preg_match('/^Pages:\s+(\d+)/m', $p->getOutput(), $m)) {
                return (int) $m[1];
            }
        } catch (\Throwable $e) {
            Log::warning("OcrService: pdfinfo page count failed: {$e->getMessage()}");
        }

        return null;
    }

    /**
     * OCR a single 1-indexed page. Returns:
     *   ['text' => string, 'words' => [['t'=>string,'x'=>float,'y'=>float,'w'=>float,'h'=>float], ...]]
     * where word coordinates are fractions (0..1) of the page. Never throws.
     */
    public function ocrPageData(string $pdfPath, int $page): array
    {
        $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ocr_' . uniqid('', true);
        $img  = null;

        try {
            $img = $this->rasterizePage($pdfPath, $page, (int) config('ocr.dpi', 300), $base);
            if (!$img || !is_file($img)) {
                return ['text' => '', 'words' => []];
            }

            $size = @getimagesize($img);
            $imgW = $size[0] ?? 0;
            $imgH = $size[1] ?? 0;
            if ($imgW <= 0 || $imgH <= 0) {
                return ['text' => '', 'words' => []];
            }

            $tsvBase = $base . '_tsv';
            $tess    = new Process([
                (string) config('ocr.tesseract_bin'),
                $img,
                $tsvBase,
                '-l', (string) config('ocr.language', 'eng'),
                'tsv',
            ]);
            $tess->setTimeout(240);
            $tess->run();

            if (!$tess->isSuccessful()) {
                Log::warning("OcrService: tesseract failed for page {$page}: " . $tess->getErrorOutput());
                return ['text' => '', 'words' => []];
            }

            $tsvFile = $tsvBase . '.tsv';
            $tsv     = is_file($tsvFile) ? (string) file_get_contents($tsvFile) : '';
            @unlink($tsvFile);

            return $this->parseTsv($tsv, $imgW, $imgH);
        } catch (\Throwable $e) {
            Log::warning("OcrService: OCR failed for page {$page}: {$e->getMessage()}");
            return ['text' => '', 'words' => []];
        } finally {
            if ($img && is_file($img)) {
                @unlink($img);
            }
        }
    }

    // ── Internals ────────────────────────────────────────────────────────────────

    /** Rasterize one page to a grayscale PNG. Returns the image path, or null on failure. */
    private function rasterizePage(string $pdfPath, int $page, int $dpi, string $base): ?string
    {
        $engine = $this->rasterEngine();

        if ($engine === 'pdftoppm') {
            $p = new Process([
                (string) config('ocr.pdftoppm_bin'),
                '-png', '-gray',
                '-r', (string) $dpi,
                '-f', (string) $page,
                '-l', (string) $page,
                $pdfPath,
                $base,
            ]);
            $p->setTimeout(240);
            $p->run();
            if (!$p->isSuccessful()) {
                Log::warning("OcrService: pdftoppm failed for page {$page}: " . $p->getErrorOutput());
                return null;
            }
            $matches = glob($base . '-*.png') ?: [];
            return $matches[0] ?? null;
        }

        if ($engine === 'gs') {
            $out = $base . '.png';
            $p   = new Process([
                (string) config('ocr.gs_bin'),
                '-q', '-dNOPAUSE', '-dBATCH', '-dSAFER',
                '-sDEVICE=pnggray',
                '-r' . $dpi,
                '-dFirstPage=' . $page,
                '-dLastPage=' . $page,
                '-sOutputFile=' . $out,
                $pdfPath,
            ]);
            $p->setTimeout(240);
            $p->run();
            if (!$p->isSuccessful()) {
                Log::warning("OcrService: ghostscript failed for page {$page}: " . $p->getErrorOutput());
                return null;
            }
            return is_file($out) ? $out : null;
        }

        return null;
    }

    /** Which rasterizer to use, honouring the config preference and what's installed. */
    private function rasterEngine(): ?string
    {
        $pref = (string) config('ocr.rasterizer', 'auto');

        if ($pref === 'pdftoppm' && $this->binaryExists((string) config('ocr.pdftoppm_bin'))) {
            return 'pdftoppm';
        }
        if ($pref === 'gs' && $this->binaryExists((string) config('ocr.gs_bin'))) {
            return 'gs';
        }
        if ($this->binaryExists((string) config('ocr.pdftoppm_bin'))) {
            return 'pdftoppm';
        }
        if ($this->binaryExists((string) config('ocr.gs_bin'))) {
            return 'gs';
        }
        return null;
    }

    /**
     * Parse tesseract TSV into text + word boxes (coordinates normalised to page fractions).
     */
    private function parseTsv(string $tsv, int $imgW, int $imgH): array
    {
        $words     = [];
        $textParts = [];
        $prevLine  = null;

        foreach (preg_split('/\r?\n/', $tsv) as $row) {
            if ($row === '') {
                continue;
            }
            $c = explode("\t", $row);
            if (count($c) < 12 || $c[0] === 'level') {
                continue;
            }
            if ((int) $c[0] !== 5) { // 5 = word-level rows
                continue;
            }

            $text = trim($c[11]);
            if ($text === '') {
                continue;
            }

            $left = (int) $c[6];
            $top  = (int) $c[7];
            $w    = (int) $c[8];
            $h    = (int) $c[9];

            $words[] = [
                't' => $text,
                'x' => $left / $imgW,
                'y' => $top / $imgH,
                'w' => $w / $imgW,
                'h' => $h / $imgH,
            ];

            $lineKey = $c[2] . '_' . $c[3] . '_' . $c[4]; // block_par_line
            if ($prevLine !== null && $lineKey !== $prevLine) {
                $textParts[] = "\n";
            }
            $textParts[] = $text . ' ';
            $prevLine    = $lineKey;
        }

        return ['text' => trim(implode('', $textParts)), 'words' => $words];
    }

    private function binaryExists(string $bin): bool
    {
        if ($bin === '') {
            return false;
        }
        if (str_contains($bin, DIRECTORY_SEPARATOR) && is_file($bin)) {
            return true;
        }
        try {
            $locator = stripos(PHP_OS, 'WIN') === 0 ? 'where' : 'which';
            $p       = new Process([$locator, $bin]);
            $p->setTimeout(15);
            $p->run();
            return $p->isSuccessful();
        } catch (\Throwable) {
            return false;
        }
    }
}
