<?php

namespace App\Services;

use App\Exceptions\StockReferenceException;
use App\Models\Catalog\Product;
use App\Models\Catalog\ProductBundle;
use App\Models\Customers\CustomerCreditLedger;
use App\Models\Inventory\ProductSerial;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\MovementType;
use App\Models\Lookups\Status;
use App\Models\Returns\CreditNote;
use App\Models\Sales\Invoice;
use App\Models\Sales\InvoiceItem;
use App\Models\Sales\SalesOrder;
use App\Models\Scopes\BranchScope;
use App\Models\Vouchers\VoucherRedemption;
use App\Support\SerialInput;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;

/**
 * Create invoices for TILL or SALES channel. Hands off splits to PaymentService.
 */
class InvoiceService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly StockMovementService $stock,
        private readonly PaymentService $payments,
        private readonly CustomerCreditService $customerCredit,
        private readonly UnitConversionService $units,
        private readonly BatchService $batches,
        private readonly SerialService $serials,
        private readonly BundleService $bundles,
        private readonly VoucherService $vouchers,
        private readonly LoyaltyService $loyalty,
        private readonly CashMovementService $cashMovements,
        private readonly VatSettings $vat,
        private readonly DealService $deals,
        private readonly CartDiscountService $cartDiscounts,
    ) {}

    /**
     * @param  array{
     *   channel:'till'|'sales', branch_id:int, customer_id?:int,
     *   delivery_address_id?:int, till_id?:int, pos_session_id?:int, amended_from_invoice_id?:int,
     *   sales_rep_id?:int, note?:string,
     *   subtotal?:float, vat_total?:float, total?:float,
     *   discount?:float, net_total?:float,
     * }  $header
     * @param  array<int,array{
     *   product_id?:int, product_name:string, product_sku?:string, product_barcode?:string,
     *   quantity:float, unit_price:float, vat_rate?:float, vat_amount?:float, line_total?:float
     * }>  $lines
     * @param  array<int,array<string,mixed>>  $splits  optional payment splits
     */
    public function create(array $header, array $lines, array $splits = [], bool $confirmShortfall = false): Invoice
    {
        return DB::transaction(function () use ($header, $lines, $splits, $confirmShortfall) {
            // Every product line is repriced from its selected lot here, before
            // totals or stock movements are computed anywhere else — the caller's
            // `unit_price` is a checkout-UI convenience, never the source of truth,
            // otherwise anyone with checkout access could submit a real product at
            // an arbitrary price. Bundle lines are already fully server-priced in
            // sellBundleLine(); a free-form line with no product/bundle id has no
            // lot to check against, so the caller's amount stands.
            // Batched variant avoids N+1 StockBatch lookups + N frontLotId checks.
            $lines = $this->withAuthoritativePrices($lines, (int) $header['branch_id']);

            // Deals: the authoritative evaluation point for the item-level
            // promotional engine. Whatever the client payload claims, the line
            // discount attached here is the deal table's own — a product with a
            // live deal is sold at its discounted line price on every channel
            // (POS, sales-order conversion, manual invoice), and a client can no
            // more smuggle a deal discount onto a non-deal product than it can
            // smuggle a price. One batched lookup for all product lines.
            $dealByProduct = $this->deals->activeDealsForProducts(array_column($lines, 'product_id'));
            $lines = array_map(function (array $line) use ($dealByProduct): array {
                if (empty($line['product_id']) || ! empty($line['product_bundle_id']) || ! empty($line['_bundle_id'])) {
                    // No deal on bundles (the bundle already has its own price)
                    // or on free-form lines. Strip any client-supplied deal keys
                    // so a crafted payload cannot invent one.
                    unset($line['deal_id'], $line['deal_name'], $line['deal_discount_amount']);

                    return $line;
                }

                $deal = $dealByProduct[(int) $line['product_id']] ?? null;
                if (! $deal) {
                    unset($line['deal_id'], $line['deal_name'], $line['deal_discount_amount']);

                    return $line;
                }

                // Clamped here, not just in resolveLineTotals(): the amount
                // persisted on each InvoiceItem must match the header — a deal
                // can only eat what the cashier's manual line discount leaves
                // of the gross, never push the line below zero.
                $lineGrossTotal = (float) $line['quantity'] * (float) $line['unit_price'];
                $manualDiscount = $this->computeLineDiscount(
                    $lineGrossTotal,
                    (float) $line['quantity'],
                    $line['discount_type'] ?? null,
                    (float) ($line['discount_value'] ?? 0),
                );
                $dealAmount = $this->deals->computeDiscount(
                    $deal,
                    $lineGrossTotal,
                    (float) $line['quantity'],
                );

                $line['deal_id'] = $deal->id;
                $line['deal_name'] = $deal->name;
                $line['deal_discount_amount'] = round(min(max($dealAmount, 0), max(0, $lineGrossTotal - $manualDiscount)), 2);

                return $line;
            }, $lines);

            // subtotal is GROSS (pre-any-discount); line-level discounts (the
            // cashier's manual ones AND the automatic deal discounts) are folded
            // into $lineDiscountTotal and added to the invoice's overall
            // `discount` alongside the header/voucher/loyalty discounts, so
            // `subtotal` always reads as "what the items would cost with no
            // discounts at all" and `discount` is the single total of everything
            // taken off.
            $subtotal = 0;
            $lineDiscountTotal = 0;
            $vatTotal = 0;
            foreach ($lines as $l) {
                $resolved = $this->resolveLineTotals($l);
                $subtotal += $resolved['gross_total'];
                $lineDiscountTotal += $resolved['discount_amount'] + $resolved['deal_discount_amount'];
                $vatTotal += $resolved['vat_amount'];
            }
            $netSubtotal = round($subtotal - $lineDiscountTotal, 2);

            // ── Vouchers: resolve + validate before totals are locked in ──────
            $voucherCodes = array_filter(array_unique($header['voucher_codes'] ?? []));
            $resolvedVouchers = [];
            $voucherDiscountTotal = 0.0;
            $eligibleAmount = $netSubtotal + $vatTotal;
            if ($voucherCodes) {
                foreach ($voucherCodes as $code) {
                    $voucher = $this->vouchers->validate($code, $header['customer_id'] ?? null, (int) $header['branch_id'], $eligibleAmount);
                    $resolvedVouchers[] = $voucher;
                }
                if (count($resolvedVouchers) > 1 && collect($resolvedVouchers)->contains(fn ($v) => ! $v->is_stackable)) {
                    throw new RuntimeException('Only one non-stackable voucher can be applied per invoice.');
                }
                foreach ($resolvedVouchers as $voucher) {
                    $voucherDiscountTotal += $this->vouchers->computeDiscount($voucher, $eligibleAmount - $voucherDiscountTotal);
                }
            }

            // ── Cart-value rule ("Spend & Save"): automatic and authoritative ─
            // Evaluated against the exact same eligible base vouchers use
            // (net subtotal + VAT) — the client previews it from the same
            // server lookup, so what the cashier sees is what gets charged.
            $cartRule = $this->cartDiscounts->bestRule($eligibleAmount);
            $cartRuleDiscount = $cartRule ? $this->cartDiscounts->computeDiscount($cartRule, $eligibleAmount) : 0.0;

            // ── Loyalty points redemption: pure conversion, validated again on apply ─
            // Capped server-side so points can never convert into more discount
            // than the cart actually owes — an over-zealous client that claims
            // more points than the cart can absorb must not burn points for
            // nothing. If the cap pushes the conversion below the program's
            // minimum, the redemption is refused outright rather than half-applied.
            $redeemPoints = (float) ($header['redeem_points'] ?? 0);
            $loyaltyDiscount = 0.0;
            if ($redeemPoints > 0) {
                $program = $this->loyalty->activeProgram();
                if (! $program) {
                    throw new RuntimeException('No active loyalty program to redeem points against.');
                }
                $loyaltyDiscount = round($redeemPoints / (float) $program->redemption_rate, 2);
                $remainingOwed = max(0, $eligibleAmount - (float) ($header['discount'] ?? 0) - $voucherDiscountTotal - $cartRuleDiscount);
                if ($loyaltyDiscount > $remainingOwed + 0.001) {
                    $loyaltyDiscount = round($remainingOwed, 2);
                    $redeemPoints = round($loyaltyDiscount * (float) $program->redemption_rate, 2);
                }
                if ($redeemPoints < (float) $program->minimum_points_to_redeem) {
                    $redeemPoints = 0;
                    $loyaltyDiscount = 0.0;
                }
            }

            $discount = (float) ($header['discount'] ?? 0) + $voucherDiscountTotal + $cartRuleDiscount + $loyaltyDiscount + $lineDiscountTotal;
            $netTotal = max(0, $subtotal - $discount + $vatTotal);

            // A sale fully covered by discount/credit has nothing left to pay.
            $initialPaymentStatus = $netTotal <= 0.001 ? Status::PAID : Status::UNPAID;

            $invoice = Invoice::create([
                'invoice_number' => $this->numbers->next('invoice'),
                'physical_reference' => $header['physical_reference'] ?? null,
                'branch_id' => $header['branch_id'],
                'customer_id' => $header['customer_id'] ?? null,
                'delivery_address_id' => $header['delivery_address_id'] ?? null,
                'till_id' => $header['till_id'] ?? null,
                'pos_session_id' => $header['pos_session_id'] ?? null,
                'amended_from_invoice_id' => $header['amended_from_invoice_id'] ?? null,
                'sales_rep_id' => $header['sales_rep_id'] ?? null,
                'channel' => $header['channel'],
                'order_status_id' => Status::idOf(Status::CTX_INVOICE_ORDER, Status::INVOICED),
                'payment_status_id' => Status::idOf(Status::CTX_INVOICE_PAYMENT, $initialPaymentStatus),
                'subtotal' => $subtotal,
                'vat_total' => $vatTotal,
                'total' => $subtotal + $vatTotal,
                'discount' => $discount,
                'cart_discount_amount' => $cartRuleDiscount,
                'cart_discount_name' => $cartRule?->name,
                'net_total' => $netTotal,
                'invoice_payable' => $netTotal,
                'paid_amount' => 0,
                'balance' => $netTotal,
                'note' => $header['note'] ?? null,
            ]);

            foreach ($lines as $line) {
                $this->sellLine($line, (int) $header['branch_id'], $invoice);
            }

            $remainingVoucherDiscount = $voucherDiscountTotal;
            foreach ($resolvedVouchers as $voucher) {
                $eligibleAmount = $netSubtotal + $vatTotal;
                $amount = min($this->vouchers->computeDiscount($voucher, $eligibleAmount), $remainingVoucherDiscount);
                if ($amount > 0.001) {
                    $this->vouchers->apply($voucher, $invoice, round($amount, 2));
                    $remainingVoucherDiscount -= $amount;
                }
            }

            if ($redeemPoints > 0 && $invoice->customer_id) {
                $this->loyalty->redeem($invoice->customer, $redeemPoints, $invoice);
            }

            if ($splits) {
                $invoice->refresh();
                $this->payments->settleInvoice($invoice, $splits, $confirmShortfall);
                $invoice->refresh();

                // Only the cash portion of a (possibly split) payment enters the
                // till — never card/bank/cheque. settleInvoice() either applies
                // the splits exactly as given or throws, so the requested cash
                // amount here is exactly what was claimed.
                if ($invoice->pos_session_id) {
                    $cashAmount = array_sum(array_map(
                        fn ($split) => ($split['method'] ?? null) === 'cash' ? (float) ($split['amount'] ?? 0) : 0.0,
                        $splits,
                    ));
                    $this->cashMovements->recordSaleCash($invoice, $cashAmount);
                }
            }

            if ($invoice->customer_id) {
                $this->loyalty->earn($invoice->customer, $invoice->fresh());
            }

            AuditLog::record('invoice.created', $invoice, [
                'reference' => $invoice->invoice_number,
                'channel' => $invoice->channel,
                'customer' => $invoice->customer?->business_name,
                'total' => (string) $netTotal,
            ]);

            return $invoice;
        });
    }

    /**
     * Batch-reprice all product lines from their lots in ONE round-trip per
     * concept (batches, sellable candidates, expiry flags) instead of per-line.
     * The single largest N+1 elimination for POS checkout: a 10-line cart
     * previously fired 20 StockBatch queries (lot + front check) plus 10 product
     * existence checks; now it fires 3 queries total regardless of cart size.
     *
     * @param  array<int,array<string,mixed>>  $lines
     * @return array<int,array<string,mixed>>
     */
    private function withAuthoritativePrices(array $lines, int $branchId): array
    {
        $productIds = collect($lines)
            ->filter(fn (array $l) => ! empty($l['product_id']) && empty($l['product_bundle_id']) && empty($l['_bundle_id']))
            ->pluck('product_id')
            ->map(fn ($id) => (int) $id)
            ->unique()
            ->values()
            ->all();

        if ($productIds === []) {
            return $lines;
        }

        // Expiry flag per product drives FEFO vs FIFO front selection.
        $expiryMap = Product::whereIn('id', $productIds)->pluck('is_expiry_tracked', 'id')->all();
        $batchTrackedMap = Product::whereIn('id', $productIds)->pluck('is_batch_tracked', 'id')->all();

        $suppliedBatchIds = collect($lines)
            ->filter(fn (array $l) => ! empty($l['product_id']) && ! empty($l['batch_id']))
            ->pluck('batch_id')
            ->map(fn ($id) => (int) $id)
            ->unique()
            ->values()
            ->all();

        $batchesById = $suppliedBatchIds === []
            ? collect()
            : StockBatch::withoutGlobalScope(BranchScope::class)->with('status:id,code')->whereIn('id', $suppliedBatchIds)->get()->keyBy('id');

        // All sellable candidates for every product in the cart — one query.
        $candidatesByProduct = collect();
        if ($productIds !== []) {
            $candidates = StockBatch::withoutGlobalScope(BranchScope::class)
                ->whereIn('product_id', $productIds)
                ->where('branch_id', $branchId)
                ->active()
                ->where('available_quantity', '>', 0)
                ->whereNotNull('sale_price')
                ->get(['id', 'product_id', 'internal_batch_number', 'sale_price', 'expiry_date', 'available_quantity', 'created_at', 'status_id', 'branch_id']);
            // Eager-load status for isActive() check via whereHas already ensures active, but load relation for message.
            $candidates->load('status:id,code');
            $candidatesByProduct = $candidates->groupBy('product_id');
        }

        // Front lot per product — authoritative via BatchService (SQL ordering)
        // to guarantee exact FEFO/FIFO parity with the lot picker.
        $frontIds = [];
        foreach ($productIds as $pid) {
            $frontIds[$pid] = $this->batches->frontLotId($pid, $branchId, (bool) ($expiryMap[$pid] ?? false));
        }

        $needsOverrideCheck = ! Auth::user()?->hasPermissionTo('inventory_batches.override');

        return array_map(function (array $line) use ($branchId, $batchesById, $candidatesByProduct, $frontIds, $needsOverrideCheck, $batchTrackedMap): array {
            if (! empty($line['product_bundle_id']) || empty($line['product_id'])) {
                return $line;
            }

            $pid = (int) $line['product_id'];
            $isBatchTracked = (bool) ($batchTrackedMap[$pid] ?? false);

            // Non-batch products: single unified pool — bypass lot selection,
            // price from the unified lot at this branch (active price overwritten
            // on each GRN). No FEFO/FIFO or override check applies.
            if (! $isBatchTracked) {
                $lots = $candidatesByProduct->get($pid) ?? collect();
                // Prefer the unified lot (most recent active lot) — for unified
                // products priceRanges/unifiedBatchFor ensures a single lot.
                $batch = $lots->sortByDesc(fn ($lot) => $lot->created_at->getTimestamp())->first();
                if (! $batch) {
                    // Fallback: try unifiedBatchFor directly (covers newly-created
                    // unified lot not yet in candidates due to timing).
                    $batch = app(\App\Services\BatchService::class)->unifiedBatchFor($pid, $branchId);
                }

                if (! $batch) {
                    throw new RuntimeException("Product #{$pid} cannot be sold: no sellable lot at this branch.");
                }
                if ((int) $batch->product_id !== $pid) {
                    throw new RuntimeException("Lot #{$batch->id} does not belong to product #{$pid}.");
                }
                if ((int) $batch->branch_id !== (int) $branchId) {
                    throw new RuntimeException("Lot {$batch->internal_batch_number} does not belong to this branch.");
                }
                if (! $batch->isActive() || (float) $batch->available_quantity <= 0) {
                    throw new RuntimeException("Lot {$batch->internal_batch_number} is not available for sale.");
                }
                if ($batch->sale_price === null) {
                    throw new RuntimeException("Lot {$batch->internal_batch_number} has no price on file.");
                }

                $line['batch_id'] = $batch->id;
                $line['unit_price'] = (float) $batch->sale_price;
                unset($line['line_total'], $line['vat_amount'], $line['gross_total']);

                return $line;
            }

            $batchId = (int) ($line['batch_id'] ?? 0);
            if ($batchId) {
                $batch = $batchesById->get($batchId);
                if (! $batch) {
                    throw new RuntimeException("Product #{$line['product_id']} cannot be sold: no sellable lot at this branch.");
                }
            } else {
                $lots = $candidatesByProduct->get($pid) ?? collect();
                $isExpiry = false; // fallback; expiryMap not in scope — fetch minimal
                // We already have frontIds; pick the batch object for the front id.
                $frontId = $frontIds[$pid] ?? null;
                $batch = $frontId ? $lots->firstWhere('id', $frontId) : null;
                // Fallback to sellableFor ordering if grouping missed (should not happen).
                if (! $batch) {
                    $batch = $lots->first();
                }
            }

            if (! $batch) {
                throw new RuntimeException("Product #{$line['product_id']} cannot be sold: no sellable lot at this branch.");
            }

            if ((int) $batch->product_id !== (int) $line['product_id']) {
                throw new RuntimeException("Lot #{$batch->id} does not belong to product #{$line['product_id']}.");
            }

            if ((int) $batch->branch_id !== (int) $branchId) {
                throw new RuntimeException("Lot {$batch->internal_batch_number} does not belong to this branch.");
            }

            if (! $batch->isActive() || (float) $batch->available_quantity <= 0) {
                throw new RuntimeException("Lot {$batch->internal_batch_number} is not available for sale.");
            }

            if ($batch->sale_price === null) {
                throw new RuntimeException("Lot {$batch->internal_batch_number} has no price on file.");
            }

            if ($needsOverrideCheck) {
                $frontLotId = $frontIds[(int) $line['product_id']] ?? null;
                if ($frontLotId !== null && (int) $frontLotId !== (int) $batch->id) {
                    throw new RuntimeException(
                        "Lot {$batch->internal_batch_number} is not the next lot to sell from. "
                        ."Selecting another lot requires the 'override batch selection' permission."
                    );
                }
            }

            $line['batch_id'] = $batch->id;
            $line['unit_price'] = (float) $batch->sale_price;
            unset($line['line_total'], $line['vat_amount'], $line['gross_total']);

            return $line;
        }, $lines);
    }

    /**
     * Price a line from its lot — and nothing else.
     *
     * The lot, not the product, owns the selling price: `unit_price` is taken
     * from the selected `stock_batch`'s sale_price, so a checkout-UI number can
     * never set a real product's price. When the caller omits `batch_id` (a
     * channel without a lot picker, e.g. the back-office invoice form today),
     * the front sellable lot is taken — FEFO for expiry-tracked products,
     * FIFO otherwise, the same default the POS picker would pre-select and
     * one the server would accept without the override permission anyway. The
     * lot must belong to the stated product AND branch, be active, have
     * stock, and carry a price; anything else is refused outright. The
     * branch/product equality checks below — not BranchScope — are the access
     * enforcement: the lookup is BranchScope-exempt because the caller's
     * branch_id (already validated by the resource layer) is the contract,
     * and the context-branch filter would break legitimate cross-branch
     * service calls. `StockBatch` lookups remain tenant-scoped, so a
     * `batch_id` from another tenant (which the
     * `exists:stock_batches,id` request-validation rule alone would not
     * catch) resolves to null here and is rejected — the same cross-tenant
     * IDOR guard this method always had.
     *
     * @param  array<string,mixed>  $line
     * @return array<string,mixed>
     */
    private function withAuthoritativePrice(array $line, int $branchId): array
    {
        if (! empty($line['product_bundle_id']) || empty($line['product_id'])) {
            return $line;
        }

        $batchId = (int) ($line['batch_id'] ?? 0);

        $batch = $batchId
            ? StockBatch::withoutGlobalScope(BranchScope::class)->with('status:id,code')->find($batchId)
            : $this->batches->sellableFor((int) $line['product_id'], $branchId)->first();

        if (! $batch) {
            throw new RuntimeException("Product #{$line['product_id']} cannot be sold: no sellable lot at this branch.");
        }

        if ((int) $batch->product_id !== (int) $line['product_id']) {
            throw new RuntimeException("Lot #{$batch->id} does not belong to product #{$line['product_id']}.");
        }

        if ((int) $batch->branch_id !== (int) $branchId) {
            throw new RuntimeException("Lot {$batch->internal_batch_number} does not belong to this branch.");
        }

        if (! $batch->isActive() || (float) $batch->available_quantity <= 0) {
            throw new RuntimeException("Lot {$batch->internal_batch_number} is not available for sale.");
        }

        if ($batch->sale_price === null) {
            throw new RuntimeException("Lot {$batch->internal_batch_number} has no price on file.");
        }

        // The front lot is the default for everyone — FEFO for expiry-tracked
        // products, FIFO otherwise (see BatchService::frontLotId); selling
        // from any other lot is the `inventory_batches.override` privilege.
        if (! Auth::user()?->hasPermissionTo('inventory_batches.override')) {
            $frontLotId = $this->batches->frontLotId((int) $line['product_id'], $branchId);

            if ($frontLotId !== null && (int) $frontLotId !== $batch->id) {
                throw new RuntimeException(
                    "Lot {$batch->internal_batch_number} is not the next lot to sell from. "
                    ."Selecting another lot requires the 'override batch selection' permission."
                );
            }
        }

        $line['batch_id'] = $batch->id;
        $line['unit_price'] = (float) $batch->sale_price;

        // The caller may have pre-derived line_total/vat_amount/gross_total from
        // the tampered price above (e.g. a controller's own rough estimate) —
        // drop them so resolveLineTotals()/sellLine() recompute both fresh from
        // the corrected unit_price instead of trusting the stale figures.
        unset($line['line_total'], $line['vat_amount'], $line['gross_total']);

        return $line;
    }

    /**
     * Single source of truth for a line's final total: base price/quantity
     * minus any line-level discount (the cashier's manual one and the
     * automatic deal discount), with VAT computed on the discounted (net)
     * amount. Used both to compute the invoice header totals up front
     * and to persist the actual `InvoiceItem` in `sellLine()` — so the
     * header always matches the sum of its items even when discounts are
     * involved.
     *
     * @param  array<string,mixed>  $line
     * @return array{line_total: float, gross_total: float, discount_amount: float, deal_discount_amount: float, vat_amount: float}
     */
    private function resolveLineTotals(array $line): array
    {
        if (! empty($line['product_bundle_id'])) {
            $lineTotal = (float) ProductBundle::findOrFail($line['product_bundle_id'])->sale_price * (float) $line['quantity'];

            return ['line_total' => $lineTotal, 'gross_total' => $lineTotal, 'discount_amount' => 0.0, 'deal_discount_amount' => 0.0, 'vat_amount' => $this->vat->enabled() ? (float) ($line['vat_amount'] ?? 0) : 0.0];
        }

        $quantity = (float) $line['quantity'];
        $vatRate = $this->vatRate($line['vat_rate'] ?? 0);
        $grossTotal = (float) ($line['line_total'] ?? ($quantity * (float) $line['unit_price']));

        $discountAmount = $this->computeLineDiscount($grossTotal, $quantity, $line['discount_type'] ?? null, (float) ($line['discount_value'] ?? 0));
        // The deal's share is clamped to what the manual discount left of the
        // line, so the two together can never take a line below zero.
        $dealAmount = round(min(max((float) ($line['deal_discount_amount'] ?? 0), 0), max(0, $grossTotal - $discountAmount)), 2);
        $lineTotal = round($grossTotal - $discountAmount - $dealAmount, 2);

        return [
            'line_total' => $lineTotal,
            'gross_total' => round($grossTotal, 2),
            'discount_amount' => $discountAmount,
            'deal_discount_amount' => $dealAmount,
            'vat_amount' => round($lineTotal * $vatRate / 100, 2),
        ];
    }

    /**
     * The single choke point every VAT rate — from a product, a POS line, a
     * manual invoice line, or a sales-order conversion — passes through
     * before it can affect a total. While the tenant's VAT master switch is
     * off, this always returns 0, regardless of what the caller supplied: a
     * hidden control in the UI is not enough on its own (see the
     * loyalty/voucher enforcement pattern in Pos::completeCheckout()).
     */
    private function vatRate(mixed $rate): float
    {
        return $this->vat->enabled() ? (float) $rate : 0.0;
    }

    /**
     * Clamp a fixed/percentage discount to the amount it's taken off of — a
     * discount can never exceed (or invert the sign of) the line it applies to.
     */
    private function computeLineDiscount(float $amount, float $quantity, ?string $type, float $value): float
    {
        if (! $type || $value <= 0 || $amount <= 0) {
            return 0.0;
        }

        // A fixed discount is a per-unit amount: 100 Rs. on a 2-unit line is
        // 200 Rs. off, not 100 Rs. A percentage is already quantity-scaled
        // because $amount = quantity × unit price.
        $discount = $type === 'percentage' ? $amount * ($value / 100) : $value * $quantity;

        return round(min(max($discount, 0), $amount), 2);
    }

    /**
     * Sell a single cart line. A line without serials creates a single
     * `InvoiceItem`, exactly as before. A line that carries serials (data-
     * driven: `serial_ids` present) explodes into one `InvoiceItem` per serial
     * unit so every resulting row carries a single, unambiguous `stock_batch_id`
     * / `product_serial_id` — that is what lets `ReturnService` trace a return
     * straight back to the exact unit sold via `invoice_item_id`.
     *
     * @param  array<string,mixed>  $line
     */
    private function sellLine(array $line, int $branchId, Invoice $invoice): void
    {
        if (! empty($line['product_bundle_id'])) {
            $this->sellBundleLine($line, $branchId, $invoice);

            return;
        }

        if (empty($line['product_id'])) {
            $grossTotal = (float) ($line['line_total'] ?? ((float) $line['quantity'] * (float) $line['unit_price']));
            $discountType = $line['discount_type'] ?? null;
            $discountValue = (float) ($line['discount_value'] ?? 0);
            $discountAmount = $this->computeLineDiscount($grossTotal, (float) $line['quantity'], $discountType, $discountValue);
            $lineTotal = round($grossTotal - $discountAmount, 2);
            $vatRate = $this->vatRate($line['vat_rate'] ?? 0);

            InvoiceItem::create([
                'invoice_id' => $invoice->id,
                'unit_id' => $line['unit_id'] ?? null,
                'product_name' => $line['product_name'],
                'product_sku' => $line['product_sku'] ?? null,
                'product_barcode' => $line['product_barcode'] ?? null,
                'quantity' => $line['quantity'],
                'unit_price' => $line['unit_price'],
                'vat_rate' => $vatRate,
                'vat_amount' => round($lineTotal * $vatRate / 100, 2),
                'gross_total' => round($grossTotal, 2),
                'discount_type' => $discountType,
                'discount_value' => $discountValue,
                'discount_amount' => $discountAmount,
                'line_total' => $lineTotal,
            ]);

            return;
        }

        static $productCache = [];
        $pid = (int) $line['product_id'];
        if (! array_key_exists($pid, $productCache)) {
            $productCache[$pid] = Product::find($pid);
        }
        $product = $productCache[$pid];
        $unitId = $line['unit_id'] ?? null;

        // The printed invoice shows the unit of measure next to the quantity.
        // POS, the manual invoice form and sales-order conversion never send an
        // explicit unit, so fall back to the product's own base unit — a bare
        // number on paper reads as "units unknown", not "one unit".
        if ($unitId === null && $product !== null && $product->base_unit_id !== null) {
            $unitId = $product->base_unit_id;
        }

        $quantity = (float) $line['quantity'];
        $unitPrice = (float) $line['unit_price'];
        $vatRate = $this->vatRate($line['vat_rate'] ?? 0);

        $baseQty = ($product && $unitId)
            ? $this->units->toBaseUnit($product, (int) $unitId, $quantity)
            : $quantity;

        $common = [
            'invoice_id' => $invoice->id,
            'product_id' => $product?->id,
            'unit_id' => $unitId,
            'product_name' => $line['product_name'],
            'product_sku' => $line['product_sku'] ?? null,
            'product_barcode' => $line['product_barcode'] ?? null,
            'vat_rate' => $vatRate,
            'product_bundle_id' => $line['_bundle_id'] ?? null,
            'bundle_group_key' => $line['_bundle_group_key'] ?? null,
        ];

        // ── A line that actually carries serial ids: one row per unit ────────
        $serialIds = SerialInput::ids($line['serial_ids'] ?? null);
        if ($serialIds !== []) {
            $expectedCount = (int) round($quantity);

            if (abs($quantity - $expectedCount) > 0.0001 || count($serialIds) !== $expectedCount) {
                throw StockReferenceException::serial(
                    $product->id,
                    "Product {$line['product_name']}: exactly {$expectedCount} serial number(s) must be selected for this line, ".count($serialIds).' given',
                );
            }

            // Line-level discounts (the cashier's manual one and the automatic deal
            // one) are each spread evenly across serial units, with the last unit
            // absorbing the rounding remainder so the sum always matches the
            // line's total exactly (mirrors the bundle component split below).
            $discountType = $line['discount_type'] ?? null;
            $discountValue = (float) ($line['discount_value'] ?? 0);
            $lineGrossTotal = round($unitPrice * $quantity, 2);
            $lineDiscountAmount = $this->computeLineDiscount($lineGrossTotal, $quantity, $discountType, $discountValue);
            $lineDealAmount = (float) ($line['deal_discount_amount'] ?? 0);
            $allocatedDiscount = 0.0;
            $allocatedDeal = 0.0;

            // Batch-fetch all serials for this line in one query (was N queries).
            $serialsById = ProductSerial::whereIn('id', $serialIds)->get()->keyBy('id');
            // The batch for this line's serials is the same lot — fetch once.
            $sharedBatch = null;
            $sharedBatchId = (int) ($line['batch_id'] ?? 0);
            if ($sharedBatchId) {
                $sharedBatch = StockBatch::withoutGlobalScope(BranchScope::class)->find($sharedBatchId);
            }

            foreach ($serialIds as $serialIndex => $serialId) {
                $serial = $serialsById->get($serialId);
                if (! $serial) {
                    throw new \Illuminate\Database\Eloquent\ModelNotFoundException("Serial #{$serialId} not found");
                }
                $this->serials->validateAvailable($serial);

                // Universal lots: every serial sits on the lot its receipt
                // created, and that lot must be the one the line was priced
                // against (withAuthoritativePrice validated it).
                $stockBatchId = $serial->stock_batch_id;
                if (! $stockBatchId) {
                    throw StockReferenceException::lot(
                        $product->id,
                        "Serial #{$serial->serial_number} has no lot on file",
                    );
                }
                $selectedBatchId = $sharedBatchId;
                if ($selectedBatchId && (int) $stockBatchId !== $selectedBatchId) {
                    throw StockReferenceException::serial(
                        $product->id,
                        "Serial #{$serial->serial_number} does not belong to the selected lot",
                    );
                }

                $unitBaseQty = $baseQty / $quantity;

                $this->stock->record(
                    branchId: $branchId,
                    productId: $product->id,
                    movementTypeCode: MovementType::SALE,
                    quantityChange: -$unitBaseQty,
                    unitCost: $unitPrice,
                    referenceType: $invoice,
                    referenceNumber: $invoice->invoice_number,
                    stockBatchId: $stockBatchId,
                );

                if ($stockBatchId) {
                    $batchForConsume = $sharedBatch && (int) $sharedBatch->id === (int) $stockBatchId ? $sharedBatch : StockBatch::withoutGlobalScope(BranchScope::class)->findOrFail($stockBatchId);
                    $this->batches->consume($batchForConsume, $unitBaseQty);
                }

                $this->serials->assignToInvoice($serial, $invoice->id, (int) ($invoice->customer_id ?? 0));

                $unitGross = round($unitPrice, 2);
                $isLastSerial = $serialIndex === $expectedCount - 1;
                $unitDiscount = $isLastSerial
                    ? round($lineDiscountAmount - $allocatedDiscount, 2)
                    : round($lineDiscountAmount / $expectedCount, 2);
                $unitDeal = $isLastSerial
                    ? round($lineDealAmount - $allocatedDeal, 2)
                    : round($lineDealAmount / $expectedCount, 2);
                $allocatedDiscount += $unitDiscount;
                $allocatedDeal += $unitDeal;
                $unitNet = round($unitGross - $unitDiscount - $unitDeal, 2);

                InvoiceItem::create($common + [
                    'quantity' => 1,
                    'unit_price' => $unitPrice,
                    'vat_amount' => round($unitNet * $vatRate / 100, 2),
                    'gross_total' => $unitGross,
                    'discount_type' => $discountType,
                    'discount_value' => $discountValue,
                    'discount_amount' => $unitDiscount,
                    'deal_id' => $line['deal_id'] ?? null,
                    'deal_name' => $line['deal_name'] ?? null,
                    'deal_discount_amount' => $unitDeal,
                    'line_total' => $unitNet,
                    'stock_batch_id' => $stockBatchId,
                    'product_serial_id' => $serialId,
                ]);
            }

            return;
        }

        // ── Batch tracked only: sell from the line's selected lot ───────────
        // Every product line carries the lot it was priced against
        // (withAuthoritativePrice validated product, branch, status and
        // stock) — FEFO ordering happens at selection time, in the lot
        // picker, not here.
        $batch = StockBatch::withoutGlobalScope(BranchScope::class)->findOrFail((int) ($line['batch_id'] ?? 0));
        if ((int) $batch->branch_id !== (int) $branchId) {
            throw new RuntimeException("Lot {$batch->internal_batch_number} does not belong to this branch.");
        }

        $allocations = [['batch_id' => $batch->id, 'batch' => $batch, 'quantity' => $baseQty]];

        // Line-level discounts (the cashier's manual one and the automatic deal
        // one) are each spread across batch chunks proportionally to the
        // chunk's share of the gross line total, with the last chunk
        // absorbing the rounding remainder (same convention as the serial
        // branch above and the bundle component split below).
        $discountType = $line['discount_type'] ?? null;
        $discountValue = (float) ($line['discount_value'] ?? 0);
        $lineGrossTotal = round($quantity * $unitPrice, 2);
        $lineDiscountAmount = $this->computeLineDiscount($lineGrossTotal, $quantity, $discountType, $discountValue);
        $lineDealAmount = (float) ($line['deal_discount_amount'] ?? 0);
        $allocatedDiscount = 0.0;
        $allocatedDeal = 0.0;
        $lastAllocationIndex = count($allocations) - 1;

        foreach ($allocations as $allocationIndex => $allocation) {
            $chunkBaseQty = (float) $allocation['quantity'];
            $chunkQty = $baseQty > 0 ? $quantity * ($chunkBaseQty / $baseQty) : $quantity;
            $chunkGrossTotal = round($chunkQty * $unitPrice, 2);
            $isLastAllocation = $allocationIndex === $lastAllocationIndex;
            $chunkDiscount = $isLastAllocation
                ? round($lineDiscountAmount - $allocatedDiscount, 2)
                : round($lineDiscountAmount * ($lineGrossTotal > 0 ? $chunkGrossTotal / $lineGrossTotal : 0), 2);
            $chunkDeal = $isLastAllocation
                ? round($lineDealAmount - $allocatedDeal, 2)
                : round($lineDealAmount * ($lineGrossTotal > 0 ? $chunkGrossTotal / $lineGrossTotal : 0), 2);
            $allocatedDiscount += $chunkDiscount;
            $allocatedDeal += $chunkDeal;
            $chunkLineTotal = round($chunkGrossTotal - $chunkDiscount - $chunkDeal, 2);

            $this->stock->record(
                branchId: $branchId,
                productId: $product->id,
                movementTypeCode: MovementType::SALE,
                quantityChange: -$chunkBaseQty,
                unitCost: $unitPrice,
                referenceType: $invoice,
                referenceNumber: $invoice->invoice_number,
                stockBatchId: $allocation['batch_id'],
            );

            $this->batches->consume($allocation['batch'], $chunkBaseQty);

            InvoiceItem::create($common + [
                'quantity' => $chunkQty,
                'unit_price' => $unitPrice,
                'vat_amount' => round($chunkLineTotal * $vatRate / 100, 2),
                'gross_total' => $chunkGrossTotal,
                'discount_type' => $discountType,
                'discount_value' => $discountValue,
                'discount_amount' => $chunkDiscount,
                'deal_id' => $line['deal_id'] ?? null,
                'deal_name' => $line['deal_name'] ?? null,
                'deal_discount_amount' => $chunkDeal,
                'line_total' => $chunkLineTotal,
                'stock_batch_id' => $allocation['batch_id'],
            ]);
        }
    }

    /**
     * Expand a bundle into its components and sell each one through the
     * normal per-product path (so the per-serial-unit explosion still applies
     * to bundle components). The bundle's deal price is distributed across
     * components proportionally to their own retail price, with the last
     * component absorbing any rounding remainder so the lines always sum
     * exactly to the bundle price. Bundle stock is never tracked directly.
     *
     * @param  array<string,mixed>  $line
     */
    private function sellBundleLine(array $line, int $branchId, Invoice $invoice): void
    {
        $bundle = ProductBundle::with('items.product')->findOrFail($line['product_bundle_id']);

        // The purchase gate, enforced server-side on every channel (POS
        // checkout, held-sale resume, sales orders): a bundle that is
        // switched off, expired, or scheduled for a future date must never
        // be sold, whatever the client payload claims.
        if (! $bundle->isCurrentlyActive()) {
            throw new RuntimeException("Bundle \"{$bundle->name}\" is not currently available for sale.");
        }

        $qty = (float) $line['quantity'];

        $this->bundles->validateStock($bundle, $branchId, $qty);

        $componentsTotal = $this->bundles->componentsRetailTotal($bundle, $branchId);
        $bundlePrice = round((float) $bundle->sale_price * $qty, 2);
        $factor = $componentsTotal > 0 ? $bundlePrice / ($componentsTotal * $qty) : 1.0;

        $components = $this->bundles->expand($bundle, $qty, $branchId);
        $groupKey = (string) Str::ulid();
        $allocated = 0.0;
        $lastIndex = count($components) - 1;

        foreach ($components as $i => $component) {
            $retailLineTotal = $component['sale_price'] * $component['quantity'];
            $componentLineTotal = $i === $lastIndex
                ? round($bundlePrice - $allocated, 2)
                : round($retailLineTotal * $factor, 2);
            $allocated += $componentLineTotal;

            $componentUnitPrice = $component['quantity'] > 0 ? $componentLineTotal / $component['quantity'] : 0.0;
            $product = Product::find($component['product_id']);

            $this->sellLine([
                'product_id' => $component['product_id'],
                'unit_id' => $component['unit_id'],
                'quantity' => $component['quantity'],
                'unit_price' => $componentUnitPrice,
                'batch_id' => $component['batch_id'],
                'product_name' => $product?->name ?? "Product#{$component['product_id']}",
                'product_sku' => $product?->sku,
                'vat_rate' => $this->vatRate($product?->vat_rate ?? 0),
                'line_total' => $componentLineTotal,
                '_bundle_id' => $bundle->id,
                '_bundle_group_key' => $groupKey,
            ], $branchId, $invoice);
        }
    }

    /**
     * Record a legacy (pre-system) invoice from the old paper books.
     *
     * Differs from create():
     *  - the caller supplies the original paper invoice number and date
     *  - no stock movements are written — the goods left long before the system
     *  - line items are optional; without them the entered total stands alone
     *  - the amount the customer already settled becomes an opening settlement
     *    transaction, so only the true outstanding balance remains collectable
     *
     * @param  array{invoice_number:string,customer_id:int,branch_id:int,invoice_date:string,total?:float,discount?:float,note?:string}  $header
     * @param  array<int,array{product_id?:int|null,product_name:string,quantity:float,unit_price:float}>  $lines
     */
    public function createLegacy(array $header, array $lines = [], float $settledAmount = 0): Invoice
    {
        return DB::transaction(function () use ($header, $lines, $settledAmount) {
            $subtotal = $lines === []
                ? round((float) ($header['total'] ?? 0), 2)
                : round(array_sum(array_map(
                    fn ($l) => (float) $l['quantity'] * (float) $l['unit_price'],
                    $lines,
                )), 2);

            $discount = round((float) ($header['discount'] ?? 0), 2);
            $netTotal = max(0, round($subtotal - $discount, 2));
            $settledAmount = round(min($settledAmount, $netTotal), 2);

            if ($netTotal <= 0) {
                throw new RuntimeException('A legacy invoice must have a value greater than zero.');
            }

            $invoiceDate = Carbon::parse($header['invoice_date']);

            $invoice = new Invoice([
                'invoice_number' => $header['invoice_number'],
                'branch_id' => $header['branch_id'],
                'customer_id' => $header['customer_id'],
                'channel' => 'legacy',
                'is_legacy' => true,
                'order_status_id' => Status::idOf(Status::CTX_INVOICE_ORDER, Status::DELIVERED),
                'payment_status_id' => Status::idOf(Status::CTX_INVOICE_PAYMENT, Status::UNPAID),
                'subtotal' => $subtotal,
                'vat_total' => 0,
                'total' => $subtotal,
                'discount' => $discount,
                'net_total' => $netTotal,
                'invoice_payable' => $netTotal,
                'paid_amount' => 0,
                'balance' => $netTotal,
                'note' => $header['note'] ?? null,
            ]);

            // Backdate to the paper date so ageing and reports stay truthful.
            $invoice->created_at = $invoiceDate;
            $invoice->updated_at = $invoiceDate;
            $invoice->save();

            foreach ($lines as $line) {
                $product = isset($line['product_id']) ? Product::find($line['product_id']) : null;

                InvoiceItem::create([
                    'invoice_id' => $invoice->id,
                    'product_id' => $line['product_id'] ?? null,
                    'unit_id' => $line['unit_id'] ?? $product?->base_unit_id,
                    'product_name' => $line['product_name'],
                    'quantity' => (float) $line['quantity'],
                    'unit_price' => (float) $line['unit_price'],
                    'vat_rate' => 0,
                    'vat_amount' => 0,
                    'line_total' => round((float) $line['quantity'] * (float) $line['unit_price'], 2),
                ]);
            }

            if ($settledAmount > 0.001) {
                $this->payments->recordLegacyOpeningSettlement($invoice->refresh(), $settledAmount);
            }

            AuditLog::record('invoice.legacy_created', $invoice, [
                'reference' => $invoice->invoice_number,
                'total' => (string) $netTotal,
                'already_settled' => (string) $settledAmount,
            ]);

            return $invoice->refresh();
        });
    }

    /**
     * Rebuild a sales-order invoice's lines and totals from its order.
     *
     * The counterpart to SalesOrderService::update() on a converted order: the
     * order is the living document and the invoice is its snapshot, so when
     * the order's lines, discounts or dates change, the invoice's items and
     * header totals are rewritten to match — with the money side untouched.
     * The paid amount, the claims behind it and the stock movements were
     * banked at conversion and cannot be "un-banked" by a line edit, so
     * recomputeInvoicePayment() re-derives paid/payment-status from those
     * unchanged claims.
     *
     * Refused outright when the order's new total can no longer absorb what
     * was banked against the old one — the excess would need a credit note,
     * not a line edit.
     *
     * @throws RuntimeException if the new total falls below the banked payments
     */
    public function resyncFromSalesOrder(Invoice $invoice, SalesOrder $order): Invoice
    {
        $items = $order->items()->get();

        $subtotal = round((float) $items->sum('gross_total'), 2);
        $lineDiscountTotal = round((float) $items->sum('discount_amount'), 2);
        $vatTotal = round((float) $items->sum('vat_amount'), 2);

        // The order-level discount only — line-level ones travel on the lines,
        // exactly as SalesOrderService passes the header at conversion.
        $headerDiscount = round((float) $order->discount - $lineDiscountTotal, 2);
        $discount = round($lineDiscountTotal + $headerDiscount, 2);
        $netTotal = round(max(0, $subtotal - $discount + $vatTotal), 2);

        $paid = (float) $invoice->paid_amount;
        if ($netTotal < $paid - 0.001) {
            throw new RuntimeException(
                'This order was paid Rs. '.number_format($paid, 2)
                .' but is now worth only Rs. '.number_format($netTotal, 2)
                .'. The invoice cannot be edited below what was banked — settle the excess with a credit note first.'
            );
        }

        return DB::transaction(function () use ($invoice, $order, $items, $subtotal, $vatTotal, $discount, $netTotal): Invoice {
            $invoice->items()->delete();

            $productIds = $items->pluck('product_id')->filter()->unique();
            $products = Product::whereIn('id', $productIds)->get()->keyBy('id');

            foreach ($items as $item) {
                $product = $item->product_id ? $products->get((int) $item->product_id) : null;
                $unitId = $item->unit_id ?? $product?->base_unit_id;

                // Mirrors what conversion wrote: catalog lines keep their lot
                // (for traceability), custom lines are free-form. No stock,
                // serial, deal or payment writes — none of those belong to a
                // re-snapshot.
                InvoiceItem::create([
                    'invoice_id' => $invoice->id,
                    'product_id' => $item->product_id,
                    'unit_id' => $unitId,
                    'stock_batch_id' => $item->is_custom ? null : $item->stock_batch_id,
                    'product_name' => $item->product_name,
                    'product_sku' => $item->product_sku,
                    'quantity' => (float) $item->quantity,
                    'unit_price' => (float) $item->unit_price,
                    'vat_rate' => $this->vatRate($item->vat_rate),
                    'vat_amount' => round((float) $item->vat_amount, 2),
                    'gross_total' => round((float) $item->gross_total, 2),
                    'discount_type' => $item->discount_type,
                    'discount_value' => (float) $item->discount_value,
                    'discount_amount' => round((float) $item->discount_amount, 2),
                    'line_total' => round((float) $item->line_total, 2),
                ]);
            }

            $invoice->update([
                'subtotal' => $subtotal,
                'vat_total' => $vatTotal,
                'total' => round($subtotal + $vatTotal, 2),
                'discount' => $discount,
                'net_total' => $netTotal,
                'invoice_payable' => $netTotal,
            ]);

            // paid_amount / balance / payment_status_id are re-derived from the
            // claims, which this resync never touches.
            $this->payments->recomputeInvoicePayment($invoice);

            AuditLog::record('sales_order.resynced', $order->refresh(), [
                'reference' => $order->order_number,
                'invoice' => $invoice->invoice_number,
                'total' => (string) $netTotal,
            ]);

            return $invoice->refresh();
        });
    }

    /**
     * Reverse and soft-delete an invoice.
     *
     * Reverses all stock movements, settlement transactions, and consumed
     * customer credit. Blocks if a claimed credit note or a transaction
     * shared across multiple invoices would prevent a clean reversal.
     *
     * @throws RuntimeException if a fail-safe guard is triggered
     * @throws \InvalidArgumentException if the invoice is already deleted
     */
    public function delete(Invoice $invoice): void
    {
        if ($invoice->trashed()) {
            throw new \InvalidArgumentException('Invoice is already deleted.');
        }

        DB::transaction(function () use ($invoice) {
            $invoice->load(['items.product:id,name', 'items.stockBatch', 'items.serial', 'claims.transaction', 'customer', 'branch:id,name']);

            // ── Fail-safe 1: block if a claimed credit note exists ────────────
            $hasClaimedCn = CreditNote::query()
                ->where('claimed_invoice_id', $invoice->id)
                ->where('status_id', Status::idOf(Status::CTX_CREDIT_NOTE, Status::CLAIMED))
                ->exists();
            if ($hasClaimedCn) {
                throw new RuntimeException(
                    'Cannot delete invoice: a claimed credit note is linked to it. Reverse the credit note claim first.'
                );
            }

            // ── Fail-safe 2: block if any settlement transaction also claims other invoices ─
            foreach ($invoice->claims as $claim) {
                $txn = $claim->transaction;
                if (! $txn) {
                    continue;
                }
                // Claims tied to an already-deleted invoice don't count as "other
                // invoices settled" — whereHas('invoice') inherits Invoice's own
                // SoftDeletes scope and excludes them automatically.
                $otherClaims = $txn->claims()->where('invoice_id', '!=', $invoice->id)->whereHas('invoice')->count();
                if ($otherClaims > 0) {
                    throw new RuntimeException(
                        "Cannot delete invoice: transaction {$txn->transaction_number} also settles other invoices. Unallocate it manually first."
                    );
                }
            }

            // ── Step 1: reverse stock for every line with a product ───────────
            foreach ($invoice->items as $item) {
                if (! $item->product_id) {
                    continue;
                }
                $this->stock->record(
                    branchId: (int) $invoice->branch_id,
                    productId: (int) $item->product_id,
                    movementTypeCode: MovementType::INVOICE_REVERSAL,
                    quantityChange: (float) $item->quantity,
                    unitCost: isset($item->unit_price) ? (float) $item->unit_price : null,
                    referenceType: $invoice,
                    referenceNumber: $invoice->invoice_number,
                    note: "Invoice deletion reversal of {$invoice->invoice_number}",
                    productName: $item->product?->name ?? "Product#{$item->product_id}",
                    branchName: $invoice->branch?->name,
                    stockBatchId: $item->stock_batch_id,
                );

                if ($item->stockBatch) {
                    $this->batches->restock($item->stockBatch, (float) $item->quantity);
                }

                if ($item->serial) {
                    $this->serials->reverseSale($item->serial, (int) $invoice->branch_id);
                }
            }

            // ── Step 2: soft-delete all settlement transactions ───────────────
            foreach ($invoice->claims as $claim) {
                $txn = $claim->transaction;
                if ($txn && ! $txn->trashed()) {
                    $txn->delete();
                }
            }

            // ── Step 3: reverse any customer credit consumed by this invoice ──
            if ($invoice->customer_id) {
                $creditOutEntries = CustomerCreditLedger::query()
                    ->where('customer_id', $invoice->customer_id)
                    ->where('type', 'credit_out')
                    ->where('reference_type', Invoice::class)
                    ->where('reference_id', $invoice->id)
                    ->get();

                foreach ($creditOutEntries as $entry) {
                    $amount = (float) $entry->amount;
                    if ($amount > 0.001) {
                        $this->customerCredit->credit(
                            customer: $invoice->customer,
                            amount: $amount,
                            reference: $invoice,
                            note: "Credit reversal for deleted invoice {$invoice->invoice_number}",
                        );
                    }
                }
            }

            // ── Step 4: reverse any voucher redemptions tied to this invoice ──
            foreach (VoucherRedemption::where('invoice_id', $invoice->id)->where('is_reversed', false)->get() as $redemption) {
                $this->vouchers->reverse($redemption, "Invoice {$invoice->invoice_number} deleted.");
            }

            // ── Step 5: reverse any loyalty points earned/redeemed on this invoice ─
            $this->loyalty->reverse($invoice);

            AuditLog::record('invoice.deleted', $invoice, [
                'invoice_number' => $invoice->invoice_number,
                'customer' => $invoice->customer?->business_name,
                'net_total' => (string) $invoice->net_total,
                'claims_reversed' => $invoice->claims->count(),
            ]);

            $invoice->delete();
        });
    }
}
