<?php

namespace App\Services\AutoCount;

use App\Services\AutoCount\Concerns\ResolvesAutoCountLookups;
use App\Services\DataverseService;
use Throwable;

/**
 * Builds AutoCount Desktop write-job payloads from Dataverse records.
 *
 * Mirrors the Excel-export row builders (functions/pipeline_export_excel/invoice_rows.py
 * and functions/pipeline_export_je/je_rows.py) — same field selection — but emits the
 * write-API field names documented in autocount-write-api.md instead of template-column
 * headers.
 *
 * Each build* method returns either:
 *   ['ok' => true,  'type' => '<job_type>', 'params' => [...], 'docId' => '<id>', ...]
 *   ['ok' => false, 'docId' => '<id>', 'error' => '<reason>']
 * so the caller can collect per-document failures without aborting a batch.
 */
class AutoCountWritePayloadBuilder
{
    use ResolvesAutoCountLookups;

    // cr6f2_maindoctype
    private const MAINDOC_CASHBOOK = 521880001;

    // cr6f2_docuementtype → write job type (the reachable set; see the plan)
    private const AR_INVOICE     = 521880000;
    private const AR_DEBIT_NOTE  = 521880001;
    private const AR_CREDIT_NOTE = 521880002;
    private const AP_INVOICE     = 521880003;
    private const AP_CREDIT_NOTE = 521880004;
    private const CASHBOOK_OR    = 160320001; // Official Receipt — money in
    private const CASHBOOK_PV    = 160320002; // Payment Voucher  — money out
    // Extended cash/payment types (added by autocount:extend-cashpayment-schema)
    private const CASHBOOK_AR_PAYMENT = 160320003;
    private const CASHBOOK_AP_PAYMENT = 160320004;
    private const CASHBOOK_AR_REFUND  = 160320005;
    private const CASHBOOK_AP_REFUND  = 160320006;
    private const CASHBOOK_AR_DEPOSIT = 160320007;
    private const CASHBOOK_AP_DEPOSIT = 160320008;

    /**
     * Write job type → AutoCount Cloud REST route suffix. Single source of truth
     * shared by create (UploadToAutocountJob) and edit/delete (controllers) so an
     * AP invoice always resolves to `purchaseinvoice`, a credit note to `creditnote`,
     * etc. — never hardcoded to `invoice`. Verified against the AC Cloud Swagger.
     */
    public const CLOUD_ROUTES = [
        'create_ar_invoice'      => 'invoice',
        'create_ar_debit_note'   => 'invoice',
        'create_ar_credit_note'  => 'creditNote',
        'create_ap_invoice'      => 'purchaseInvoice',
        'create_ap_credit_note'  => 'purchaseReturn',
        // All cashbook subtypes map to the same AC Cloud payment endpoint.
        'create_receipt_voucher' => 'payment',
        'create_payment_voucher' => 'payment',
        'create_ar_payment'      => 'payment',
        'create_ap_payment'      => 'payment',
        'create_ar_refund'       => 'payment',
        'create_ap_refund'       => 'payment',
        'create_ar_deposit'      => 'payment',
        'create_ap_deposit'      => 'payment',
        'create_journal_entry'   => 'journalentry',
    ];

    /** cr6f2_docuementtype option value → CashPaymentParamsBuilder UI type key. */
    private const CASHPAYMENT_TYPE_MAP = [
        self::CASHBOOK_OR         => 'receipt_voucher',
        self::CASHBOOK_PV         => 'payment_voucher',
        self::CASHBOOK_AR_PAYMENT => 'ar_payment',
        self::CASHBOOK_AP_PAYMENT => 'ap_payment',
        self::CASHBOOK_AR_REFUND  => 'ar_refund',
        self::CASHBOOK_AP_REFUND  => 'ap_refund',
        self::CASHBOOK_AR_DEPOSIT => 'ar_deposit',
        self::CASHBOOK_AP_DEPOSIT => 'ap_deposit',
    ];

    // cr6f2_creditdebit (JE lines)
    private const JE_DEBIT  = 521880000;
    private const JE_CREDIT = 521880001;

    public function __construct(
        private DataverseService $dv,
        private CashPaymentParamsBuilder $cashPayment,
    ) {}

    // ─── Invoice / Cashbook ─────────────────────────────────────────────────

    /**
     * Build a write job for one invoice/cashbook document.
     *
     * @return array{ok: bool, type?: string, params?: array, docId: string, docType?: int, error?: string}
     */
    public function buildForDocument(string $documentId, string $accountBook): array
    {
        try {
            $doc = $this->dv->find('cr6f2_documentses', $documentId, [
                'select' => ['cr6f2_documentsid', 'cr6f2_maindoctype', '_crf61_invoicemapping_value'],
            ]);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Document not found.');
        }

        $invId = $doc['_crf61_invoicemapping_value'] ?? null;
        if (!$invId) {
            return $this->fail($documentId, 'Document has no linked invoice.');
        }

        try {
            $inv = $this->dv->find('cr6f2_invoices', $invId);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Invoice record not found or inaccessible: ' . $e->getMessage());
        }

        $maindoctype  = (int) ($doc['cr6f2_maindoctype'] ?? 0);
        $docuementype = (int) ($inv['cr6f2_docuementtype'] ?? 0);

        [$jobType, $isAr, $isCashbook, $cashBookType] = $this->resolveInvoiceJobType($maindoctype, $docuementype);
        if ($jobType === null) {
            return $this->fail($documentId, "Unsupported document type {$docuementype}.");
        }

        // Line items — same summary-mode filter the export uses.
        $summary = !empty($inv['cr6f2_summarymode']) ? 'true' : 'false';
        try {
            $itemsResult = $this->dv->get('cr6f2_invoiceitems', [
                'select' => [
                    'cr6f2_detaildescription', 'cr6f2_qty', 'cr6f2_unitprice',
                    'cr6f2_discount', 'cr6f2_subamount',
                    '_cr6f2_accountnumberlookup_value', '_cr6f2_taxcodechoice_value',
                    '_cr6f2_projectnumberlookup_value', '_cr6f2_departmentnumberlookup_value',
                ],
                'filter'  => "_cr6f2_invoice_value eq {$invId} and cr6f2_summarymode eq {$summary}",
                'orderby' => 'cr6f2_sortnum asc, createdon asc',
            ]);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Failed to fetch line items: ' . $e->getMessage());
        }
        $items = $itemsResult['value'] ?? [];
        if (empty($items)) {
            return $this->fail($documentId, 'Invoice has no line items.');
        }

        // ── Resolve lookups (batched per table) ──
        $vendor   = $this->lookupOne('cr6f2_debtorcreditormappings', 'cr6f2_debtorcreditormappingid',
            $inv['_cr6f2_debtorcreditormapping_value'] ?? null,
            ['cr6f2_debtorcreditorcode', 'cr6f2_debtorcreditorcompanyname', '_cr6f2_currencycode_value']);
        $currency = $this->lookupOne('cr6f2_currencycodes', 'cr6f2_currencycodeid',
            $vendor['_cr6f2_currencycode_value'] ?? null, ['cr6f2_currencycode']);
        $payment  = $this->lookupOne('cr6f2_paymentmethods', 'cr6f2_paymentmethodid',
            $inv['_crf61_paymentmethodmapping_value'] ?? null,
            ['cr6f2_paymentmethodname', 'cr6f2_journaltype']);

        $accountMap = $this->batchMap('cr6f2_accountnumbermaps', 'cr6f2_accountnumbermapid',
            $this->ids($items, '_cr6f2_accountnumberlookup_value'), ['cr6f2_accountnumber']);
        $deptMap    = $this->batchMap('cr6f2_departmentnumbermaps', 'cr6f2_departmentnumbermapid',
            $this->ids($items, '_cr6f2_departmentnumberlookup_value'), ['cr6f2_departmentnumber']);
        $bcProjId     = $inv['_cr6f2_bankchargeprojno_value'] ?? null;
        $masterProjId = $inv['_cr6f2_masterprojno_value']    ?? null;
        $bcProjIds  = array_filter(array_merge(
            $this->ids($items, '_cr6f2_projectnumberlookup_value'),
            $bcProjId     ? [$bcProjId]     : [],
            $masterProjId ? [$masterProjId] : [],
        ));
        $projectMap = $this->batchMap('cr6f2_projectnumbermaps', 'cr6f2_projectnumbermapid',
            array_values($bcProjIds), ['cr6f2_projectcode']);
        $taxMap     = $this->batchMap('cr6f2_taxcodes', 'cr6f2_taxcodeid',
            $this->ids($items, '_cr6f2_taxcodechoice_value'), ['cr6f2_taxcodeidentifier']);

        $vendorCode = $vendor['cr6f2_debtorcreditorcode'] ?? '';

        // GL posting lines (used by family-A cashbook + AR/AP invoices).
        $glDetails = [];
        foreach ($items as $item) {
            $glDetails[] = $this->omitEmpty([
                'AccNo'       => $accountMap[$item['_cr6f2_accountnumberlookup_value'] ?? '']['cr6f2_accountnumber'] ?? '',
                'Amount'      => $this->amount($item),
                'Description' => $item['cr6f2_detaildescription'] ?? '',
                'TaxCode'     => $taxMap[$item['_cr6f2_taxcodechoice_value'] ?? '']['cr6f2_taxcodeidentifier'] ?? '',
                'ProjNo'      => $projectMap[$item['_cr6f2_projectnumberlookup_value'] ?? '']['cr6f2_projectcode'] ?? '',
                'DeptNo'      => $deptMap[$item['_cr6f2_departmentnumberlookup_value'] ?? '']['cr6f2_departmentnumber'] ?? '',
            ], ['Amount']); // keep Amount even if 0
        }

        // ── Cashbook (all 8 cash/payment types) — delegate to the single source
        //    of truth for the PaymentAmount-vs-PaymentAmt field-name rule. ──
        if ($isCashbook) {
            $cpKey   = $cashBookType; // resolveInvoiceJobType returns the UI key here
            $knockOffs = [];
            $raw = $inv['cr6f2_knockoffsjson'] ?? '';
            if (is_string($raw) && $raw !== '') {
                $decoded = json_decode($raw, true);
                if (is_array($decoded)) {
                    $knockOffs = $decoded;
                }
            }

            $cpInput = [
                'accountBook'   => $accountBook,
                'date'          => $this->fmtDate($inv['cr6f2_date'] ?? null),
                'description'   => $inv['cr6f2_description'] ?? '',
                'dealWith'      => $inv['cr6f2_vendornamedraft'] ?? '',
                'currencyCode'  => $inv['cr6f2_vendorcurrecycodedraft'] ?? '',
                'currencyRate'  => $inv['cr6f2_currencyrate'] ?? null,
                'party_code'    => $vendorCode,
                'paymentMethod' => $payment['cr6f2_paymentmethodname'] ?? '',
                'paymentAmt'    => $inv['cr6f2_totalamount'] ?? 0,
                'bankCharge'    => $inv['crf61_bankcharge'] ?? 0,
                'bcProjNo'      => $bcProjId     ? ($projectMap[$bcProjId]['cr6f2_projectcode']     ?? '') : '',
                'projNo'        => $masterProjId ? ($projectMap[$masterProjId]['cr6f2_projectcode'] ?? '') : '',
                'chequeNo'      => $inv['cr6f2_chequeno'] ?? '',
                'knockOffs'     => $knockOffs,
            ];

            // Family A (RV / PV / deposits) post GL lines; deposits target the
            // deposit control account. Family B (payments / refunds) carry no GL
            // lines — their details are the payment-method line synthesized from
            // the header by CashPaymentParamsBuilder.
            if (in_array($cpKey, ['receipt_voucher', 'payment_voucher', 'ar_deposit', 'ap_deposit'], true)) {
                $depositAcc = trim((string) ($inv['cr6f2_depositaccount'] ?? ''));
                if (in_array($cpKey, ['ar_deposit', 'ap_deposit'], true) && $depositAcc !== '') {
                    // Deposits post a single line to the deposit control account.
                    $cpInput['details'] = [[
                        'accNo'       => $depositAcc,
                        'amount'      => (float) ($inv['cr6f2_totalamount'] ?? 0),
                        'description' => $inv['cr6f2_description'] ?? '',
                    ]];
                } else {
                    $cpInput['details'] = array_map(fn($d) => [
                        'accNo'       => $d['AccNo'] ?? '',
                        'amount'      => $d['Amount'] ?? 0,
                        'description' => $d['Description'] ?? '',
                        'taxCode'     => $d['TaxCode'] ?? '',
                        'projNo'      => $d['ProjNo'] ?? '',
                        'deptNo'      => $d['DeptNo'] ?? '',
                    ], $glDetails);
                }
            }

            try {
                $spec = $this->cashPayment->buildCreate($cpKey, $cpInput);
            } catch (Throwable $e) {
                return $this->fail($documentId, $e->getMessage());
            }

            return [
                'ok'      => true,
                'type'    => $spec['type'],
                'params'  => $spec['params'],
                'docId'   => $documentId,
                'docType' => $docuementype,
                'naming'  => [
                    'isCashbook'   => true,
                    'jobType'      => $spec['type'],
                    'vendorName'   => $inv['cr6f2_vendornamedraft'] ?? '',
                    'invoiceNo'    => $inv['cr6f2_invoicenumber']    ?? '',
                    'creditNoteNo' => $inv['cr6f2_creditnotenumber'] ?? '',
                ],
            ];
        }

        // ── AR/AP invoice & note documents (unchanged path) ──
        $master = [
            'DocDate'     => $this->fmtDate($inv['cr6f2_date'] ?? null),
            'Description' => $inv['cr6f2_description'] ?? '',
        ];
        $master += $this->omitEmpty([
            'CurrencyRate' => $inv['cr6f2_currencyrate'] ?? null,
        ]);
        $master[$isAr ? 'DebtorCode' : 'CreditorCode'] = $vendorCode;
        $master['CurrencyCode'] = $currency['cr6f2_currencycode'] ?? '';
        if ($jobType === 'create_ap_invoice') {
            $master['SupplierInvoiceNo'] = $inv['cr6f2_invoicenumber'] ?? '';
        } elseif ($jobType === 'create_ap_credit_note') {
            $master['SupplierCNNo'] = $inv['cr6f2_creditnotenumber'] ?? '';
        } elseif ($jobType === 'create_ar_credit_note' || $jobType === 'create_ar_debit_note') {
            $master['OurInvoiceNo'] = $inv['cr6f2_invoicenumber'] ?? '';
        }
        $master = $this->omitEmpty($master);

        $params = [
            'accountBook' => $accountBook,
            'master'      => $master,
            'details'     => $glDetails,
        ];

        return [
            'ok'      => true,
            'type'    => $jobType,
            'params'  => $params,
            'docId'   => $documentId,
            'docType' => $docuementype,
            // Raw source strings for the post-success file rename (see UploadToAutocountJob).
            // Vendor name source matches the PA flow: cashbook uses the draft name,
            // invoices use the vendor master company name.
            'naming'  => [
                'isCashbook'    => $isCashbook,
                'jobType'       => $jobType,
                'vendorName'    => $isCashbook
                    ? ($inv['cr6f2_vendornamedraft'] ?? '')
                    : ($vendor['cr6f2_debtorcreditorcompanyname'] ?? ''),
                'invoiceNo'     => $inv['cr6f2_invoicenumber']    ?? '',
                'creditNoteNo'  => $inv['cr6f2_creditnotenumber'] ?? '',
            ],
        ];
    }

    // ─── Journal Entry ──────────────────────────────────────────────────────

    /**
     * Build a create_journal_entry job for one JE. The id is the JE GUID directly
     * (the JE tab's row id), not a document GUID.
     *
     * @return array{ok: bool, type?: string, params?: array, docId: string, linkedDocIds?: array, error?: string}
     */
    public function buildForJournalEntry(string $jeId, string $accountBook): array
    {
        try {
            $je = $this->dv->find('cr6f2_journalentries', $jeId, [
                'select' => [
                    'cr6f2_journalentryid', 'cr6f2_journalentryname', 'cr6f2_docdate',
                    'cr6f2_description', 'cr6f2_currencyrate', '_cr6f2_currencycode_value',
                ],
            ]);
        } catch (Throwable $e) {
            return $this->fail($jeId, 'Journal entry not found.');
        }

        try {
            $linesResult = $this->dv->get('cr6f2_juornalentrylines', [
                'select' => [
                    'cr6f2_description', 'cr6f2_creditdebit', 'cr6f2_amount',
                    '_cr6f2_accountnumberlookup_value', '_cr6f2_departmentnumberlookup_value',
                    '_cr6f2_projectnumbermapping_value', '_cr6f2_taxcodemapping_value',
                    '_cr6f2_documentmapping_value',
                ],
                'filter'  => "_cr6f2_jemapping_value eq {$jeId} and statecode eq 0",
                'orderby' => 'createdon asc',
            ]);
        } catch (Throwable $e) {
            return $this->fail($jeId, 'Failed to fetch JE lines.');
        }
        $lines = $linesResult['value'] ?? [];
        if (empty($lines)) {
            return $this->fail($jeId, 'Journal entry has no lines.');
        }

        $accountMap = $this->batchMap('cr6f2_accountnumbermaps', 'cr6f2_accountnumbermapid',
            $this->ids($lines, '_cr6f2_accountnumberlookup_value'), ['cr6f2_accountnumber']);
        $deptMap    = $this->batchMap('cr6f2_departmentnumbermaps', 'cr6f2_departmentnumbermapid',
            $this->ids($lines, '_cr6f2_departmentnumberlookup_value'), ['cr6f2_departmentnumber']);
        $projectMap = $this->batchMap('cr6f2_projectnumbermaps', 'cr6f2_projectnumbermapid',
            $this->ids($lines, '_cr6f2_projectnumbermapping_value'), ['cr6f2_projectcode']);
        $taxMap     = $this->batchMap('cr6f2_taxcodes', 'cr6f2_taxcodeid',
            $this->ids($lines, '_cr6f2_taxcodemapping_value'), ['cr6f2_taxcodeidentifier']);

        $master = $this->omitEmpty([
            'DocDate'      => $this->fmtDate($je['cr6f2_docdate'] ?? null),
            'Description'  => $je['cr6f2_description'] ?? '',
            'CurrencyCode' => $je['_cr6f2_currencycode_value@OData.Community.Display.V1.FormattedValue'] ?? '',
            'CurrencyRate' => $je['cr6f2_currencyrate'] ?? null,
        ]);

        // When the JE balances on its raw line amounts (Σ DR = Σ CR), send the lines as
        // tax-INCLUSIVE so AutoCount carves any line tax out of the amount instead of
        // adding it on top. Without this, a line carrying a non-zero tax code (e.g. an
        // SST P-10 10% purchase-tax line) inflates one side and the Desktop worker
        // rejects the whole entry as "Journal Entry not balance". OCR staff-claim JE
        // amounts are gross (tax-inclusive) by nature, so the carved-out tax posts right.
        // The Desktop worker binds this to JournalEntryDetail.InclusiveTax via reflection
        // (default exclusive) — field name confirmed by the worker owner.
        $drTotal = 0.0; $crTotal = 0.0;
        foreach ($lines as $line) {
            if ((int) ($line['cr6f2_creditdebit'] ?? 0) === self::JE_CREDIT) {
                $crTotal += (float) ($line['cr6f2_amount'] ?? 0);
            } else {
                $drTotal += (float) ($line['cr6f2_amount'] ?? 0);
            }
        }
        $inclusive = abs($drTotal - $crTotal) <= 0.005;

        $details = [];
        // Per linked document, in first-seen order: summed amount (Σ over lines sharing
        // that doc) + a representative line description — both feed the post-success
        // file rename (mirrors the PA Filter_Matching_JE_Lines / Sum_Amounts logic).
        $docAgg = []; // docId => ['sum' => float, 'description' => string]
        foreach ($lines as $line) {
            $amount = (float) ($line['cr6f2_amount'] ?? 0);
            $cd     = (int) ($line['cr6f2_creditdebit'] ?? 0);
            $detail = [
                'AccNo'        => $accountMap[$line['_cr6f2_accountnumberlookup_value'] ?? '']['cr6f2_accountnumber'] ?? '',
                'Description'  => $line['cr6f2_description'] ?? '',
                'TaxCode'      => $taxMap[$line['_cr6f2_taxcodemapping_value'] ?? '']['cr6f2_taxcodeidentifier'] ?? '',
                'ProjNo'       => $projectMap[$line['_cr6f2_projectnumbermapping_value'] ?? '']['cr6f2_projectcode'] ?? '',
                'DeptNo'       => $deptMap[$line['_cr6f2_departmentnumberlookup_value'] ?? '']['cr6f2_departmentnumber'] ?? '',
                'InclusiveTax' => $inclusive,
            ];
            // Set exactly one of DR / CR.
            if ($cd === self::JE_CREDIT) {
                $detail['CR'] = $amount;
            } else {
                $detail['DR'] = $amount;
            }
            $details[] = $this->omitEmpty($detail, ['DR', 'CR', 'InclusiveTax']);

            $docId = $line['_cr6f2_documentmapping_value'] ?? null;
            if ($docId) {
                if (!isset($docAgg[$docId])) {
                    $docAgg[$docId] = ['sum' => 0.0, 'description' => $line['cr6f2_description'] ?? ''];
                }
                $docAgg[$docId]['sum'] += $amount;
            }
        }

        // Resolve each linked document's current name + SharePoint link for the rename/move.
        $docIds  = array_keys($docAgg);
        $docMeta = $this->batchMap('cr6f2_documentses', 'cr6f2_documentsid', $docIds,
            ['cr6f2_documentname', 'cr6f2_documentlink', 'cr6f2_documentstoredlink']);

        $linkedDocs = [];
        foreach ($docAgg as $docId => $agg) {
            $meta = $docMeta[$docId] ?? [];
            $linkedDocs[$docId] = [
                'name'        => $meta['cr6f2_documentname']       ?? '',
                'link'        => $meta['cr6f2_documentlink']       ?? ($meta['cr6f2_documentstoredlink'] ?? ''),
                'description' => $agg['description'],
                'sum'         => $agg['sum'],
            ];
        }

        return [
            'ok'           => true,
            'type'         => 'create_journal_entry',
            'params'       => [
                'accountBook' => $accountBook,
                'master'      => $master,
                'details'     => $details,
            ],
            'docId'        => $jeId,
            'jeName'       => $je['cr6f2_journalentryname'] ?? '',  // for the system-log message
            'linkedDocIds' => $docIds,        // unchanged side-effect input
            'linkedDocs'   => $linkedDocs,    // docId => {name, link, description, sum}
        ];
    }

    // ─── Cloud Invoice / Cashbook ────────────────────────────────────────────

    /**
     * Build a Cloud upload payload for one invoice/cashbook document.
     * Mirrors the PA AutoCount Upload Invoice flow — camelCase keys, qty/unit/unitPrice
     * for AR/AP details, paymentDetails for cashbook.
     *
     * @return array{ok: bool, type?: string, params?: array, docId: string, docType?: int, naming?: array, error?: string}
     */
    public function buildForDocumentCloud(string $documentId): array
    {
        try {
            $doc = $this->dv->find('cr6f2_documentses', $documentId, [
                'select' => ['cr6f2_documentsid', 'cr6f2_maindoctype', '_crf61_invoicemapping_value'],
            ]);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Document not found.');
        }

        $invId = $doc['_crf61_invoicemapping_value'] ?? null;
        if (!$invId) {
            return $this->fail($documentId, 'Document has no linked invoice.');
        }

        try {
            $inv = $this->dv->find('cr6f2_invoices', $invId, [
                'select' => [
                    'cr6f2_invoiceid', 'cr6f2_invoicenumber', 'cr6f2_date',
                    'cr6f2_description', 'cr6f2_creditnotenumber', 'cr6f2_currencyrate',
                    'cr6f2_docuementtype', 'cr6f2_summarymode', 'cr6f2_totalamount',
                    'cr6f2_vendornamedraft', 'cr6f2_vendorcurrecycodedraft', 'crf61_bankcharge',
                    '_cr6f2_debtorcreditormapping_value', '_crf61_paymentmethodmapping_value',
                    'cr6f2_credittermchoice', 'cr6f2_reason',
                    '_cr6f2_purchaselocation_value',
                    '_cr6f2_docnoformatmapping_value',
                ],
            ]);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Invoice not found - ' . $e->getMessage());
        }

        $maindoctype  = (int) ($doc['cr6f2_maindoctype'] ?? 0);
        $docuementype = (int) ($inv['cr6f2_docuementtype'] ?? 0);

        [$jobType, $isAr, $isCashbook] = $this->resolveInvoiceJobType($maindoctype, $docuementype);
        if ($jobType === null) {
            return $this->fail($documentId, "Unsupported document type {$docuementype}.");
        }

        $summary = !empty($inv['cr6f2_summarymode']) ? 'true' : 'false';
        try {
            $itemsResult = $this->dv->get('cr6f2_invoiceitems', [
                'select' => [
                    'cr6f2_detaildescription', 'cr6f2_qty', 'cr6f2_unit', 'cr6f2_unitprice',
                    'cr6f2_discount', 'cr6f2_subamount',
                    '_cr6f2_accountnumberlookup_value', '_cr6f2_taxcodechoice_value',
                    '_cr6f2_projectnumberlookup_value', '_cr6f2_departmentnumberlookup_value',
                ],
                'filter'  => "_cr6f2_invoice_value eq {$invId} and cr6f2_summarymode eq {$summary}",
                'orderby' => 'cr6f2_sortnum asc, createdon asc',
            ]);
        } catch (Throwable $e) {
            return $this->fail($documentId, 'Failed to fetch line items: ' . $e->getMessage());
        }
        $items = $itemsResult['value'] ?? [];
        if (empty($items)) {
            return $this->fail($documentId, 'Invoice has no line items.');
        }

        // ── Resolve lookups ──
        $vendor    = $this->lookupOne('cr6f2_debtorcreditormappings', 'cr6f2_debtorcreditormappingid',
            $inv['_cr6f2_debtorcreditormapping_value'] ?? null,
            ['cr6f2_debtorcreditorcode', 'cr6f2_debtorcreditorcompanyname', 'cr6f2_debtorcreditorcompanyaddress', '_cr6f2_currencycode_value']);
        $payment   = $this->lookupOne('cr6f2_paymentmethods', 'cr6f2_paymentmethodid',
            $inv['_crf61_paymentmethodmapping_value'] ?? null,
            ['cr6f2_paymentmethodname', 'cr6f2_journaltype']);
        $location  = $this->lookupOne('cr6f2_purchaselocationmaps', 'cr6f2_purchaselocationmapid',
            $inv['_cr6f2_purchaselocation_value'] ?? null, ['cr6f2_locationcode']);
        $docFormat = $this->lookupOne('cr6f2_docnoformats', 'cr6f2_docnoformatid',
            $inv['_cr6f2_docnoformatmapping_value'] ?? null, ['cr6f2_docnoformatname']);

        $accountMap = $this->batchMap('cr6f2_accountnumbermaps', 'cr6f2_accountnumbermapid',
            $this->ids($items, '_cr6f2_accountnumberlookup_value'), ['cr6f2_accountnumber']);
        $deptMap    = $this->batchMap('cr6f2_departmentnumbermaps', 'cr6f2_departmentnumbermapid',
            $this->ids($items, '_cr6f2_departmentnumberlookup_value'), ['cr6f2_departmentnumber']);
        $projectMap = $this->batchMap('cr6f2_projectnumbermaps', 'cr6f2_projectnumbermapid',
            $this->ids($items, '_cr6f2_projectnumberlookup_value'), ['cr6f2_projectcode']);
        $taxMap     = $this->batchMap('cr6f2_taxcodes', 'cr6f2_taxcodeid',
            $this->ids($items, '_cr6f2_taxcodechoice_value'), ['cr6f2_taxcodeidentifier']);

        $creditTerm    = $inv['cr6f2_credittermchoice@OData.Community.Display.V1.FormattedValue'] ?? '';
        $docTypeLabel  = $inv['cr6f2_docuementtype@OData.Community.Display.V1.FormattedValue'] ?? '';
        $locationCode  = $location['cr6f2_locationcode'] ?? '';
        $docFormatName = $docFormat['cr6f2_docnoformatname'] ?? '';
        $vendorCode    = $vendor['cr6f2_debtorcreditorcode'] ?? '';
        $vendorName    = $vendor['cr6f2_debtorcreditorcompanyname'] ?? '';
        $address       = $vendor['cr6f2_debtorcreditorcompanyaddress'] ?? '';

        // ── camelCase master ──
        $master = [
            'docDate'     => $this->fmtDate($inv['cr6f2_date'] ?? null),
            'description' => $inv['cr6f2_description'] ?? '',
        ];
        if (($inv['cr6f2_currencyrate'] ?? null) !== null && $inv['cr6f2_currencyrate'] !== '') {
            $master['currencyRate'] = $inv['cr6f2_currencyrate'];
        }

        if ($isCashbook) {
            $master['docType']      = $docTypeLabel;
            $master['currencyCode'] = $inv['cr6f2_vendorcurrecycodedraft'] ?? '';
            $master['journalType']  = $this->resolveJournalType($payment['cr6f2_journaltype'] ?? null);
            $master['dealWith']     = $inv['cr6f2_vendornamedraft'] ?? '';
        } else {
            if ($isAr) {
                $master['debtorCode'] = $vendorCode;
                $master['debtorName'] = $vendorName;
                if ($locationCode !== '') $master['salesLocation'] = $locationCode;
            } else {
                $master['creditorCode'] = $vendorCode;
                $master['creditorName'] = $vendorName;
                if ($locationCode !== '') $master['purchaseLocation'] = $locationCode;
            }
            if ($address !== '')    $master['address']    = $address;
            if ($creditTerm !== '') $master['creditTerm'] = $creditTerm;

            if ($jobType === 'create_ap_invoice') {
                $master['supplierInvoiceNo'] = $inv['cr6f2_invoicenumber'] ?? '';
            } elseif ($jobType === 'create_ap_credit_note') {
                $master['supplierInvoiceNo'] = $inv['cr6f2_invoicenumber']    ?? '';
                $master['supplierCNNo']      = $inv['cr6f2_creditnotenumber'] ?? '';
            }
        }

        if ($docFormatName !== '') $master['docNoFormatName'] = $docFormatName;
        $master = $this->omitEmpty($master);

        // PA flow always sends 'reason' for AR Invoice / AR Credit Note / AP Credit Note
        if (!$isCashbook && in_array($jobType, ['create_ar_invoice', 'create_ar_credit_note', 'create_ap_credit_note'], true)) {
            $master['reason'] = $inv['cr6f2_reason'] ?? '';
        }

        // ── camelCase details ──
        $details = [];
        foreach ($items as $item) {
            $accNo = $accountMap[$item['_cr6f2_accountnumberlookup_value'] ?? '']['cr6f2_accountnumber'] ?? '';
            $desc  = $item['cr6f2_detaildescription'] ?? '';
            // Skip genuinely blank rows (PA flow behaviour: accNo empty AND description empty)
            if ($accNo === '' && $desc === '') continue;

            if ($isCashbook) {
                $details[] = $this->omitEmpty([
                    'accNo'         => $accNo,
                    'description'   => $desc,
                    'amount'        => $this->amount($item),
                    'toAccountRate' => 1,
                    'inclusiveTax'  => false,
                    'taxCode'       => $taxMap[$item['_cr6f2_taxcodechoice_value'] ?? '']['cr6f2_taxcodeidentifier'] ?? '',
                    'deptNo'        => $deptMap[$item['_cr6f2_departmentnumberlookup_value'] ?? '']['cr6f2_departmentnumber'] ?? '',
                ], ['amount', 'toAccountRate', 'inclusiveTax', 'description']);
            } else {
                $discountRaw  = $item['cr6f2_discount']  ?? null;
                $unitPriceRaw = $item['cr6f2_unitprice'] ?? null;
                $qty          = (float) ($item['cr6f2_qty'] ?? 0);
                $subamount    = (float) ($item['cr6f2_subamount'] ?? 0);

                // Unit price: PA sends coalesce(unitprice, 0); we also fallback to abs(subamount/qty)
                // for adjustment lines where unitPrice is null but subamount carries the value.
                if ($unitPriceRaw !== null && $unitPriceRaw !== '') {
                    $unitPrice = (float) $unitPriceRaw;
                } else {
                    $unitPrice = ($qty > 0) ? round(abs($subamount) / $qty, 6) : 0.0;
                }

                // Discount: PA sends "" when null, string of numeric value otherwise
                $discount = ($discountRaw !== null && $discountRaw !== '') ? (string)(float)$discountRaw : '';

                $details[] = $this->omitEmpty([
                    'accNo'       => $accNo,
                    'description' => $desc,
                    'qty'         => $qty,
                    'unit'        => $item['cr6f2_unit'] ?? '',
                    'unitPrice'   => $unitPrice,
                    'discount'    => $discount,
                    'taxCode'     => $taxMap[$item['_cr6f2_taxcodechoice_value'] ?? '']['cr6f2_taxcodeidentifier'] ?? '',
                    'projNo'      => $projectMap[$item['_cr6f2_projectnumberlookup_value'] ?? '']['cr6f2_projectcode'] ?? '',
                    'deptNo'      => $deptMap[$item['_cr6f2_departmentnumberlookup_value'] ?? '']['cr6f2_departmentnumber'] ?? '',
                ], ['qty', 'unitPrice', 'description', 'discount']);
            }
        }

        $params = ['master' => $master, 'details' => $details];

        if ($isCashbook) {
            // paymentAmt = sum of detail amounts so the validation passes even when taxCode is empty.
            $detailTotal = array_sum(array_map(fn($d) => (float) ($d['amount'] ?? 0), $details));
            $params['paymentDetails'] = [
                $this->omitEmpty([
                    'paymentMethod' => $payment['cr6f2_paymentmethodname'] ?? '',
                    'paymentAmt'    => $detailTotal ?: ($inv['cr6f2_totalamount'] ?? 0),
                    'bankCharge'    => $inv['crf61_bankcharge']  ?? null,
                ], ['paymentAmt']),
            ];
        }

        return [
            'ok'      => true,
            'type'    => $jobType,
            'params'  => $params,
            'docId'   => $documentId,
            'docType' => $docuementype,
            'naming'  => [
                'isCashbook'   => $isCashbook,
                'jobType'      => $jobType,
                'vendorName'   => $isCashbook
                    ? ($inv['cr6f2_vendornamedraft'] ?? '')
                    : $vendorName,
                'invoiceNo'    => $inv['cr6f2_invoicenumber']    ?? '',
                'creditNoteNo' => $inv['cr6f2_creditnotenumber'] ?? '',
            ],
        ];
    }

    // ─── Cloud Journal Entry ─────────────────────────────────────────────────

    /**
     * Build a Cloud upload payload for one JE.
     * Same data as buildForJournalEntry() but camelCase keys, dr/cr lowercase, journalType GENERAL.
     *
     * @return array{ok: bool, type?: string, params?: array, docId: string, jeName?: string, linkedDocIds?: array, linkedDocs?: array, error?: string}
     */
    public function buildForJournalEntryCloud(string $jeId): array
    {
        try {
            $je = $this->dv->find('cr6f2_journalentries', $jeId, [
                'select' => [
                    'cr6f2_journalentryid', 'cr6f2_journalentryname', 'cr6f2_docdate',
                    'cr6f2_description', 'cr6f2_currencyrate', '_cr6f2_currencycode_value',
                ],
            ]);
        } catch (Throwable $e) {
            return $this->fail($jeId, 'Journal entry not found.');
        }

        try {
            $linesResult = $this->dv->get('cr6f2_juornalentrylines', [
                'select' => [
                    'cr6f2_description', 'cr6f2_creditdebit', 'cr6f2_amount',
                    '_cr6f2_accountnumberlookup_value', '_cr6f2_departmentnumberlookup_value',
                    '_cr6f2_projectnumbermapping_value', '_cr6f2_taxcodemapping_value',
                    '_cr6f2_documentmapping_value',
                ],
                'filter'  => "_cr6f2_jemapping_value eq {$jeId} and statecode eq 0",
                'orderby' => 'createdon asc',
            ]);
        } catch (Throwable $e) {
            return $this->fail($jeId, 'Failed to fetch JE lines.');
        }
        $lines = $linesResult['value'] ?? [];
        if (empty($lines)) {
            return $this->fail($jeId, 'Journal entry has no lines.');
        }

        $accountMap = $this->batchMap('cr6f2_accountnumbermaps', 'cr6f2_accountnumbermapid',
            $this->ids($lines, '_cr6f2_accountnumberlookup_value'), ['cr6f2_accountnumber']);
        $deptMap    = $this->batchMap('cr6f2_departmentnumbermaps', 'cr6f2_departmentnumbermapid',
            $this->ids($lines, '_cr6f2_departmentnumberlookup_value'), ['cr6f2_departmentnumber']);
        $projectMap = $this->batchMap('cr6f2_projectnumbermaps', 'cr6f2_projectnumbermapid',
            $this->ids($lines, '_cr6f2_projectnumbermapping_value'), ['cr6f2_projectcode']);
        $taxMap     = $this->batchMap('cr6f2_taxcodes', 'cr6f2_taxcodeid',
            $this->ids($lines, '_cr6f2_taxcodemapping_value'), ['cr6f2_taxcodeidentifier']);

        $master = $this->omitEmpty([
            'docDate'      => $this->fmtDate($je['cr6f2_docdate'] ?? null),
            'description'  => $je['cr6f2_description'] ?? '',
            'currencyCode' => $je['_cr6f2_currencycode_value@OData.Community.Display.V1.FormattedValue'] ?? '',
            'currencyRate' => $je['cr6f2_currencyrate'] ?? null,
            'journalType'  => 'GENERAL',
        ]);

        // When the JE balances on its raw line amounts (Σ DR = Σ CR), send the lines as
        // tax-INCLUSIVE so AutoCount carves any line tax out of the amount instead of
        // adding it on top. Without this, a line carrying a non-zero tax code (e.g. an
        // SST P-10 10% purchase-tax line) inflates one side and AutoCount rejects the
        // whole entry as "Journal Entry not balance". OCR staff-claim JE amounts are
        // gross (tax-inclusive) by nature, so the carved-out tax posts correctly.
        $drTotal = 0.0; $crTotal = 0.0;
        foreach ($lines as $line) {
            if ((int) ($line['cr6f2_creditdebit'] ?? 0) === self::JE_CREDIT) {
                $crTotal += (float) ($line['cr6f2_amount'] ?? 0);
            } else {
                $drTotal += (float) ($line['cr6f2_amount'] ?? 0);
            }
        }
        $inclusive = abs($drTotal - $crTotal) <= 0.005;

        $details = [];
        $docAgg  = [];
        foreach ($lines as $line) {
            $amount = (float) ($line['cr6f2_amount'] ?? 0);
            $cd     = (int) ($line['cr6f2_creditdebit'] ?? 0);
            $detail = [
                'accNo'         => $accountMap[$line['_cr6f2_accountnumberlookup_value'] ?? '']['cr6f2_accountnumber'] ?? '',
                'description'   => $line['cr6f2_description'] ?? '',
                'taxCode'       => $taxMap[$line['_cr6f2_taxcodemapping_value'] ?? '']['cr6f2_taxcodeidentifier'] ?? '',
                'projNo'        => $projectMap[$line['_cr6f2_projectnumbermapping_value'] ?? '']['cr6f2_projectcode'] ?? '',
                'deptNo'        => $deptMap[$line['_cr6f2_departmentnumberlookup_value'] ?? '']['cr6f2_departmentnumber'] ?? '',
                'toAccountRate' => 1,
                'inclusiveTax'  => $inclusive,
            ];
            if ($cd === self::JE_CREDIT) {
                $detail['cr'] = $amount;
            } else {
                $detail['dr'] = $amount;
            }
            $details[] = $this->omitEmpty($detail, ['dr', 'cr', 'toAccountRate', 'inclusiveTax']);

            $docId = $line['_cr6f2_documentmapping_value'] ?? null;
            if ($docId) {
                if (!isset($docAgg[$docId])) {
                    $docAgg[$docId] = ['sum' => 0.0, 'description' => $line['cr6f2_description'] ?? ''];
                }
                $docAgg[$docId]['sum'] += $amount;
            }
        }

        $docIds  = array_keys($docAgg);
        $docMeta = $this->batchMap('cr6f2_documentses', 'cr6f2_documentsid', $docIds,
            ['cr6f2_documentname', 'cr6f2_documentlink', 'cr6f2_documentstoredlink']);

        $linkedDocs = [];
        foreach ($docAgg as $docId => $agg) {
            $meta = $docMeta[$docId] ?? [];
            $linkedDocs[$docId] = [
                'name'        => $meta['cr6f2_documentname']  ?? '',
                'link'        => $meta['cr6f2_documentlink']  ?? ($meta['cr6f2_documentstoredlink'] ?? ''),
                'description' => $agg['description'],
                'sum'         => $agg['sum'],
            ];
        }

        return [
            'ok'           => true,
            'type'         => 'create_journal_entry',
            'params'       => ['master' => $master, 'details' => $details],
            'docId'        => $jeId,
            'jeName'       => $je['cr6f2_journalentryname'] ?? '',
            'linkedDocIds' => $docIds,
            'linkedDocs'   => $linkedDocs,
        ];
    }

    // ─── Decision logic ─────────────────────────────────────────────────────

    /** Map a write job type (create_*) to its AutoCount Cloud REST route, or null. */
    public static function cloudRouteFor(string $jobType): ?string
    {
        return self::CLOUD_ROUTES[$jobType] ?? null;
    }

    /**
     * Resolve the Cloud REST route for an existing invoice/cashbook document
     * WITHOUT building the full payload — used by delete, which needs only the
     * docNo + route. Mirrors buildForDocumentCloud's type resolution so an AP
     * invoice deletes via `purchaseInvoice`, a credit note via `creditNote`, etc.
     *
     * @return array{ok: bool, type?: string, route?: string, error?: string}
     */
    public function resolveCloudRoute(string $documentId): array
    {
        try {
            $doc = $this->dv->find('cr6f2_documentses', $documentId, [
                'select' => ['cr6f2_maindoctype', '_crf61_invoicemapping_value'],
            ]);
        } catch (Throwable) {
            return ['ok' => false, 'error' => 'Document not found.'];
        }

        $invId = $doc['_crf61_invoicemapping_value'] ?? null;
        if (!$invId) {
            return ['ok' => false, 'error' => 'Document has no linked invoice.'];
        }

        try {
            $inv = $this->dv->find('cr6f2_invoices', $invId, ['select' => ['cr6f2_docuementtype']]);
        } catch (Throwable) {
            return ['ok' => false, 'error' => 'Invoice not found.'];
        }

        [$jobType] = $this->resolveInvoiceJobType(
            (int) ($doc['cr6f2_maindoctype'] ?? 0),
            (int) ($inv['cr6f2_docuementtype'] ?? 0),
        );
        if ($jobType === null) {
            return ['ok' => false, 'error' => 'Unsupported document type.'];
        }

        $route = self::cloudRouteFor($jobType);
        if ($route === null) {
            return ['ok' => false, 'error' => "No Cloud route for type {$jobType}."];
        }

        return ['ok' => true, 'type' => $jobType, 'route' => $route];
    }

    /**
     * The create job type for an existing invoice/cashbook document (e.g.
     * 'create_ar_invoice'), WITHOUT building the full payload. Clearer-named wrapper
     * over resolveCloudRoute() so Desktop edit/delete derive from the create job type,
     * not the Cloud route string.
     */
    public function resolveCreateJobType(string $documentId): ?string
    {
        $r = $this->resolveCloudRoute($documentId);
        return !empty($r['ok']) ? ($r['type'] ?? null) : null;
    }

    // ─── Desktop edit / delete (reuse the create builders) ──────────────────
    //
    // A Desktop edit job is the FULL create payload + docNo, with the job type
    // changed create_* → edit_*. A delete job is just {accountBook, docNo} with type
    // delete_*. Mirrors the cashbook edit/delete template, generalized to invoice/JE
    // and rebuilt from Dataverse (no frontend field-mapping).

    /** Build a desktop edit_* spec for an invoice/cashbook document. */
    public function buildDesktopEditForDocument(string $documentId, string $accountBook, string $docNo): array
    {
        $b = $this->buildForDocument($documentId, $accountBook);
        if (empty($b['ok'])) return $b;
        $b['type'] = 'edit_' . substr($b['type'], 7); // create_X → edit_X
        $b['params']['docNo'] = $docNo;
        return $b;
    }

    /** Build a desktop delete_* spec for an invoice/cashbook document (no full payload needed). */
    public function buildDesktopDeleteForDocument(string $documentId, string $accountBook, string $docNo): array
    {
        $createType = $this->resolveCreateJobType($documentId);
        if ($createType === null) {
            return $this->fail($documentId, 'Could not resolve document type.');
        }
        return [
            'ok'     => true,
            'type'   => 'delete_' . substr($createType, 7), // create_X → delete_X
            'params' => ['accountBook' => $accountBook, 'docNo' => $docNo],
            'docId'  => $documentId,
        ];
    }

    /** Build a desktop edit_journal_entry spec for a JE. */
    public function buildDesktopEditForJournalEntry(string $jeId, string $accountBook, string $docNo): array
    {
        $b = $this->buildForJournalEntry($jeId, $accountBook);
        if (empty($b['ok'])) return $b;
        $b['type'] = 'edit_journal_entry';
        $b['params']['docNo'] = $docNo;
        return $b;
    }

    /**
     * @return array{0: ?string, 1: bool, 2: bool, 3: ?string}
     *   [jobType, isAr, isCashbook, cashPaymentTypeKey]
     * For cashbook documents, cashPaymentTypeKey is the CashPaymentParamsBuilder
     * UI type key (receipt_voucher, ar_payment, …) and the params are delegated
     * to that builder; isAr is unused for cashbook.
     */
    private function resolveInvoiceJobType(int $maindoctype, int $docuementype): array
    {
        if ($maindoctype === self::MAINDOC_CASHBOOK) {
            $key = self::CASHPAYMENT_TYPE_MAP[$docuementype] ?? null;
            if ($key === null) {
                return [null, false, true, null];
            }
            return ["create_{$key}", false, true, $key];
        }

        return match ($docuementype) {
            self::AR_INVOICE     => ['create_ar_invoice',     true,  false, null],
            self::AR_DEBIT_NOTE  => ['create_ar_debit_note',  true,  false, null],
            self::AR_CREDIT_NOTE => ['create_ar_credit_note', true,  false, null],
            self::AP_INVOICE     => ['create_ap_invoice',     false, false, null],
            self::AP_CREDIT_NOTE => ['create_ap_credit_note', false, false, null],
            default              => [null, false, false, null],
        };
    }

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

    /** Payment method journaltype → 'CASH' or 'BANK'. Mirrors PA Compose_JournalType expression. */
    private function resolveJournalType(mixed $journalType): string
    {
        return ((int) $journalType === 0) ? 'CASH' : 'BANK';
    }

    /** Line amount: cr6f2_subamount if set, else unitprice*qty - discount (port of invoice_rows._amount). */
    private function fail(string $docId, string $error): array
    {
        return ['ok' => false, 'docId' => $docId, 'error' => $error];
    }
}
