<?php

namespace App\Jobs\Synapse;

use App\Models\Claim\Claim;
use App\Models\Contact\Hub\ContactMessage;
use App\Models\Contact\Hub\ContactMessageAttachment;
use App\Models\Document\Document;
use App\Models\Dsar\Dsar;
use App\Models\Dsar\DsarEvidenceItem;
use App\Models\Synapse\SynapseRun;
use App\Services\OcrService;
use App\Synapse\Agents\ExtractDsarEvidenceAgent;
use App\Synapse\EvidenceAiTagger;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Exceptions\RateLimitedException;

/**
 * Async OCR + extraction for scanned PDFs (image-only records, up to thousands of pages) that
 * have no text layer. Each invocation OCRs one page-range with coordinate output (OcrService →
 * tesseract TSV), extracts evidence from that range's text, locates each quote among the page's
 * OCR words to store a real bounding box, tags it, then re-dispatches itself for the next range
 * until the document is finished.
 *
 * OCR output (text + word boxes) is cached per page on disk so retries/continuations never
 * re-OCR, and each page-range's extraction is idempotent. Works for both origins: sourceType is
 * 'document' or 'attachment'.
 */
class OcrExtractEvidenceJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 2;
    public int $timeout = 1800;
    public int $backoff = 120;

    /** Pages per extraction call to the agent (text-only). */
    private const CHUNK_SIZE = 25;

    public function __construct(
        public readonly string $sourceType, // 'document' | 'attachment'
        public readonly int    $sourceId,
        public readonly ?int   $dsarId = null,
        public readonly int    $offset = 0,
        public readonly ?int   $pageCount = null,
    ) {}

    public function handle(OcrService $ocr, EvidenceAiTagger $tagger): void
    {
        $source = $this->loadSource();
        if (!$source) {
            Log::warning("OcrExtractEvidenceJob: {$this->sourceType} {$this->sourceId} not found.");
            return;
        }

        if (!$ocr->isAvailable()) {
            $source->update(['evidence_status' => 'failed']);
            Log::error('OcrExtractEvidenceJob: OCR tooling unavailable — install tesseract + a rasterizer (poppler-utils or ghostscript) and set config/ocr.php binaries.');
            return;
        }

        [$pdfPath, $solicitorId, $claim] = $this->resolveContext($source);
        if (!$pdfPath || !is_file($pdfPath)) {
            $source->update(['evidence_status' => 'failed']);
            Log::error("OcrExtractEvidenceJob: PDF not found on disk for {$this->sourceType} {$this->sourceId} ({$pdfPath}).");
            return;
        }

        $pageCount = $this->pageCount ?? $ocr->pageCount($pdfPath) ?? 0;
        if ($pageCount <= 0) {
            $source->update(['evidence_status' => 'failed']);
            Log::error("OcrExtractEvidenceJob: could not read page count for {$this->sourceType} {$this->sourceId}.");
            return;
        }

        $pagesPerRun = max(1, (int) config('ocr.pages_per_run', 100));
        $from        = $this->offset + 1;
        $to          = min($this->offset + $pagesPerRun, $pageCount);

        Log::info("OcrExtractEvidenceJob: {$this->sourceType} {$this->sourceId} — OCR pages {$from}-{$to} of {$pageCount}.");

        // On the first range, wipe any prior AI items (e.g. from an earlier vision run with
        // unreliable page numbers and no boxes) so OCR results replace them cleanly. Human items
        // are left untouched.
        if ($this->offset === 0) {
            $this->clearAllAiItems();
        }

        try {
            // 1. OCR this run's pages (cached on disk; skips already-OCR'd pages).
            $perPage = []; // page => ['text' => string, 'words' => [...]]
            for ($page = $from; $page <= $to; $page++) {
                $perPage[$page] = $this->cachedOcr($ocr, $pdfPath, $page);
            }

            // 2. Extract evidence from this range, chunked, idempotent per page-range.
            $pagesWithText  = array_keys(array_filter($perPage, fn ($p) => trim($p['text']) !== ''));
            $createdItemIds = [];
            foreach (array_chunk($pagesWithText, self::CHUNK_SIZE) as $chunkPages) {
                $this->clearAiItemsForPages($chunkPages); // idempotency for retries
                $prompt = "Filename: {$this->sourceName($source)} (scanned, OCR'd)\n"
                    . "Pages in this chunk: " . implode(', ', $chunkPages) . "\n\n"
                    . $this->buildChunkText($chunkPages, $perPage);

                $extract = (new ExtractDsarEvidenceAgent)->prompt($prompt);
                $items   = json_decode($extract->structured['items_json'] ?? '[]', true) ?? [];

                $createdItemIds = array_merge($createdItemIds, $this->saveItems($items, $solicitorId, $perPage));
            }

            // 3. Tag this range's items against the claim's care settings.
            if ($claim && $createdItemIds) {
                $tagger->tagCareSettings($claim, $createdItemIds);
            }

            // 4. On the first range, detect the source institution for uploaded documents.
            if ($this->offset === 0 && $source instanceof Document) {
                $firstPages = implode("\n\n", array_map(fn ($p) => $p['text'], array_slice($perPage, 0, 3, true)));
                $tagger->suggestDocumentSource($source, $firstPages);
            }
        } catch (RateLimitedException $e) {
            $delay = min(120 * $this->attempts(), 900);
            Log::warning("OcrExtractEvidenceJob: rate limited on {$this->sourceType} {$this->sourceId}; retrying range from offset {$this->offset} in {$delay}s.");
            $this->release($delay);
            return;
        }

        // 5. Continue with the next range, or finish.
        if ($to < $pageCount) {
            self::dispatch($this->sourceType, $this->sourceId, $this->dsarId, $to, $pageCount);
            Log::info("OcrExtractEvidenceJob: {$this->sourceType} {$this->sourceId} — dispatched continuation from offset {$to}.");
            return;
        }

        $source->update(['evidence_status' => 'completed']);
        $this->recordRun($source, $pageCount, $solicitorId);
        Log::info("OcrExtractEvidenceJob: {$this->sourceType} {$this->sourceId} — OCR pipeline complete ({$pageCount} pages).");
    }

    // ── Helpers ─────────────────────────────────────────────────────────────────

    private function loadSource(): Document|ContactMessageAttachment|null
    {
        return $this->sourceType === 'document'
            ? Document::with(['owner', 'type'])->find($this->sourceId)
            : ContactMessageAttachment::find($this->sourceId);
    }

    /** @return array{0:?string,1:?int,2:?Claim} [absolute pdf path, solicitor id, claim] */
    private function resolveContext(Document|ContactMessageAttachment $source): array
    {
        if ($source instanceof Document) {
            $path = Storage::disk('public')->path($source->owner->getDocumentStoragePath() . $source->file_name);
            return [$path, $source->solicitor_id, $this->resolveDocumentClaim($source)];
        }

        $path        = Storage::disk($source->disk)->path($source->path);
        $solicitorId = ContactMessage::where('id', $source->contact_message_id)->value('solicitor_id');
        $claim       = $this->dsarId ? Dsar::find($this->dsarId)?->claim : null;

        return [$path, $solicitorId, $claim];
    }

    private function resolveDocumentClaim(Document $document): ?Claim
    {
        $owner = $document->owner;
        if ($owner instanceof Claim) {
            return $owner;
        }
        if ($owner instanceof Dsar) {
            return $owner->claim;
        }
        if ($owner && method_exists($owner, 'claim')) {
            return $owner->claim;
        }
        return null;
    }

    /**
     * OCR a page (text + word boxes), caching the result on the local disk as JSON so retries
     * and continuations never re-OCR.
     *
     * @return array{text:string,words:array}
     */
    private function cachedOcr(OcrService $ocr, string $pdfPath, int $page): array
    {
        $key  = "ocr/{$this->sourceType}/{$this->sourceId}/p{$page}.json";
        $disk = Storage::disk('local');

        if ($disk->exists($key)) {
            $decoded = json_decode((string) $disk->get($key), true);
            if (is_array($decoded) && isset($decoded['text'], $decoded['words'])) {
                return $decoded;
            }
        }

        $data = $ocr->ocrPageData($pdfPath, $page);
        $disk->put($key, json_encode($data));

        return $data;
    }

    /** @param array<int,array{text:string,words:array}> $perPage */
    private function buildChunkText(array $pageNums, array $perPage): string
    {
        $parts = [];
        foreach ($pageNums as $page) {
            $text = $perPage[$page]['text'] ?? '';
            if (trim($text) !== '') {
                $parts[] = "=== PAGE {$page} ===\n" . trim($text);
            }
        }
        return implode("\n\n", $parts);
    }

    /** Remove all AI items for this source (used at the start of a fresh OCR run). */
    private function clearAllAiItems(): void
    {
        $query = DsarEvidenceItem::where('source', 'ai');
        if ($this->sourceType === 'document') {
            $query->where('document_id', $this->sourceId);
        } else {
            $query->where('contact_message_attachment_id', $this->sourceId);
        }
        $query->delete();
    }

    /** Remove prior AI items for these pages so re-processing a range doesn't duplicate. */
    private function clearAiItemsForPages(array $pages): void
    {
        $query = DsarEvidenceItem::where('source', 'ai')->whereIn('page', $pages);
        if ($this->sourceType === 'document') {
            $query->where('document_id', $this->sourceId);
        } else {
            $query->where('contact_message_attachment_id', $this->sourceId);
        }
        $query->delete();
    }

    /**
     * @param  array<int,array{text:string,words:array}>  $perPage
     * @return int[] ids of created items
     */
    private function saveItems(array $items, ?int $solicitorId, array $perPage): array
    {
        $ids = [];
        foreach ($items as $item) {
            if (empty($item['quote']) || empty($item['category'])) {
                continue;
            }

            $page = ($item['page'] ?? 0) ?: null;
            $bbox = ($page && isset($perPage[$page]))
                ? $this->locateBbox($perPage[$page]['words'], (string) $item['quote'])
                : null;

            $data = [
                'solicitor_id' => $solicitorId,
                'dsar_id'      => $this->dsarId,
                'page'         => $page,
                'quote'        => $item['quote'],
                'bbox'         => $bbox,
                'category'     => $item['category'],
                'severity'     => $item['severity'] ?? 'medium',
                'comment'      => $item['comment'] ?? '',
                'source'       => 'ai',
            ];
            if ($this->sourceType === 'document') {
                $data['document_id'] = $this->sourceId;
            } else {
                $data['contact_message_attachment_id'] = $this->sourceId;
            }

            $ids[] = DsarEvidenceItem::create($data)->id;
        }
        return $ids;
    }

    /**
     * Locate a quote among a page's OCR words and return a normalised bounding box
     * ['x','y','width','height'] as page fractions, or null if it can't be matched.
     *
     * @param  array<int,array{t:string,x:float,y:float,w:float,h:float}>  $words
     */
    private function locateBbox(array $words, string $quote): ?array
    {
        if (empty($words)) {
            return null;
        }

        $normalise = fn (string $s): string => preg_replace('/[^a-z0-9]/', '', mb_strtolower($s));

        // Concatenate normalised page words, tracking each word's char span.
        $pageStr = '';
        $spans   = [];
        foreach ($words as $i => $word) {
            $n = $normalise($word['t']);
            if ($n === '') {
                continue;
            }
            $start = strlen($pageStr);
            $pageStr .= $n;
            $spans[] = ['i' => $i, 's' => $start, 'e' => strlen($pageStr)];
        }
        if ($pageStr === '') {
            return null;
        }

        $q = $normalise($quote);
        if (strlen($q) < 4) {
            return null;
        }

        $pos = strpos($pageStr, $q);
        $len = strlen($q);
        if ($pos === false) {
            // Fall back to matching the quote's opening run so we at least anchor the box.
            $prefix = substr($q, 0, min(60, strlen($q)));
            $pos    = strpos($pageStr, $prefix);
            if ($pos === false) {
                return null;
            }
            $len = strlen($prefix);
        }
        $end = $pos + $len;

        $minX = 1.0; $minY = 1.0; $maxX = 0.0; $maxY = 0.0;
        $matched = false;
        foreach ($spans as $span) {
            if ($span['s'] < $end && $span['e'] > $pos) {
                $w    = $words[$span['i']];
                $minX = min($minX, $w['x']);
                $minY = min($minY, $w['y']);
                $maxX = max($maxX, $w['x'] + $w['w']);
                $maxY = max($maxY, $w['y'] + $w['h']);
                $matched = true;
            }
        }
        if (!$matched) {
            return null;
        }

        return [
            'x'      => round(max(0.0, $minX), 5),
            'y'      => round(max(0.0, $minY), 5),
            'width'  => round(min(1.0, $maxX - $minX), 5),
            'height' => round(min(1.0, $maxY - $minY), 5),
        ];
    }

    private function sourceName(Document|ContactMessageAttachment $source): string
    {
        return $source instanceof Document ? $source->file_name : $source->filename;
    }

    private function recordRun(Document|ContactMessageAttachment $source, int $pageCount, ?int $solicitorId): void
    {
        if (!config('synapse.record_runs', true)) {
            return;
        }
        SynapseRun::create([
            'solicitor_id' => $solicitorId,
            'agent_type'   => 'ocr_evidence_extractor',
            'subject_type' => $source::class,
            'subject_id'   => $source->id,
            'status'       => 'completed',
            'result'       => ['pages' => $pageCount],
        ]);
    }

    public function failed(\Throwable $exception): void
    {
        $this->loadSource()?->update(['evidence_status' => 'failed']);
        Log::error("OcrExtractEvidenceJob: permanently failed for {$this->sourceType} {$this->sourceId}.", [
            'error' => $exception->getMessage(),
        ]);
    }
}
