<?php

namespace App\Jobs;

use App\Services\AutoCount\AutoCountClient;
use App\Services\AutoCount\DesktopRouting;
use App\Services\AutoCount\AutoCountWritePayloadBuilder;
use App\Services\DataverseService;
use App\Services\DocumentErrorService;
use App\Services\SharePointService;
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\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Pushes selected invoice/cashbook documents (or JEs) into AutoCount Desktop via the cr6f2_autocountjobs write-job queue. Runs on the database queue (NOT inline)
 * so a batch of N high-priority write jobs polled at 1 Hz doesn't trip PHP-FPM's request_terminate_timeout — same reason AutocountSyncJob moved off the inline path.
 *
 * Status is tracked in the cache under $cacheKey for the frontend to poll:
 *   { status: pending|running|completed|failed, total, done, summary, results[], ... }
 */
class UploadToAutocountJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // AC Cloud REST route map now lives on AutoCountWritePayloadBuilder::CLOUD_ROUTES
    // (single source of truth shared with edit/delete). Resolve via cloudRouteFor().

    /** Never retry — Cloud POST creates a real AC document; retries risk duplicates. */
    public $tries = 1;

    /**
     * A large batch polls the Desktop worker per document, so it can exceed the
     * worker's `--timeout=300`. Override to 15 min so a big upload isn't killed
     * mid-batch (which, with $tries=1, would leave some docs created and some not).
     */
    public $timeout = 900;

    /** Resolved in handle() via method injection; used by the per-doc apply paths. */
    private ?DocumentErrorService $docErrors = null;

    // ── Async reconcile state (Desktop) ─────────────────────────────────────
    // When $reconcileMode is true this job instance is a reconcile pass that
    // handleDesktop dispatched (and that reconcile() re-dispatches every ~15s).
    // Public so the queue serializer carries them across the delayed chain.
    public bool    $reconcileMode     = false;
    public array   $reconcileBuilt    = [];   // localKey => {docId,naming,jeName?,linkedDocIds?,linkedDocs?}
    public array   $reconcilePending  = [];   // localKey => AC job-row GUID still pending
    public array   $reconcileResults  = [];   // docId => outcome accumulated so far
    public int     $reconcileTotal    = 0;    // total docs in the batch (for progress)
    public ?string $reconcileDeadline = null; // ISO; past this, remaining = "still finishing"

    /**
     * @param string      $companyId
     * @param string      $accountBook  Desktop: cr6f2_acdbname. Cloud: pass '' (unused).
     * @param string[]    $docIds       document GUIDs (invoice/cashbook) OR JE GUIDs.
     * @param string      $doctype      'invoice' | 'cashbook' | 'journalentry'.
     * @param string|null $userGuid
     * @param string|null $userEmail  dispatcher's email — the only attribution anchor
     *        for a self-registered user, who has no systemusers row at all.
     * @param string      $cacheKey     status cache key (e.g. "ac_upload_{companyId}").
     * @param string|null $acApiEndpoint  Cloud: base URL with trailing slash. null = Desktop path.
     * @param string|null $acApiKey       Cloud: API-Key header value.
     * @param string|null $acKeyId        Cloud: Key-ID header value.
     */
    public function __construct(
        private string  $companyId,
        private string  $accountBook,
        private array   $docIds,
        private string  $doctype,
        private ?string $userGuid,
        private string  $cacheKey,
        private ?string $acApiEndpoint = null,
        private ?string $acApiKey      = null,
        private ?string $acKeyId       = null,
        private ?string $userEmail     = null,
    ) {}

    public function handle(DataverseService $dv, AutoCountClient $ac, AutoCountWritePayloadBuilder $builder, SharePointService $sp, DocumentErrorService $docErrors): void
    {
        set_time_limit(0);
        $this->docErrors = $docErrors;
        if ($this->reconcileMode) {
            $this->reconcile($dv, $ac, $sp);
        } elseif ($this->acApiEndpoint && $this->acApiKey && $this->acKeyId) {
            $this->handleCloud($dv, $builder, $sp);
        } else {
            $this->handleDesktop($dv, $ac, $builder, $sp);
        }
    }

    /**
     * Called by Laravel when the job is moved to failed_jobs for any reason
     * (MaxAttemptsExceededException, worker crash, etc.) without handle() completing.
     * Updates the cache so the frontend stops polling instead of getting stuck forever.
     */
    public function failed(Throwable $exception): void
    {
        Cache::put($this->cacheKey, [
            'status'      => 'failed',
            'error'       => $exception->getMessage(),
            'finished_at' => now()->toISOString(),
        ], 3600);
    }

    private function handleDesktop(DataverseService $dv, AutoCountClient $ac, AutoCountWritePayloadBuilder $builder, SharePointService $sp): void
    {
        $isJe  = $this->doctype === 'journalentry';
        $total = count($this->docIds);

        Cache::put($this->cacheKey, [
            'status'     => 'running',
            'total'      => $total,
            'done'       => 0,
            'started_at' => now()->toISOString(),
        ], 3600);

        try {
            // 1. Build a payload per document. Build failures become per-doc results immediately and never enter the dispatch batch.
            $specs    = [];   // localKey => ['type'=>, 'params'=>]
            $built    = [];   // localKey => build result (type, docId, linkedDocIds…)
            $results  = [];   // docId => ['docId'=>, 'ok'=>, 'docNo'?=>, 'error'?=>]

            foreach ($this->docIds as $i => $docId) {
                // Already-exported guard (mirrors handleCloud) — a re-dispatch (double-click,
                // retry while the prior reconcile is still pending) must not build+dispatch a
                // second write for a doc AutoCount already has, or the Desktop worker creates
                // a duplicate document.
                if ($isJe) {
                    try {
                        $jeCheck = $dv->find('cr6f2_journalentries', $docId, ['select' => ['cr6f2_jestatus']]);
                        if (($jeCheck['cr6f2_jestatus'] ?? null) === 521880002) {
                            try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => null]); } catch (Throwable) {}
                            $results[$docId] = ['docId' => $docId, 'ok' => true, 'skipped' => true];
                            continue;
                        }
                    } catch (Throwable) { /* non-fatal — proceed */ }
                } else {
                    try {
                        $docCheck = $dv->find('cr6f2_documentses', $docId, ['select' => ['cr6f2_exporttoautocount', 'cr6f2_ocrstatus']]);
                        if (!empty($docCheck['cr6f2_exporttoautocount'])) {
                            if (($docCheck['cr6f2_ocrstatus'] ?? null) === 521880002) {
                                try { $dv->update('cr6f2_documentses', $docId, ['cr6f2_ocrstatus' => 521880003]); } catch (Throwable) {}
                            }
                            $results[$docId] = ['docId' => $docId, 'ok' => true, 'skipped' => true];
                            continue;
                        }
                    } catch (Throwable) { /* non-fatal — proceed */ }
                }

                // JE duplicate (Dataverse): don't create in AC — soft Pending Move + warning.
                if ($isJe) {
                    $dupMsg = $this->jeDuplicateWarning($dv, $docId);
                    if ($dupMsg !== null) {
                        $this->applyJeDuplicate($dv, $docId, $dupMsg);
                        $results[$docId] = ['docId' => $docId, 'ok' => true, 'docNo' => null, 'duplicate' => true, 'warning' => $dupMsg];
                        $this->writeLog($dv, 521880000, "JE OCR Autocount Export — {$dupMsg}", null);
                        continue;
                    }
                }

                $b = $isJe
                    ? $builder->buildForJournalEntry($docId, $this->accountBook)
                    : $builder->buildForDocument($docId, $this->accountBook);

                if (empty($b['ok'])) {
                    $err = $b['error'] ?? 'Could not build payload.';
                    $results[$docId] = ['docId' => $docId, 'ok' => false, 'error' => $err];
                    $this->logExportResult($dv, $isJe, $b, false, $err, null);
                    continue;
                }

                $key             = "doc_{$i}";
                $specs[$key]     = ['type' => $b['type'], 'params' => $b['params']];
                $built[$key]     = $b;
            }

            // 2. Dispatch the whole batch, then HAND OFF to the async reconciler
            //    instead of blocking here. The Desktop agent creates docs at
            //    ~10s each, so watching inline would blow any fixed poll budget on
            //    a large batch and report false "timed out" failures. reconcile()
            //    re-checks the job rows every ~15s and applies each doc's
            //    side-effects as AutoCount actually finishes it.
            if ($specs !== []) {
                // Route these desktop write jobs to the company's site/server (multi-site).
                $routing = DesktopRouting::resolve($dv, $this->companyId, config('autocount.default_site'));
                $ac->setDesktopRouting($routing['site'], $routing['serverIp']);
                $jobIds = $ac->dispatchDesktopWrites($specs);

                Cache::put($this->cacheKey, [
                    'status'     => 'running',
                    'total'      => $total,
                    'done'       => count($results), // build-failed docs already counted
                    'started_at' => now()->toISOString(),
                ], 3600);

                // Carry only the fields the reconciler needs (drop the bulky AC
                // params) so the serialized delayed-job payload stays small.
                $carry = [];
                foreach ($built as $key => $b) {
                    $carry[$key] = [
                        'docId'        => $b['docId']        ?? null,
                        'type'         => $b['type']         ?? null,
                        'naming'       => $b['naming']       ?? [],
                        'jeName'       => $b['jeName']       ?? null,
                        'linkedDocIds' => $b['linkedDocIds'] ?? [],
                        'linkedDocs'   => $b['linkedDocs']   ?? [],
                    ];
                }

                $reconciler = new self($this->companyId, $this->accountBook, [], $this->doctype, $this->userGuid, $this->cacheKey, null, null, null, $this->userEmail);
                $reconciler->reconcileMode     = true;
                $reconciler->reconcileBuilt    = $carry;
                $reconciler->reconcilePending  = $jobIds;   // localKey => AC job-row GUID
                $reconciler->reconcileResults  = $results;  // build failures (if any) carried forward
                $reconciler->reconcileTotal    = $total;
                $reconciler->reconcileDeadline = now()->addSeconds(max(720, $total * 15 + 120))->toISOString();
                dispatch($reconciler)->delay(now()->addSeconds(8));
                return; // the reconciler owns progress + the final 'completed' write
            }

            // Nothing dispatchable (every doc failed to build) → finalize now.
            $okCount     = count(array_filter($results, fn($r) => !empty($r['ok'])));
            $failedCount = $total - $okCount;
            Cache::put($this->cacheKey, [
                'status'      => 'completed',
                'total'       => $total,
                'done'        => $total,
                'summary'     => ['ok' => $okCount, 'failed' => $failedCount, 'warnings' => 0],
                'results'     => array_values($results),
                'finished_at' => now()->toISOString(),
            ], 3600);

        } catch (Throwable $e) {
            $label  = $isJe ? 'JE' : ($this->doctype === 'cashbook' ? 'Cashbook' : 'Invoice');
            $prefix = "{$label} OCR Autocount Export Fail";
            $this->writeLog($dv, 521880002, "{$prefix} - {$e->getMessage()}", $e->getMessage());

            Cache::put($this->cacheKey, [
                'status'      => 'failed',
                'error'       => $e->getMessage(),
                'finished_at' => now()->toISOString(),
            ], 3600);
        }
    }

    /**
     * Async reconcile pass (Desktop). Dispatched by handleDesktop, then
     * re-dispatches ITSELF every ~15s until every AC job row is done/error or
     * the deadline passes — so nothing blocks waiting on the Desktop agent and
     * no false "timed out" failures are reported. Each run is short: one batched
     * poll, apply side-effects for the docs that just finished, reschedule.
     */
    private function reconcile(DataverseService $dv, AutoCountClient $ac, SharePointService $sp): void
    {
        $isJe  = $this->doctype === 'journalentry';
        $total = $this->reconcileTotal;

        try {
            $rows = $ac->readRowsBatch(array_values($this->reconcilePending)); // one query per ~15 jobs
            foreach ($this->reconcilePending as $key => $jobId) {
                $row    = $rows[$jobId] ?? null;
                $status = $row['cr6f2_status'] ?? null;
                if ($status === 'done') {
                    $raw     = $row['cr6f2_result'] ?? '';
                    $decoded = $raw === '' ? [] : json_decode($raw, true);
                    $this->applyDesktopResult($dv, $sp, $key, ['result' => is_array($decoded) ? $decoded : []]);
                    unset($this->reconcilePending[$key], $this->reconcileBuilt[$key]);
                } elseif ($status === 'error') {
                    $this->applyDesktopResult($dv, $sp, $key, ['error' => (string) ($row['cr6f2_error'] ?? 'unknown error')]);
                    unset($this->reconcilePending[$key], $this->reconcileBuilt[$key]);
                }
                // else: still pending → leave for the next pass
            }
        } catch (Throwable $e) {
            // A transient poll error must not break the chain — log and reschedule.
            Log::warning('AutoCount reconcile poll failed; will retry', ['error' => $e->getMessage()]);
        }

        $done           = $total - count($this->reconcilePending);
        $beforeDeadline = $this->reconcileDeadline === null
            || now()->lt(Carbon::parse($this->reconcileDeadline));

        // Still pending and within the deadline → reschedule another short pass.
        if (!empty($this->reconcilePending) && $beforeDeadline) {
            Cache::put($this->cacheKey, [
                'status'     => 'running',
                'total'      => $total,
                'done'       => $done,
                'started_at' => now()->toISOString(),
            ], 3600);

            $next = new self($this->companyId, $this->accountBook, [], $this->doctype, $this->userGuid, $this->cacheKey, null, null, null, $this->userEmail);
            $next->reconcileMode     = true;
            $next->reconcileBuilt    = $this->reconcileBuilt;
            $next->reconcilePending  = $this->reconcilePending;
            $next->reconcileResults  = $this->reconcileResults;
            $next->reconcileTotal    = $total;
            $next->reconcileDeadline = $this->reconcileDeadline;
            dispatch($next)->delay(now()->addSeconds(15));
            return;
        }

        // Deadline reached with rows still pending → mark them "still finishing"
        // (Info, NOT a failure — the agent will create them shortly).
        foreach ($this->reconcilePending as $key => $jobId) {
            $b = $this->reconcileBuilt[$key] ?? null;
            if ($b === null) continue;
            $docId = $b['docId'];
            $msg   = 'AutoCount is still creating this document — please verify in AutoCount.';
            $this->reconcileResults[$docId] = ['docId' => $docId, 'ok' => true, 'pending' => true, 'warning' => $msg];
            $label = $isJe
                ? ('JE OCR ' . trim((string) ($b['jeName'] ?? '')))
                : (($this->doctype === 'cashbook' ? 'Cashbook' : 'Invoice') . ' OCR');
            $this->writeLog($dv, 521880000, "{$label} Autocount Export — still finishing (verify in AutoCount)", null, $isJe ? null : $docId);
            if ($isJe) {
                try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => mb_substr($msg, 0, 500)]); } catch (Throwable) {}
            }
        }

        $results      = $this->reconcileResults;
        $okCount      = count(array_filter($results, fn($r) => !empty($r['ok']) && empty($r['pending'])));
        $failedCount  = count(array_filter($results, fn($r) => empty($r['ok'])));
        $pendingCount = count(array_filter($results, fn($r) => !empty($r['pending'])));
        $warningCount = count(array_filter($results, fn($r) => !empty($r['warning'])));

        Cache::put($this->cacheKey, [
            'status'      => 'completed',
            'total'       => $total,
            'done'        => $total,
            'summary'     => ['ok' => $okCount, 'failed' => $failedCount, 'pending' => $pendingCount, 'warnings' => $warningCount],
            'results'     => array_values($results),
            'finished_at' => now()->toISOString(),
        ], 3600);
    }

    /**
     * Apply one Desktop write result (done/error/duplicate) to its source
     * document: side-effects, file rename, per-doc system log, and accumulate
     * into reconcileResults. Idempotent — skips if the document is already
     * flagged exported, so a re-run never double-applies or double-renames.
     */
    private function applyDesktopResult(DataverseService $dv, SharePointService $sp, string $key, array $wr): void
    {
        $isJe = $this->doctype === 'journalentry';
        $b    = $this->reconcileBuilt[$key] ?? null;
        if ($b === null) return;
        $docId = $b['docId'];

        if ($this->alreadyExported($dv, $isJe, $docId)) {
            $this->reconcileResults[$docId] ??= ['docId' => $docId, 'ok' => true, 'skipped' => true];
            return;
        }

        if (isset($wr['error'])) {
            [$err, $isAcRejection] = $this->friendlyWorkerError($wr['error']);
            $this->reconcileResults[$docId] = ['docId' => $docId, 'ok' => false, 'error' => $err];
            $this->logExportResult($dv, $isJe, $b, false, $err, null, $isAcRejection);
            if ($isJe) {
                try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => mb_substr($err, 0, 500)]); } catch (Throwable) {}
            } else {
                $this->docErrors?->flagAcUploadFailed($docId, $err);
            }
            return;
        }

        $result = $wr['result'] ?? [];
        $docNo  = $result['docNo'] ?? null;
        $isDup  = ($result['status'] ?? null) === 'duplicate';

        // Duplicate (Desktop worker) — already in AutoCount Desktop. Stay Completed
        // (do NOT move to Pending Move / rename); just flag the warning.
        if ($isDup) {
            if (!$isJe) {
                try { $dv->update('cr6f2_documentses', $docId, ['cr6f2_acduplicatewarning' => true]); } catch (Throwable) {}
                $this->docErrors?->flagAcDuplicate($docId, 'AutoCount Desktop');
            }
            $this->reconcileResults[$docId] = ['docId' => $docId, 'ok' => true, 'docNo' => $docNo, 'duplicate' => true, 'warning' => 'Duplicate — invoice already exists in AutoCount Desktop.'];
            $this->logExportResult($dv, $isJe, $b, true, null, $docNo);
            return;
        }

        $this->applySideEffects($dv, $isJe, $docId, $docNo, $b['linkedDocIds'] ?? []);
        $warning = $this->renameUploadedFiles($dv, $sp, $isJe, $b, $docNo);

        $row = ['docId' => $docId, 'ok' => true, 'docNo' => $docNo];
        if ($warning !== null) {
            $row['warning'] = $warning;
        }
        $this->reconcileResults[$docId] = $row;

        $this->logExportResult($dv, $isJe, $b, true, null, $docNo);
        if ($isJe) {
            try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => null]); } catch (Throwable) {}
        }
    }

    /** True if the document/JE is already flagged exported (idempotency guard). */
    private function alreadyExported(DataverseService $dv, bool $isJe, string $docId): bool
    {
        try {
            if ($isJe) {
                $r = $dv->find('cr6f2_journalentries', $docId, ['select' => ['cr6f2_jestatus']]);
                return ($r['cr6f2_jestatus'] ?? null) === 521880002;
            }
            $r = $dv->find('cr6f2_documentses', $docId, ['select' => ['cr6f2_exporttoautocount']]);
            return !empty($r['cr6f2_exporttoautocount']);
        } catch (Throwable) {
            return false; // can't check → proceed (better to apply than skip)
        }
    }

    private function handleCloud(DataverseService $dv, AutoCountWritePayloadBuilder $builder, SharePointService $sp): void
    {
        $isJe  = $this->doctype === 'journalentry';
        $total = count($this->docIds);

        Cache::put($this->cacheKey, [
            'status'     => 'running',
            'total'      => $total,
            'done'       => 0,
            'started_at' => now()->toISOString(),
        ], 3600);

        try {
            $results = [];
            $done    = 0;

            foreach ($this->docIds as $docId) {
                // ── Duplicate-upload protection ──
                if ($isJe) {
                    try {
                        $jeCheck = $dv->find('cr6f2_journalentries', $docId, ['select' => ['cr6f2_jestatus']]);
                        if (($jeCheck['cr6f2_jestatus'] ?? null) === 521880002) {
                            // Already in Pending Move = already in AC. Clear any stale error from previous attempts.
                            try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => null]); } catch (Throwable) {}
                            $results[$docId] = ['docId' => $docId, 'ok' => true, 'skipped' => true];
                            $done++;
                            Cache::put($this->cacheKey, ['status' => 'running', 'total' => $total, 'done' => $done, 'started_at' => now()->toISOString()], 3600);
                            continue;
                        }
                    } catch (Throwable) { /* non-fatal — proceed */ }
                } else {
                    try {
                        $docCheck = $dv->find('cr6f2_documentses', $docId, ['select' => ['cr6f2_exporttoautocount', 'cr6f2_ocrstatus']]);
                        if (!empty($docCheck['cr6f2_exporttoautocount'])) {
                            // Already exported. Ensure status is Pending Move in case PA left it wrong —
                            // but only normalize FROM Completed (521880002). A doc already Stored
                            // (521880004) has moved past Pending Move; forcing it back would
                            // reverse that terminal state and re-queue it for another move.
                            if (($docCheck['cr6f2_ocrstatus'] ?? null) === 521880002) {
                                try { $dv->update('cr6f2_documentses', $docId, ['cr6f2_ocrstatus' => 521880003]); } catch (Throwable) {}
                            }
                            $results[$docId] = ['docId' => $docId, 'ok' => true, 'skipped' => true];
                            $done++;
                            Cache::put($this->cacheKey, ['status' => 'running', 'total' => $total, 'done' => $done, 'started_at' => now()->toISOString()], 3600);
                            continue;
                        }
                    } catch (Throwable) { /* non-fatal — proceed */ }
                }

                // ── JE duplicate (Dataverse): don't create in AC — soft Pending Move + warning ──
                if ($isJe) {
                    $dupMsg = $this->jeDuplicateWarning($dv, $docId);
                    if ($dupMsg !== null) {
                        $this->applyJeDuplicate($dv, $docId, $dupMsg);
                        $results[$docId] = ['docId' => $docId, 'ok' => true, 'docNo' => null, 'duplicate' => true, 'warning' => $dupMsg];
                        $this->writeLog($dv, 521880000, "JE OCR Autocount Export — {$dupMsg}", null);
                        $done++;
                        Cache::put($this->cacheKey, ['status' => 'running', 'total' => $total, 'done' => $done, 'started_at' => now()->toISOString()], 3600);
                        continue;
                    }
                }

                // ── Build Cloud payload ──
                $b = $isJe
                    ? $builder->buildForJournalEntryCloud($docId)
                    : $builder->buildForDocumentCloud($docId);

                if (empty($b['ok'])) {
                    $err = $b['error'] ?? 'Could not build payload.';
                    $results[$docId] = ['docId' => $docId, 'ok' => false, 'error' => $err];
                    $this->logExportResult($dv, $isJe, $b, false, $err, null);
                    $done++;
                    Cache::put($this->cacheKey, ['status' => 'running', 'total' => $total, 'done' => $done, 'started_at' => now()->toISOString()], 3600);
                    continue;
                }

                $route = AutoCountWritePayloadBuilder::cloudRouteFor($b['type']);
                if ($route === null) {
                    $err = "No Cloud route for document type {$b['type']}.";
                    $results[$docId] = ['docId' => $docId, 'ok' => false, 'error' => $err];
                    $this->logExportResult($dv, $isJe, $b, false, $err, null);
                    $done++;
                    Cache::put($this->cacheKey, ['status' => 'running', 'total' => $total, 'done' => $done, 'started_at' => now()->toISOString()], 3600);
                    continue;
                }

                // ── POST to AC Cloud ──
                $response = Http::withHeaders([
                        'API-Key' => $this->acApiKey,
                        'Key-ID'  => $this->acKeyId,
                        'Accept'  => 'application/json',
                    ])
                    ->timeout(30)
                    ->post($this->acApiEndpoint . $route, $b['params']);

                if ($response->status() === 201) {
                    $docNo   = $this->extractDocNoFromLocation($response->header('Location'));
                    $this->applySideEffects($dv, $isJe, $docId, $docNo, $b['linkedDocIds'] ?? []);
                    $warning = $this->renameUploadedFiles($dv, $sp, $isJe, $b, $docNo);
                    $row = ['docId' => $docId, 'ok' => true, 'docNo' => $docNo];
                    if ($warning !== null) $row['warning'] = $warning;
                    $results[$docId] = $row;
                    $this->logExportResult($dv, $isJe, $b, true, null, $docNo);
                } elseif ($response->status() === 400 && $this->isDuplicateResponse($response->body())) {
                    // Already exists in AutoCount Cloud — stay Completed (do NOT move to
                    // Pending Move); just flag the duplicate warning. AC created nothing.
                    if (!$isJe) {
                        try { $dv->update('cr6f2_documentses', $docId, ['cr6f2_acduplicatewarning' => true]); } catch (Throwable) {}
                        $this->docErrors?->flagAcDuplicate($docId, 'AutoCount Cloud');
                    } else {
                        try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => 'Duplicate — invoice already exists in AutoCount Cloud.']); } catch (Throwable) {}
                    }
                    $results[$docId] = ['docId' => $docId, 'ok' => true, 'docNo' => null, 'duplicate' => true, 'warning' => 'Duplicate — invoice already exists in AutoCount Cloud.'];
                    $this->logExportResult($dv, $isJe, $b, true, null, null);
                } else {
                    $err = "AutoCount Cloud upload failed. HTTP {$response->status()}: " . $response->body();
                    [$friendlyErr, $isAcRejection] = $this->friendlyWorkerError($err);
                    $results[$docId] = ['docId' => $docId, 'ok' => false, 'error' => $friendlyErr];
                    $this->logExportResult($dv, $isJe, $b, false, $friendlyErr, null, $isAcRejection);
                    // Persist the error on the JE so a warning icon shows in the list
                    if ($isJe) {
                        try { $dv->update('cr6f2_journalentries', $docId, ['cr6f2_acuploaderror' => mb_substr($friendlyErr, 0, 500)]); } catch (Throwable) {}
                    } else {
                        $this->docErrors?->flagAcUploadFailed($docId, $friendlyErr);
                    }
                }

                $done++;
                Cache::put($this->cacheKey, [
                    'status'     => 'running',
                    'total'      => $total,
                    'done'       => $done,
                    'started_at' => now()->toISOString(),
                ], 3600);
            }

            $skippedCount = count(array_filter($results, fn($r) => !empty($r['skipped'])));
            $okCount      = count(array_filter($results, fn($r) => $r['ok'] && empty($r['skipped'])));
            $failedCount  = count(array_filter($results, fn($r) => !$r['ok']));
            $warningCount = count(array_filter($results, fn($r) => !empty($r['warning'])));

            Cache::put($this->cacheKey, [
                'status'      => 'completed',
                'total'       => $total,
                'done'        => $total,
                'summary'     => ['ok' => $okCount, 'skipped' => $skippedCount, 'failed' => $failedCount, 'warnings' => $warningCount],
                'results'     => array_values($results),
                'finished_at' => now()->toISOString(),
            ], 3600);

        } catch (Throwable $e) {
            $label = $isJe ? 'JE' : ($this->doctype === 'cashbook' ? 'Cashbook' : 'Invoice');
            $this->writeLog($dv, 521880002, "{$label} OCR Autocount Export Fail - {$e->getMessage()}", $e->getMessage());

            Cache::put($this->cacheKey, [
                'status'      => 'failed',
                'error'       => $e->getMessage(),
                'finished_at' => now()->toISOString(),
            ], 3600);
        }
    }

    private function isDuplicateResponse(string $body): bool
    {
        $msg = strtolower(json_decode($body, true)['message'] ?? $body);
        return str_contains($msg, 'duplicate');
    }

    // ─── JE duplicate detection (Dataverse) ─────────────────────────────────
    //
    // AutoCount's journal-entry create has no supplier-invoice-no duplicate check
    // (that only exists for AP invoices), so a JE duplicate can only be known from
    // our own Dataverse records. Before pushing a JE we compare the invoice numbers
    // captured on its lines (cr6f2_invoicenumber) against OTHER JEs in the same
    // company. A hit → don't create in AutoCount; the caller applies the soft
    // outcome (Pending Move + cr6f2_acuploaderror warning).
    //
    // Deliberately NOT checked against invoices/cashbooks: on a pay-on-behalf JV the
    // client settles a supplier invoice that is already recorded as its own AP invoice
    // in AutoCount, so the same supplier invoice number legitimately appears on BOTH an
    // AP invoice and a JE. AutoCount already enforces supplier-invoice-no uniqueness on
    // the AP side itself, so only the JE-vs-JE gap needs guarding here.

    /**
     * Returns a duplicate warning message for the JE, or null when it's not a
     * duplicate. Company is the job's own company.
     */
    private function jeDuplicateWarning(DataverseService $dv, string $jeId): ?string
    {
        $companyId = $this->companyId;
        if ($jeId === '' || $companyId === '') {
            return null;
        }

        // Invoice numbers captured on this JE's own lines.
        try {
            $lines = $dv->get('cr6f2_juornalentrylines', [
                'select' => ['cr6f2_invoicenumber'],
                'filter' => "_cr6f2_jemapping_value eq {$jeId} and statecode eq 0",
                'top'    => 200,
            ]);
        } catch (Throwable) {
            return null; // can't read lines → don't block the push
        }

        $numbers = [];
        foreach (($lines['value'] ?? []) as $l) {
            $n = strtoupper(trim((string) ($l['cr6f2_invoicenumber'] ?? '')));
            if ($n !== '') {
                $numbers[$n] = true;
            }
        }
        if ($numbers === []) {
            return null;
        }

        foreach (array_keys($numbers) as $num) {
            $numEsc = str_replace("'", "''", $num);

            // Same number on a line belonging to a DIFFERENT JE in this company —
            // only if that JE actually reached AutoCount (Pending Move/Stored). Two
            // JEs can share an invoice number while both are still unpushed drafts;
            // that's not yet a real AutoCount conflict either.
            try {
                $res = $dv->get('cr6f2_juornalentrylines', [
                    'select' => ['_cr6f2_jemapping_value'],
                    'expand' => 'cr6f2_JEMapping($select=_cr6f2_companyid_value,cr6f2_jestatus)',
                    'filter' => "cr6f2_invoicenumber eq '{$numEsc}' and statecode eq 0 and _cr6f2_jemapping_value ne {$jeId}",
                    'top'    => 10,
                ]);
                foreach (($res['value'] ?? []) as $hit) {
                    $otherJe = $hit['cr6f2_JEMapping'] ?? null;
                    if (!in_array($otherJe['cr6f2_jestatus'] ?? null, [521880002, 521880003], true)) {
                        continue; // other JE never reached AutoCount — not a real conflict
                    }
                    if ($this->sameGuid($otherJe['_cr6f2_companyid_value'] ?? null, $companyId)) {
                        return "Duplicate — invoice number {$num} already exists in another Journal Entry in this company.";
                    }
                }
            } catch (Throwable) {}
        }

        return null;
    }

    /** Soft outcome for a duplicate JE: don't create in AC, stay Completed, warning only. */
    private function applyJeDuplicate(DataverseService $dv, string $jeId, string $msg): void
    {
        try {
            // Do NOT advance cr6f2_jestatus — the JE stays where it is, just flagged.
            $dv->update('cr6f2_journalentries', $jeId, [
                'cr6f2_acuploaderror' => mb_substr($msg, 0, 500),
            ]);
        } catch (Throwable) {}

        // Surface the same warning on the JE's linked document(s) so the warning
        // icon + popup shows it in the JE documents list — same mechanism the
        // invoice/cashbook duplicate path already uses (docErrors->flagAcDuplicate).
        foreach ($this->linkedDocumentIdsForJe($dv, $jeId) as $docId) {
            $this->docErrors?->flag($docId, DocumentErrorService::STAGE_AC_UPLOAD, DocumentErrorService::SEVERITY_WARNING, $msg);
        }
    }

    /**
     * Document IDs linked to a JE's lines. Goes via the document-side FK
     * (_cr6f2_journalentrymapping_value), not the line's own back-reference —
     * the Python OCR runner only sets the document-side FK (see moveJeToStorage
     * in InvoiceController for the same reasoning).
     */
    private function linkedDocumentIdsForJe(DataverseService $dv, string $jeId): array
    {
        try {
            $lines = $dv->get('cr6f2_juornalentrylines', [
                'select' => ['cr6f2_juornalentrylineid'],
                'filter' => "_cr6f2_jemapping_value eq {$jeId} and statecode eq 0",
                'top'    => 200,
            ]);
            $lineIds = array_column($lines['value'] ?? [], 'cr6f2_juornalentrylineid');
            if ($lineIds === []) return [];

            $filter = implode(' or ', array_map(fn($id) => "_cr6f2_journalentrymapping_value eq {$id}", $lineIds));
            $docs = $dv->get('cr6f2_documentses', [
                'select' => ['cr6f2_documentsid'],
                'filter' => $filter,
                'top'    => 200,
            ]);
            return array_values(array_unique(array_filter(array_column($docs['value'] ?? [], 'cr6f2_documentsid'))));
        } catch (Throwable) {
            return [];
        }
    }

    /** Case-insensitive GUID equality (Dataverse casing varies). */
    private function sameGuid(?string $a, ?string $b): bool
    {
        return $a !== null && $b !== null && strcasecmp(trim($a), trim($b)) === 0;
    }

    private function extractDocNoFromLocation(?string $location): ?string
    {
        if (!$location) return null;

        $parts = parse_url($location);
        if (!empty($parts['query'])) {
            parse_str($parts['query'], $query);
            return isset($query['docNo']) ? urldecode($query['docNo']) : null;
        }

        if (preg_match('/docNo=([^&]+)/', $location, $m)) {
            return urldecode($m[1]);
        }

        return null;
    }

    /**
     * On a successful write, mirror the Excel-export side effects: mark the
     * document(s) uploaded, store the AutoCount doc number, flip to Pending Move.
     */
    private function applySideEffects(DataverseService $dv, bool $isJe, string $docId, ?string $docNo, array $linkedDocIds): void
    {
        if ($isJe) {
            // JE record → Pending Move (jestatus); name + autocount number ← AutoCount docNo.
            // Name uses the slash-replaced docNo so it matches the renamed files' prefix.
            try {
                $patch = ['cr6f2_jestatus' => 521880002, 'cr6f2_acuploaderror' => null];
                if ($docNo !== null && $docNo !== '') {
                    $patch['cr6f2_journalentryname'] = str_replace('/', '-', $docNo);
                    $patch['cr6f2_autocountnumber']  = $docNo;
                }
                $dv->update('cr6f2_journalentries', $docId, $patch);
            } catch (Throwable) { /* non-fatal — the AutoCount doc was created */ }

            foreach ($linkedDocIds as $linkedDocId) {
                $this->patchDocument($dv, $linkedDocId, $docNo);
            }
            return;
        }

        $this->patchDocument($dv, $docId, $docNo);
    }

    private function patchDocument(DataverseService $dv, string $docId, ?string $docNo): void
    {
        try {
            $patch = [
                'cr6f2_exporttoautocount'     => true,
                'cr6f2_ocrstatus'             => 521880003, // Pending Move (matches export-excel)
                'cr6f2_exporttoautocountdate' => now()->format('Y-m-d\TH:i:s\Z'),
                'cr6f2_acduplicatewarning'    => false,
            ];
            if ($docNo !== null && $docNo !== '') {
                $patch['cr6f2_autocountnumber'] = $docNo;
            }
            $dv->update('cr6f2_documentses', $docId, $patch);
            // SCRUM-88 — a clean upload resolves any prior AutoCount issue on this doc.
            $this->docErrors?->clear($docId, DocumentErrorService::STAGE_AC_UPLOAD);
        } catch (Throwable) { /* non-fatal — the AutoCount doc was created */ }
    }

    // ─── Post-success file rename / move (mirrors the PA upload flows) ───────

    /**
     * Rename the invoice/cashbook source file, or for a JE create a folder + move +
     * rename every linked file. Returns a warning string if any file op failed
     * (non-fatal), or null on success / when there's nothing to rename.
     */
    private function renameUploadedFiles(DataverseService $dv, SharePointService $sp, bool $isJe, array $b, ?string $docNo): ?string
    {
        if ($docNo === null || $docNo === '') {
            return null; // No AutoCount doc number → nothing to name after.
        }

        try {
            return $isJe
                ? $this->renameJeFiles($dv, $sp, $b, $docNo)
                : $this->renameInvoiceFile($dv, $sp, $b, $docNo);
        } catch (Throwable $e) {
            return 'File rename/move failed: ' . $e->getMessage();
        }
    }

    /**
     * Invoice / Cashbook: rename the document's file in place to
     * {docNo}_{vendor 10}[_{invoiceNo|creditNoteNo}].{ext}, then update the
     * Dataverse record's name + link. Matches PA's per-doc-type naming.
     */
    private function renameInvoiceFile(DataverseService $dv, SharePointService $sp, array $b, string $docNo): ?string
    {
        $docId  = $b['docId'];
        $naming = $b['naming'] ?? [];

        $doc = $dv->find('cr6f2_documentses', $docId, [
            'select' => ['cr6f2_documentname', 'cr6f2_documentlink', 'cr6f2_documentstoredlink'],
        ]);
        $link = $doc['cr6f2_documentlink'] ?? ($doc['cr6f2_documentstoredlink'] ?? '');
        if ($link === '') {
            return 'No SharePoint file link on the document — skipped rename.';
        }

        $base = $this->buildInvoiceBaseName($docNo, $naming);
        $ext  = $this->extOf($doc['cr6f2_documentname'] ?? '', $link);
        $newFilename = $this->sanitizeName($base) . ($ext ? ".{$ext}" : '');

        $newLink = $sp->renameFile($link, $newFilename);

        try {
            $dv->update('cr6f2_documentses', $docId, [
                'cr6f2_documentname' => $newFilename,
                'cr6f2_documentlink' => $newLink,
            ]);
        } catch (Throwable $e) {
            return "File renamed but Dataverse record not updated: {$e->getMessage()}";
        }
        return null;
    }

    /**
     * JE: for each linked document, create a folder named {docNo} beside the file,
     * move the file in, rename it to {docNo}_{counter}_{desc10}_{sum}.{ext}, and
     * update the document record. Collects any per-file failures into one warning.
     */
    private function renameJeFiles(DataverseService $dv, SharePointService $sp, array $b, string $docNo): ?string
    {
        $linked = $b['linkedDocs'] ?? [];
        if (empty($linked)) {
            return null;
        }

        $folderName = str_replace('/', '-', $docNo);
        $counter    = 1;
        $problems   = [];

        foreach ($linked as $docId => $meta) {
            $link = $meta['link'] ?? '';
            if ($link === '') {
                $problems[] = 'a linked file had no SharePoint link';
                $counter++;
                continue;
            }

            try {
                // Folder is a sibling of the file's current folder.
                $fileFolderUrl = substr($link, 0, strrpos($link, '/'));
                $sp->ensureFolder($fileFolderUrl, $folderName);
                $destFolderUrl = $fileFolderUrl . '/' . rawurlencode($folderName);

                $movedLink = $sp->moveFile($link, $destFolderUrl);
                $movedFrom = $movedLink !== '' ? $movedLink : ($destFolderUrl . '/' . rawurlencode((string) ($meta['name'] ?? basename($link))));

                $desc = $this->sanitizeName($this->firstWords((string) ($meta['description'] ?? '')));
                $sum  = str_replace('.', '_', number_format((float) ($meta['sum'] ?? 0), 2, '.', ''));
                $ext  = $this->extOf((string) ($meta['name'] ?? ''), $link);
                $newFilename = "{$folderName}_{$counter}_{$desc}_{$sum}" . ($ext ? ".{$ext}" : '');

                $newLink = $sp->renameFile($movedFrom, $newFilename);

                try {
                    $dv->update('cr6f2_documentses', $docId, [
                        'cr6f2_documentname' => $newFilename,
                        'cr6f2_documentlink' => $newLink,
                    ]);
                } catch (Throwable $e) {
                    $problems[] = "doc {$docId}: moved/renamed but record not updated";
                }
            } catch (Throwable $e) {
                $problems[] = "doc {$docId}: " . $e->getMessage();
            }
            $counter++;
        }

        return empty($problems) ? null : ('Some JE files could not be moved/renamed: ' . implode('; ', $problems));
    }

    /** PA per-type base name (before sanitization + extension). */
    private function buildInvoiceBaseName(string $docNo, array $naming): string
    {
        $docPart    = str_replace('/', '-', $docNo);
        $vendorPart = substr(str_replace('.', '', (string) ($naming['vendorName'] ?? '')), 0, 10);
        $base       = "{$docPart}_{$vendorPart}";

        $jobType = $naming['jobType'] ?? '';
        if ($jobType === 'create_ap_invoice') {
            $num = str_replace('/', '-', (string) ($naming['invoiceNo'] ?? ''));
            if ($num !== '') $base .= "_{$num}";
        } elseif ($jobType === 'create_ap_credit_note') {
            $num = str_replace('/', '-', (string) ($naming['creditNoteNo'] ?? ''));
            if ($num !== '') $base .= "_{$num}";
        }
        return $base;
    }

    /** First segment of a description (split on '-'), trimmed, first 10 chars, or 'N/A'. */
    private function firstWords(string $description): string
    {
        $first = trim(explode('-', $description)[0] ?? '');
        if ($first === '') return 'N/A';
        return mb_substr($first, 0, 10, 'UTF-8');
    }

    /** Original file extension, preferring the document name, falling back to the link path. */
    private function extOf(string $name, string $link): string
    {
        $ext = pathinfo($name, PATHINFO_EXTENSION);
        if ($ext === '') {
            $ext = pathinfo(parse_url($link, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
        }
        return $ext;
    }

    /** The PA 25-char sanitization chain: any of `~@#$%&*{};"'/\|<>?:+^[], → _. */
    private function sanitizeName(string $s): string
    {
        return preg_replace('/[`~@#$%&*{};"\'\/\\\\|<>?:+^\[\],]/', '_', trim($s)) ?? $s;
    }

    /**
     * Turn a worker error string into a short, user-facing message (raw kept appended).
     * Returns [message, isAutocountRejection]. The second flag is true only when the
     * message came from AutoCount's own API/validation response (a business rejection
     * like "Transaction Date ... is locked" or an unposted account code) — as opposed
     * to a system-side failure (worker timeout, unparseable error) where we can't
     * confirm AutoCount actually rejected anything. logExportResult uses this to log
     * AutoCount's own rejections as Warning instead of Error.
     */
    private function friendlyWorkerError(string $err): array
    {
        if (preg_match('/HTTP 409|duplicate/i', $err)) {
            return ['Already created in AutoCount (duplicate).', true];
        }
        if (str_starts_with($err, 'ValueError:')) {
            return [trim(substr($err, strlen('ValueError:'))), false];
        }
        if (preg_match('/did not complete within/i', $err)) {
            return ['AutoCount worker timed out — it may still finish; check AutoCount.', false];
        }
        // Unwrap AutoCount's JSON envelope: {"statusCode":400,"message":"..."} or {"error":"..."}
        $acMsg = null;
        if (preg_match('/\{.*\}/s', $err, $m)) {
            $j = json_decode($m[0], true);
            $acMsg = $j['message'] ?? $j['error'] ?? null;
        }
        if ($acMsg !== null) {
            if (preg_match('/AccNo\s*"?([\w\/\-]+)"?\s*is not valid account for posting/i', $acMsg, $m)) {
                return ["Account code '{$m[1]}' can't be posted to in AutoCount — it may be a header or control account. Correct the account code and try again.", true];
            }
            if (preg_match('/not found|does not exist/i', $acMsg)) {
                return ["AutoCount rejected this — a code or reference wasn't found. Check account/tax/project codes and try again.", true];
            }
            return ["AutoCount rejected this: {$acMsg}", true];
        }
        return [$err, false];
    }

    /**
     * Write a per-document Export system log matching the Power Automate wording:
     *   Invoice/Cashbook → "Invoice OCR Autocount Export Pass|Fail"
     *   JE               → "JE OCR {name} Autocount Export Pass|Fail"
     * Pass → Info, Fail → Warning when AutoCount itself rejected the transaction
     * (see friendlyWorkerError), Error otherwise (+ error detail either way).
     *
     * For a successful JE the name is the new name written to the record — the
     * slash-replaced AutoCount docNo (e.g. JE-2605-001). When there's no docNo
     * (build failure), it falls back to the JE's pre-upload name from $b.
     */
    private function logExportResult(DataverseService $dv, bool $isJe, array $b, bool $ok, ?string $error, ?string $docNo, bool $isAutocountRejection = false): void
    {
        $verb = $ok ? 'Pass' : 'Fail';
        // Link the source document on invoice/cashbook logs. For JE, $b['docId'] is
        // the JE GUID (not a document), so leave the document lookup unset there.
        $documentId = $isJe ? null : ($b['docId'] ?? null);

        if ($isJe) {
            $name    = ($docNo !== null && $docNo !== '')
                ? str_replace('/', '-', $docNo)
                : trim((string) ($b['jeName'] ?? ''));
            $message = "JE OCR {$name} Autocount Export {$verb}";
        } else {
            $label   = $this->doctype === 'cashbook' ? 'Cashbook' : 'Invoice';
            $message = "{$label} OCR Autocount Export {$verb}";
        }
        if (!$ok && $error !== null && $error !== '') {
            $message .= " - {$error}";
        }

        $logType = $ok ? 521880000 : ($isAutocountRejection ? 521880001 : 521880002);
        $this->writeLog($dv, $logType, $message, $ok ? null : $error, $documentId);
    }

    private function writeLog(DataverseService $dv, int $logType, string $message, ?string $errorDetails = null, ?string $documentId = null): void
    {
        try {
            $payload = [
                'cr6f2_logtype'              => $logType,
                'cr6f2_category'             => 521880002, // Export
                'cr6f2_message'              => $message,
                'cr6f2_timestamp'            => now()->toIso8601String(),
                'cr6f2_CompanyID@odata.bind' => "/cr6f2_companies({$this->companyId})",
            ];
            if ($errorDetails !== null && $errorDetails !== '') {
                $payload['cr6f2_errordetails'] = $errorDetails;
            }
            if ($documentId !== null && $documentId !== '') {
                $payload['cr6f2_DocumentID@odata.bind'] = "/cr6f2_documentses({$documentId})";
            }
            // Email first — queued jobs have no request, so actorBind() can't be
            // used here, and a self-registered dispatcher has no systemusers GUID.
            if ($this->userEmail || $this->userGuid) {
                $payload += app(\App\Services\Identity\AppUserService::class)
                    ->attributionBind('UserID', $this->userEmail, null, $this->userGuid);
            }
            $dv->create('cr6f2_systemlogs', $payload);
        } catch (Throwable) {}
    }
}
