<?php

namespace App\Jobs\Synapse;

use App\Models\Claim\Claim;
use App\Models\Document\Document;
use App\Models\Dsar\Dsar;
use App\Models\Dsar\DsarEvidenceItem;
use App\Models\Institution\Institution;
use App\Models\Synapse\SynapseRun;
use App\Synapse\Agents\CareSettingTaggerAgent;
use App\Synapse\Agents\DocumentSourceIdentifierAgent;
use App\Synapse\Agents\ExtractDsarEvidenceAgent;
use App\Synapse\Agents\FilterDsarPagesAgent;
use App\Synapse\Agents\VerifyDsarDocumentAgent;
use App\Synapse\Context\CareSettingsContext;
use App\Synapse\ZipExpander;
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\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Exceptions\RateLimitedException;
use Laravel\Ai\Files;
use Smalot\PdfParser\Parser as PdfParser;

class ExtractEvidenceFromDocumentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 3;
    public int $timeout = 600;
    public int $backoff = 60;

    /** Documents above this page count skip AI verify/filter and use text chunking. */
    private const LARGE_DOC_THRESHOLD = 40;

    /** Max pages of text sent to the extraction agent per call. */
    private const CHUNK_SIZE = 25;

    /** Pages whose extracted text is shorter than this are treated as image/blank. */
    private const MIN_TEXT_CHARS = 80;

    public function __construct(
        public readonly int $documentId,
    ) {}

    public function handle(): void
    {
        $document = Document::with(['owner', 'type'])->find($this->documentId);

        if (!$document) {
            Log::warning("ExtractEvidenceFromDocumentJob: document {$this->documentId} not found.");
            return;
        }

        // Atomic claim: only one job instance may process this document at a time.
        $claimed = Document::where('id', $this->documentId)
            ->where(fn ($q) => $q->whereNull('evidence_status')->orWhere('evidence_status', '!=', 'processing'))
            ->update(['evidence_status' => 'processing']);

        if (!$claimed) {
            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} already processing — duplicate skipped.");
            return;
        }

        $document->refresh();

        $storagePath = $document->owner->getDocumentStoragePath() . $document->file_name;
        $solicitorId = $document->solicitor_id;
        $dsarId      = $document->owner instanceof Dsar ? $document->owner->id : null;
        $claim       = $this->resolveClaim($document);

        // Stage 0: cheap junk pre-filter — a tiny uploaded image is a signature/logo, not a record.
        $rawSize = (int) $document->getRawOriginal('file_size');
        if (str_starts_with($document->file_type ?? '', 'image/')
            && $rawSize > 0
            && $rawSize < config('synapse.min_image_evidence_bytes', 50000)) {
            $document->update(['evidence_status' => 'skipped']);
            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — skipped as small image ({$rawSize} bytes), likely signature/logo.");
            return;
        }

        $zipExpander = new ZipExpander();

        try {
            // DSAR responses/uploads are sometimes a zip bundle of several files rather than a
            // single document. Expand it and run every member through the same pipeline instead
            // of sending the unreadable zip blob itself to the AI.
            if ($zipExpander->isZip($document->file_type ?? '', $document->file_name ?? '')) {
                $this->handleZipDocument($document, $zipExpander, $storagePath, $solicitorId, $dsarId, $claim);
                return;
            }

            $isPdf = $document->file_type === 'application/pdf';

            // Parse per-page text for PDFs so we know the page count and can chunk large docs.
            $perPageText = $isPdf ? $this->parsePageText($storagePath) : [];
            $pageCount   = count($perPageText);
            $isLarge     = $pageCount >= self::LARGE_DOC_THRESHOLD;

            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — pages={$pageCount}, large=" . ($isLarge ? 'yes' : 'no') . ".");

            $file = $this->loadFile($document, $storagePath);

            $extraction = $this->extractFromFile(
                $document->file_name,
                $document->file_type ?? '',
                $perPageText,
                $file,
                $document->id,
                $solicitorId,
                $dsarId,
                $document,
                allowOcrDispatch: true,
            );

            if ($extraction['status'] === 'deferred') {
                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — {$pageCount}-page scan; handing off to OCR pipeline.");
                return;
            }

            if ($extraction['status'] === 'skipped') {
                $document->update(['evidence_status' => 'skipped']);
                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — skipped: {$extraction['skip_reason']}.");
                return;
            }

            if ($extraction['status'] === 'failed') {
                $document->update(['evidence_status' => 'failed']);
                Log::error("ExtractEvidenceFromDocumentJob: document {$this->documentId} — {$extraction['skip_reason']}");
                return;
            }

            $createdItemIds = $extraction['item_ids'];
            $totalItems     = count($createdItemIds);
            $this->recordSynapseRun('dsar_evidence_extractor', $document, ['item_count' => $totalItems], $solicitorId);

            // ── Stage 4: Source detection (uploaded docs without a source) ────
            $this->detectSource($document, $perPageText, $solicitorId);

            // ── Stage 5: Care-setting tagging (suggestions) ───────────────────
            $this->tagCareSettings($createdItemIds, $document, $claim, $solicitorId);

            $document->update(['evidence_status' => 'completed']);
            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — done, {$totalItems} evidence items.");

        } catch (RateLimitedException $e) {
            // Reset to null so the retry's atomic claim can re-acquire it.
            $document->update(['evidence_status' => null]);
            $delay = min(60 * $this->attempts(), 600);
            Log::warning("ExtractEvidenceFromDocumentJob: rate limited for document {$this->documentId}, releasing in {$delay}s.");
            $this->release($delay);
        } catch (\Throwable $e) {
            $document->update(['evidence_status' => 'failed']);
            Log::error("ExtractEvidenceFromDocumentJob: failed for document {$this->documentId}.", ['error' => $e->getMessage()]);
            report($e);
            $this->fail($e);
        } finally {
            $zipExpander->cleanup();
        }
    }

    public function failed(\Throwable $exception): void
    {
        $document = Document::find($this->documentId);
        $document?->update(['evidence_status' => 'failed']);

        Log::error("ExtractEvidenceFromDocumentJob: permanently failed for document {$this->documentId}.", [
            'error' => $exception->getMessage(),
        ]);
    }

    // ── Zip bundles ──────────────────────────────────────────────────────────

    /**
     * Expands a zip document and runs every member file through the same verify → filter →
     * extract pipeline as a standalone document, pooling all evidence items under this
     * document's id. Source detection (Stage 4) is skipped for zip bundles — a bundle can
     * contain files from more than one institution, so guessing a single source for the whole
     * zip isn't meaningful. A member that individually needs the async OCR pipeline (large
     * scanned PDF) is skipped with a clear reason rather than attempted — that pipeline
     * re-dispatches itself across several jobs and expects to reload the file from durable
     * storage, which a zip member extracted to a temp dir during this run can't offer.
     */
    private function handleZipDocument(
        Document $document,
        ZipExpander $zipExpander,
        string $storagePath,
        ?int $solicitorId,
        ?int $dsarId,
        ?Claim $claim,
    ): void {
        $contents = Storage::disk('public')->get($storagePath);

        if ($contents === null) {
            $document->update(['evidence_status' => 'failed']);
            Log::error("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip file not found at {$storagePath}.");
            return;
        }

        $members = $zipExpander->expandFromContents($contents);

        if (empty($members)) {
            $document->update(['evidence_status' => 'skipped']);
            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip contained no usable files.");
            return;
        }

        Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip bundle with " . count($members) . ' member(s).');

        $createdItemIds = [];

        foreach ($members as $member) {
            $memberMime = File::mimeType($member['path']) ?: 'application/octet-stream';
            $memberSize = File::size($member['path']);

            // Same tiny-image pre-filter as Stage 0, applied per member.
            if (str_starts_with($memberMime, 'image/')
                && $memberSize > 0
                && $memberSize < config('synapse.min_image_evidence_bytes', 50000)) {
                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip member {$member['filename']} skipped as small image.");
                continue;
            }

            $isPdf       = str_contains($memberMime, 'pdf');
            $perPageText = [];
            if ($isPdf) {
                try {
                    $perPageText = $this->parsePageTextFromContents(File::get($member['path']));
                } catch (\Throwable $e) {
                    Log::warning("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip member {$member['filename']} PDF parse failed: {$e->getMessage()}");
                }
            }

            $file = $this->loadFileFromPath($member['path'], $memberMime);

            try {
                $extraction = $this->extractFromFile(
                    $member['filename'],
                    $memberMime,
                    $perPageText,
                    $file,
                    $document->id,
                    $solicitorId,
                    $dsarId,
                    $document,
                    allowOcrDispatch: false,
                );
            } catch (RateLimitedException $e) {
                // Let the job-level handler release/retry the whole document.
                throw $e;
            } catch (\Throwable $e) {
                Log::warning("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip member {$member['filename']} failed: {$e->getMessage()}");
                continue;
            }

            $createdItemIds = array_merge($createdItemIds, $extraction['item_ids']);

            $note = $extraction['status'] !== 'ok' ? " ({$extraction['status']}: {$extraction['skip_reason']})" : '';
            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip member {$member['filename']}: " . count($extraction['item_ids']) . " item(s){$note}.");
        }

        $totalItems = count($createdItemIds);
        $this->recordSynapseRun('dsar_evidence_extractor', $document, [
            'item_count'  => $totalItems,
            'zip_members' => count($members),
        ], $solicitorId);

        $this->tagCareSettings($createdItemIds, $document, $claim, $solicitorId);

        $document->update(['evidence_status' => $totalItems > 0 ? 'completed' : 'skipped']);

        Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — zip done, {$totalItems} total evidence item(s) across " . count($members) . ' member(s).');
    }

    // ── Core pipeline ────────────────────────────────────────────────────────

    /**
     * Runs stages 1–3 (verify → filter → extract) for a single file and saves any evidence
     * items found under the given document. Used for the document's own file, or once per
     * member when the document is a zip bundle.
     *
     * @return array{status: string, skip_reason: ?string, is_dsar_document: ?bool, item_ids: int[]}
     *               status is one of: ok, skipped, failed, deferred (deferred = handed off to
     *               the async OCR pipeline; only possible when $allowOcrDispatch is true)
     */
    private function extractFromFile(
        string $filename,
        string $mime,
        array $perPageText,
        Files\Document|Files\Image $file,
        int $documentId,
        ?int $solicitorId,
        ?int $dsarId,
        Document $subject,
        bool $allowOcrDispatch,
    ): array {
        $pageCount = count($perPageText);
        $isLarge   = $pageCount >= self::LARGE_DOC_THRESHOLD;

        // ── Stage 1: Verify ───────────────────────────────────────────────
        if ($isLarge) {
            // Large docs are almost certainly real records — skip the AI verify step.
            $isDsarDocument = true;
        } else {
            $verify     = (new VerifyDsarDocumentAgent)->prompt(
                "Filename: {$filename}\n"
                    . 'Document type: ' . ($subject->type?->display_name ?? 'Unknown') . "\n"
                    . "MIME type: {$mime}",
                attachments: [$file]
            );
            $verifyData = $verify->structured;

            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, is_dsar_document={$verifyData['is_dsar_document']}, confidence={$verifyData['confidence']}.");

            if (!$verifyData['is_dsar_document'] && in_array($verifyData['confidence'], ['high', 'medium'], true)) {
                return [
                    'status'           => 'skipped',
                    'skip_reason'      => "verify: not a DSAR document ({$verifyData['confidence']} confidence)",
                    'is_dsar_document' => false,
                    'item_ids'         => [],
                ];
            }

            // Any other outcome (a positive verdict, or a low-confidence negative one) is
            // treated as a DSAR document — matches the agent's own bias toward inclusion for
            // low-confidence calls.
            $isDsarDocument = true;
        }

        // ── Stage 2: Filter pages ─────────────────────────────────────────
        $relevantPageNums = [];
        $filterData        = null;

        if ($isLarge) {
            // Heuristic filter: skip pages with very little text (images, ECGs, blanks).
            $excludedPages = [];
            foreach ($perPageText as $i => $text) {
                $pageNum = $i + 1;
                if (mb_strlen(trim($text)) >= self::MIN_TEXT_CHARS) {
                    $relevantPageNums[] = $pageNum;
                } else {
                    $excludedPages[] = $pageNum;
                }
            }

            $this->recordSynapseRun('dsar_page_filter', $subject, [
                'method'      => 'heuristic',
                'total_pages' => $pageCount,
                'relevant'    => count($relevantPageNums),
                'excluded'    => count($excludedPages),
                'file'        => $filename,
            ], $solicitorId);

            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, heuristic filter: " . count($relevantPageNums) . ' relevant, ' . count($excludedPages) . ' excluded.');
        } else {
            $filter     = (new FilterDsarPagesAgent)->prompt("Filename: {$filename}", attachments: [$file]);
            $filterData = $filter->structured;
            $this->recordSynapseRun('dsar_page_filter', $subject, $filterData + ['file' => $filename], $solicitorId);

            $relevantPageNums = array_values(array_filter(
                array_map('intval', explode(',', $filterData['relevant_pages_csv'] ?? '')),
                fn ($n) => $n > 0
            ));

            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, AI filter: " . count($relevantPageNums) . " relevant of {$filterData['total_pages']}.");
        }

        $isScanned = false;
        if (empty($relevantPageNums)) {
            if ($isLarge) {
                // No text layer on any page of a large doc → almost certainly a scanned,
                // image-only PDF. Rather than silently skip it, fall back to sending the
                // file to the vision model (which can read the page images).
                $isScanned = true;
                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, no text layer on a large doc; treating as scanned.");
            } else {
                return [
                    'status'           => 'skipped',
                    'skip_reason'      => 'no relevant pages found',
                    'is_dsar_document' => $isDsarDocument,
                    'item_ids'         => [],
                ];
            }
        }

        // ── Stage 3: Extract evidence ─────────────────────────────────────
        $items = [];

        if ($isScanned) {
            // Prefer the OCR pipeline (exact page numbers + real bounding boxes). It runs
            // async in page-range batches and completes the doc itself, so return here.
            if ($allowOcrDispatch && app(\App\Services\OcrService::class)->isAvailable()) {
                OcrExtractEvidenceJob::dispatch('document', $documentId, $dsarId, 0, $pageCount);
                return [
                    'status'           => 'deferred',
                    'skip_reason'      => null,
                    'is_dsar_document' => $isDsarDocument,
                    'item_ids'         => [],
                ];
            }

            // OCR tooling unavailable (or not offered to zip members): fall back to a single
            // vision request for small scans; large scans can't be handled reliably without OCR.
            if ($pageCount > (int) config('ocr.vision_max_pages', 80)) {
                $reason = $allowOcrDispatch
                    ? "{$pageCount}-page scan but OCR tooling unavailable; install tesseract + poppler-utils or ghostscript."
                    : "{$pageCount}-page scanned zip member exceeds vision_max_pages; the async OCR pipeline isn't supported for zip contents.";

                return [
                    'status'           => 'failed',
                    'skip_reason'      => $reason,
                    'is_dsar_document' => $isDsarDocument,
                    'item_ids'         => [],
                ];
            }

            // Small enough to read directly: send the whole file to the vision model.
            $prompt  = "Filename: {$filename}\n"
                . "This document is scanned (image-only, {$pageCount} pages) with no extractable text layer. "
                . 'Read the pages visually and extract all evidence, noting the page number where each quote appears.';
            $extract = (new ExtractDsarEvidenceAgent)->prompt($prompt, attachments: [$file]);
            $items   = json_decode($extract->structured['items_json'] ?? '[]', true) ?? [];
        } elseif ($isLarge) {
            // Saved chunk-by-chunk (not accumulated to one save at the end) so a mid-loop
            // failure — a later chunk gets rate-limited, say — doesn't lose evidence already
            // extracted from earlier chunks.
            $chunks  = array_chunk($relevantPageNums, self::CHUNK_SIZE);
            $itemIds = [];

            foreach ($chunks as $chunkIndex => $chunkPages) {
                $chunkText = $this->buildChunkText($chunkPages, $perPageText);
                $prompt    = "Filename: {$filename} (chunk " . ($chunkIndex + 1) . ' of ' . count($chunks) . ")\n"
                    . 'Pages in this chunk: ' . implode(', ', $chunkPages) . "\n\n" . $chunkText;
                $extract    = (new ExtractDsarEvidenceAgent)->prompt($prompt);
                $chunkItems = json_decode($extract->structured['items_json'] ?? '[]', true) ?? [];
                $itemIds    = array_merge($itemIds, $this->saveItems($chunkItems, $documentId, $solicitorId, $dsarId));

                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, chunk " . ($chunkIndex + 1) . '/' . count($chunks) . ' extracted ' . count($chunkItems) . ' item(s).');
            }

            return [
                'status'           => 'ok',
                'skip_reason'      => null,
                'is_dsar_document' => $isDsarDocument,
                'item_ids'         => $itemIds,
            ];
        } else {
            $relevantPages = implode(', ', $relevantPageNums);
            $excludedNotes = trim($filterData['exclusion_notes'] ?? '');
            $prompt        = "Filename: {$filename}\n"
                . "Focus your analysis on pages: {$relevantPages}.\n"
                . ($excludedNotes ? "Skip these diagnostic-imagery pages: {$excludedNotes}.\n" : '');
            $extract     = (new ExtractDsarEvidenceAgent)->prompt($prompt, attachments: [$file]);
            $items       = json_decode($extract->structured['items_json'] ?? '[]', true) ?? [];
        }

        $itemIds = $this->saveItems($items, $documentId, $solicitorId, $dsarId);

        Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — file={$filename}, extraction produced " . count($itemIds) . ' item(s).');

        return [
            'status'           => 'ok',
            'skip_reason'      => null,
            'is_dsar_document' => $isDsarDocument,
            'item_ids'         => $itemIds,
        ];
    }

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

    private function parsePageText(string $storagePath): array
    {
        try {
            $contents = Storage::disk('public')->get($storagePath);
            return $this->parsePageTextFromContents($contents);
        } catch (\Throwable $e) {
            Log::warning("ExtractEvidenceFromDocumentJob: per-page parse failed for document {$this->documentId}: {$e->getMessage()}");
            return [];
        }
    }

    private function parsePageTextFromContents(string $contents): array
    {
        $pdf = $this->makePdfParser()->parseContent($contents);
        return array_map(fn ($page) => $page->getText(), $pdf->getPages());
    }

    /**
     * A memory-bounded PDF parser. Scanned medical/care records embed large images that
     * smalot/pdfparser otherwise retains in memory, exhausting the worker (an uncatchable
     * fatal). Dropping image content and capping per-stream decompression keeps it bounded —
     * we only need the text layer here anyway.
     */
    private function makePdfParser(): PdfParser
    {
        $config = new \Smalot\PdfParser\Config();
        $config->setRetainImageContent(false);
        $config->setDecodeMemoryLimit(100 * 1024 * 1024);

        return new PdfParser([], $config);
    }

    private function buildChunkText(array $pageNums, array $perPageText): string
    {
        $parts = [];
        foreach ($pageNums as $pageNum) {
            $text = $perPageText[$pageNum - 1] ?? '';
            if (trim($text) !== '') {
                $parts[] = "=== PAGE {$pageNum} ===\n" . trim($text);
            }
        }
        return implode("\n\n", $parts);
    }

    private function saveItems(array $items, int $documentId, ?int $solicitorId, ?int $dsarId): array
    {
        $ids = [];
        foreach ($items as $item) {
            if (empty($item['quote']) || empty($item['category'])) {
                continue;
            }
            $ids[] = DsarEvidenceItem::create([
                'solicitor_id' => $solicitorId,
                'document_id'  => $documentId,
                'dsar_id'      => $dsarId,
                'page'         => ($item['page'] ?? 0) ?: null,
                'quote'        => $item['quote'],
                'category'     => $item['category'],
                'severity'     => $item['severity'] ?? 'medium',
                'comment'      => $item['comment'] ?? '',
                'source'       => 'ai',
            ])->id;
        }
        return $ids;
    }

    /**
     * Resolve the owning claim for this document from its polymorphic owner
     * (Claim directly, a Dsar, or a Client), so we can offer the claim's care
     * settings as tagging candidates.
     */
    private function resolveClaim(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;
    }

    /**
     * Stage 4: suggest the source institution for an uploaded document that has none.
     * Suggestion is flagged pending until a human accepts/overrides. Never fails extraction.
     */
    private function detectSource(Document $document, array $perPageText, ?int $solicitorId): void
    {
        if ($document->institution_id) {
            return; // already sourced
        }

        try {
            $firstPages = mb_substr(trim(implode("\n\n", array_slice($perPageText, 0, 3))), 0, 6000);

            $prompt = "Filename: {$document->file_name}\n"
                . "Document type: " . ($document->type?->display_name ?? 'Unknown') . "\n"
                . "MIME type: {$document->file_type}\n\n"
                . ($firstPages !== ''
                    ? "First-pages text (letterhead / header):\n" . $firstPages
                    : "(No extractable text — infer from filename and type only.)");

            $response      = (new DocumentSourceIdentifierAgent)->prompt($prompt);
            $data          = $response->structured;
            $institutionId = $data['institution_id'] ?? null;
            $confidence    = $data['confidence'] ?? 'low';

            if ($institutionId
                && in_array($confidence, ['high', 'medium'], true)
                && Institution::whereKey($institutionId)->exists()) {
                $document->update([
                    'institution_id'           => $institutionId,
                    'institution_is_suggested' => true,
                ]);
                Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — source suggested institution {$institutionId} ({$confidence}).");
            }

            $this->recordSynapseRun('document_source_identifier', $document, [
                'institution_id' => $institutionId,
                'confidence'     => $confidence,
            ], $solicitorId);
        } catch (\Throwable $e) {
            Log::warning("ExtractEvidenceFromDocumentJob: source detection failed for document {$this->documentId}: {$e->getMessage()}");
        }
    }

    /**
     * Stage 5: suggest which of the claim's existing care settings each freshly-extracted
     * item relates to. Suggestions are flagged pending until a human accepts. Never fails extraction.
     *
     * @param  int[]  $itemIds
     */
    private function tagCareSettings(array $itemIds, Document $document, ?Claim $claim, ?int $solicitorId): void
    {
        if (empty($itemIds) || !$claim) {
            return;
        }

        try {
            $careSettingIds = $claim->careSettings->pluck('id')->all();
            if (empty($careSettingIds)) {
                return; // AI only matches existing care settings.
            }

            $items = DsarEvidenceItem::whereIn('id', $itemIds)->get(['id', 'quote']);
            if ($items->isEmpty()) {
                return;
            }

            $quoteLines = [];
            $indexToId  = [];
            foreach ($items->values() as $i => $item) {
                $indexToId[$i] = $item->id;
                $quoteLines[]  = "[{$i}] " . trim(mb_substr((string) $item->quote, 0, 500));
            }

            $prompt = CareSettingsContext::fromClaim($claim)
                . "\n\nEVIDENCE QUOTES:\n" . implode("\n", $quoteLines);

            $response = (new CareSettingTaggerAgent)->prompt($prompt);
            $mappings = json_decode($response->structured['mappings_json'] ?? '[]', true) ?: [];

            $tagged = 0;
            foreach ($mappings as $m) {
                $idx  = $m['index'] ?? null;
                $csId = $m['care_setting_id'] ?? null;
                $conf = $m['confidence'] ?? 'low';

                if (!isset($indexToId[$idx])
                    || !in_array($csId, $careSettingIds, true)
                    || !in_array($conf, ['high', 'medium'], true)) {
                    continue;
                }

                DsarEvidenceItem::where('id', $indexToId[$idx])->update([
                    'care_setting_id'           => $csId,
                    'care_setting_is_suggested' => true,
                ]);
                $tagged++;
            }

            $this->recordSynapseRun('care_setting_tagger', $document, [
                'tagged'     => $tagged,
                'candidates' => count($careSettingIds),
                'items'      => $items->count(),
            ], $solicitorId);

            Log::info("ExtractEvidenceFromDocumentJob: document {$this->documentId} — care-setting tagger suggested {$tagged} tag(s).");
        } catch (\Throwable $e) {
            Log::warning("ExtractEvidenceFromDocumentJob: care-setting tagging failed for document {$this->documentId}: {$e->getMessage()}");
        }
    }

    private function loadFile(Document $document, string $storagePath): Files\Document|Files\Image
    {
        $isImage = str_starts_with($document->file_type ?? '', 'image/');
        return $isImage
            ? Files\Image::fromStorage($storagePath, disk: 'public')
            : Files\Document::fromStorage($storagePath, disk: 'public');
    }

    private function loadFileFromPath(string $path, string $mime): Files\Document|Files\Image
    {
        return str_starts_with($mime, 'image/')
            ? Files\Image::fromPath($path)
            : Files\Document::fromPath($path);
    }

    private function recordSynapseRun(string $agentType, Document $document, array $result, ?int $solicitorId): void
    {
        if (!config('synapse.record_runs', true)) {
            return;
        }
        SynapseRun::create([
            'solicitor_id' => $solicitorId,
            'agent_type'   => $agentType,
            'subject_type' => Document::class,
            'subject_id'   => $document->id,
            'status'       => 'completed',
            'result'       => $result,
        ]);
    }
}
