<?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\Dsar\Dsar;
use App\Models\Dsar\DsarEvidenceItem;
use App\Models\Synapse\SynapseRun;
use App\Synapse\Agents\CareSettingTaggerAgent;
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 Laravel\Ai\Exceptions\RateLimitedException;
use Laravel\Ai\Files;
use Smalot\PdfParser\Parser as PdfParser;

class ExtractEvidenceFromAttachmentJob 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 the AI verify/filter steps 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 (filter heuristic). */
    private const MIN_TEXT_CHARS = 80;

    public function __construct(
        public readonly int  $attachmentId,
        public readonly ?int $dsarId = null,
    ) {}

    public function handle(): void
    {
        $attachment = ContactMessageAttachment::find($this->attachmentId);

        if (!$attachment) {
            Log::warning("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} not found.");
            return;
        }

        // Atomic claim: only one job instance may process this attachment at a time.
        // If another instance already set evidence_status = 'processing', this returns 0 and we bail out.
        $claimed = ContactMessageAttachment::where('id', $this->attachmentId)
            ->where(fn ($q) => $q->whereNull('evidence_status')->orWhere('evidence_status', '!=', 'processing'))
            ->update(['evidence_status' => 'processing']);

        if (!$claimed) {
            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} already processing — duplicate skipped.");
            return;
        }

        $attachment->refresh();

        // Resolve solicitor_id from the parent message — app(Solicitor::class) is null in queue context.
        $solicitorId = ContactMessage::where('id', $attachment->contact_message_id)->value('solicitor_id');

        // Stage 0: cheap junk pre-filter — tiny image attachments are signatures/logos, not records.
        // Skip them before spending an AI verify call.
        if (str_starts_with($attachment->mime ?? '', 'image/')
            && (int) $attachment->size > 0
            && (int) $attachment->size < config('synapse.min_image_evidence_bytes', 50000)) {
            $attachment->update(['evidence_status' => 'skipped', 'evidence_is_dsar_doc' => false]);
            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — skipped as small image ({$attachment->size} bytes), likely signature/logo.");
            return;
        }

        $zipExpander = new ZipExpander();

        try {
            // DSAR responses are sometimes delivered as a zip bundle of several files rather
            // than a single attachment. Expand it and run every member through the same
            // pipeline instead of sending the unreadable zip blob itself to the AI.
            if ($zipExpander->isZip($attachment->mime ?? '', $attachment->filename ?? '')) {
                $this->handleZipAttachment($attachment, $zipExpander, $solicitorId);
                return;
            }

            $isPdf = str_contains($attachment->mime ?? '', 'pdf');

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

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

            $file = $this->loadFile($attachment);

            $extraction = $this->extractFromFile(
                $attachment->filename,
                $attachment->mime ?? '',
                $perPageText,
                $file,
                $attachment->id,
                $solicitorId,
                $attachment,
                $attachment->extracted_text,
                allowOcrDispatch: true,
            );

            if ($extraction['is_dsar_document'] !== null) {
                $attachment->update(['evidence_is_dsar_doc' => $extraction['is_dsar_document']]);
            }

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

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

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

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

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

            $attachment->update(['evidence_status' => 'completed']);

            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — done, {$totalItems} total evidence items.");

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

            Log::error("ExtractEvidenceFromAttachmentJob: failed for attachment {$this->attachmentId}.", [
                'error' => $e->getMessage(),
            ]);

            report($e);
            $this->fail($e);
        } finally {
            $zipExpander->cleanup();
        }
    }

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

    /**
     * Expands a zip attachment and runs every member file through the same verify → filter →
     * extract pipeline as a standalone attachment, pooling all evidence items under this
     * attachment's id. 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 handleZipAttachment(ContactMessageAttachment $attachment, ZipExpander $zipExpander, ?int $solicitorId): void
    {
        $members = $zipExpander->expandFromContents($attachment->contents());

        if (empty($members)) {
            $attachment->update(['evidence_status' => 'skipped', 'evidence_is_dsar_doc' => false]);
            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — zip contained no usable files.");
            return;
        }

        Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — zip bundle with " . count($members) . ' member(s).');

        $createdItemIds = [];
        $anyIsDsar      = false;

        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("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — zip member {$member['filename']} PDF parse failed: {$e->getMessage()}");
                }
            }

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

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

            if ($extraction['is_dsar_document']) {
                $anyIsDsar = true;
            }
            $createdItemIds = array_merge($createdItemIds, $extraction['item_ids']);

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

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

        $this->tagCareSettings($createdItemIds, $attachment, $solicitorId);

        $attachment->update([
            'evidence_status'      => $totalItems > 0 ? 'completed' : 'skipped',
            'evidence_is_dsar_doc' => $anyIsDsar,
        ]);

        Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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 attachment. Used for the attachment's own file, or once per
     * member when the attachment 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 $attachmentId,
        ?int $solicitorId,
        ContactMessageAttachment $subject,
        ?string $extractedTextHint,
        bool $allowOcrDispatch,
    ): array {
        $pageCount = count($perPageText);
        $isLarge   = $pageCount >= self::LARGE_DOC_THRESHOLD;

        // ── Stage 1: Verify ───────────────────────────────────────────────
        // Large PDFs are almost certainly real data records; skip the AI verify step.
        if ($isLarge) {
            $isDsarDocument = true;
        } else {
            $verify     = (new VerifyDsarDocumentAgent)->prompt(
                "Filename: {$filename}\nMIME type: {$mime}",
                attachments: [$file]
            );
            $verifyData = $verify->structured;

            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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, so a "probably not, but not sure" verdict still gets
            // recorded as true rather than the raw (possibly false) verdict.
            $isDsarDocument = true;
        }

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

        if ($isLarge) {
            // Heuristic filter: skip pages with very little text (images, ECGs, blanks).
            $excludedSummary = [];

            foreach ($perPageText as $i => $text) {
                $pageNum = $i + 1; // 1-indexed
                if (mb_strlen(trim($text)) >= self::MIN_TEXT_CHARS) {
                    $relevantPageNums[] = $pageNum;
                } else {
                    $excludedSummary[] = $pageNum;
                }
            }

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

            Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — file={$filename}, heuristic filter: " . count($relevantPageNums) . ' relevant, ' . count($excludedSummary) . ' 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("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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 attachment itself, so return here.
            if ($allowOcrDispatch && app(\App\Services\OcrService::class)->isAvailable()) {
                OcrExtractEvidenceJob::dispatch('attachment', $attachmentId, $this->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) {
            // Chunk relevant pages and extract text-only (no file attachment). 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, $attachmentId, $solicitorId));

                Log::info("ExtractEvidenceFromAttachmentJob: attachment {$this->attachmentId} — 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 {
            // Small doc: send the file directly for visual context.
            $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" : '')
                . ($extractedTextHint
                    ? "\nExtracted text for reference:\n" . substr($extractedTextHint, 0, 40000)
                    : '');

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

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

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

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

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

    /**
     * Extract text from each page of a PDF. Returns array indexed 0..N-1.
     * Returns [] for non-PDFs or if parsing fails.
     */
    private function parsePageText(ContactMessageAttachment $attachment): array
    {
        try {
            return $this->parsePageTextFromContents($attachment->contents());
        } catch (\Throwable $e) {
            Log::warning("ExtractEvidenceFromAttachmentJob: per-page parse failed for attachment {$this->attachmentId}: {$e->getMessage()}");
            return [];
        }
    }

    /**
     * Extract per-page text from raw PDF bytes. Returns array indexed 0..N-1.
     */
    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);
    }

    /**
     * Build a text block for a set of page numbers from the per-page text array.
     */
    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);
    }

    /**
     * Persist a batch of evidence items returned by the agent.
     * Returns the ids of the items created.
     */
    private function saveItems(array $items, int $attachmentId, ?int $solicitorId): array
    {
        $ids = [];
        foreach ($items as $item) {
            if (empty($item['quote']) || empty($item['category'])) {
                continue;
            }
            $ids[] = DsarEvidenceItem::create([
                'solicitor_id'                  => $solicitorId,
                'contact_message_attachment_id' => $attachmentId,
                'dsar_id'                        => $this->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 attachment (via the DSAR it is affixed to),
     * so we can offer the claim's care settings as tagging candidates.
     */
    private function resolveClaim(ContactMessageAttachment $attachment): ?Claim
    {
        if ($this->dsarId) {
            $claim = Dsar::find($this->dsarId)?->claim;
            if ($claim) {
                return $claim;
            }
        }

        $messageable = ContactMessage::find($attachment->contact_message_id)?->messageable;

        return $messageable instanceof Dsar ? $messageable->claim : null;
    }

    /**
     * Stage 5: ask Synapse to suggest which of the claim's existing care settings each
     * freshly-extracted item relates to. Suggestions are flagged pending until a human
     * accepts/overrides them. Never fails the extraction — logs and moves on.
     *
     * @param  int[]  $itemIds  ids of items created during this run
     */
    private function tagCareSettings(array $itemIds, ContactMessageAttachment $attachment, ?int $solicitorId): void
    {
        if (empty($itemIds)) {
            return;
        }

        try {
            $claim = $this->resolveClaim($attachment);
            if (!$claim) {
                return;
            }

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

            $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', $attachment, [
                'tagged'     => $tagged,
                'candidates' => count($careSettingIds),
                'items'      => $items->count(),
            ], $solicitorId);

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

    private function loadFile(ContactMessageAttachment $attachment): Files\Document|Files\Image
    {
        $isImage = str_starts_with($attachment->mime ?? '', 'image/');

        return $isImage
            ? Files\Image::fromStorage($attachment->path, disk: $attachment->disk)
            : Files\Document::fromStorage($attachment->path, disk: $attachment->disk);
    }

    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);
    }

    /**
     * Called by Laravel when the job fails permanently (all retries exhausted or ->fail() called).
     * Ensures evidence_status never gets stuck at 'processing'.
     */
    public function failed(\Throwable $exception): void
    {
        $attachment = ContactMessageAttachment::find($this->attachmentId);
        $attachment?->update(['evidence_status' => 'failed']);

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

    private function recordSynapseRun(string $agentType, ContactMessageAttachment $attachment, array $result, ?int $solicitorId = null): void
    {
        if (!config('synapse.record_runs', true)) {
            return;
        }

        SynapseRun::create([
            'solicitor_id' => $solicitorId,
            'agent_type'   => $agentType,
            'subject_type' => ContactMessageAttachment::class,
            'subject_id'   => $attachment->id,
            'status'       => 'completed',
            'result'       => $result,
        ]);
    }
}
