<?php

namespace App\Filament\Pages;

use App\Exceptions\InsufficientStockException;
use App\Exceptions\StorageQuotaExceededException;
use App\Filament\Resources\CashMovementResource;
use App\Filament\Resources\InvoiceResource;
use App\Filament\Resources\TillResource;
use App\Filament\Support\RelationSelect;
use App\Http\Controllers\Concerns\AppliesCheckoutCredit;
use App\Http\Controllers\InvoicePrintController;
use App\Models\Catalog\MainCategory;
use App\Models\Catalog\Product;
use App\Models\Catalog\ProductBundle;
use App\Models\Customers\Customer;
use App\Models\Geo\City;
use App\Models\Inventory\BarcodeLabel;
use App\Models\Inventory\ProductSerial;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\CashMovementReason;
use App\Models\Lookups\PaymentMethod;
use App\Models\Lookups\Status;
use App\Models\Loyalty\CustomerLoyaltyBalance;
use App\Models\Promotions\CartDiscount;
use App\Models\Returns\CreditNote;
use App\Models\Sales\CashMovement;
use App\Models\Sales\HeldSale;
use App\Models\Sales\Invoice;
use App\Models\Sales\PosSession;
use App\Models\Sales\Till;
use App\Models\Scopes\BranchScope;
use App\Services\BarcodeLabelService;
use App\Services\BatchService;
use App\Services\CartDiscountService;
use App\Services\CashMovementService;
use App\Services\CreditNoteService;
use App\Services\CurrentContext;
use App\Services\CustomerCreditService;
use App\Services\DealService;
use App\Services\HeldSaleService;
use App\Services\InvoiceService;
use App\Services\LoyaltyService;
use App\Services\PosSessionService;
use App\Services\PosSettings;
use App\Services\Storage\TenantStorage;
use App\Services\VatSettings;
use App\Services\VoucherService;
use App\Support\Invoice\InvoiceBusiness;
use App\Support\Invoice\InvoiceDocument;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Renderless;
use RuntimeException;

/**
 * Ported from App\Http\Controllers\PosController (docs/migration-plan.md
 * Phase 5, §7 of the locked-in architecture).
 *
 * A custom Page, not a Resource: the POS terminal is a single-screen cart +
 * checkout workflow, not CRUD over a model.
 *
 * **Cart state lives entirely in Alpine on the client** (see the Blade view).
 * Adding a line, changing a quantity, or recomputing the running total never
 * round-trips to Livewire — that would be a severe UX regression against the
 * React SPA this replaces. The client posts one payload at genuine boundaries
 * only: checkout, hold, resume, till/session changes, cash movements, amend.
 *
 * Every one of those boundaries calls the same framework-agnostic services the
 * source controller called, with the same arguments, in the same order, inside
 * the same transaction boundaries.
 */
class Pos extends Page
{
    use AppliesCheckoutCredit;

    protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedShoppingCart;

    protected static string|\UnitEnum|null $navigationGroup = 'Point of Sale';

    protected static ?string $navigationLabel = 'POS Terminal';

    protected static ?string $slug = 'pos';

    protected static ?int $navigationSort = -1;

    /** The terminal is a full-width two-panel screen, not an article column. */
    protected Width|string|null $maxContentWidth = Width::Full;

    /** Livewire upload target for custom payment method "File" fields. */
    public ?UploadedFile $paymentAttachment = null;

    /**
     * Session key holding the branch this POS device is selling from.
     *
     * Deliberately separate from the app-wide `selected_branch_id` used by the
     * top-bar selector: that one is a viewing preference for reports and may
     * legitimately be "All branches", which is never a valid state to sell in.
     */
    public const BRANCH_SESSION_KEY = 'pos_branch_id';

    protected string $view = 'filament.pages.pos';

    /** Mirrors `can_do:pos.access` on the source's `pos` route. */
    public static function canAccess(): bool
    {
        return Auth::user()?->hasPermissionTo('pos.access') ?? false;
    }

    public static function shouldRegisterNavigation(): bool
    {
        return static::canAccess();
    }

    /**
     * @return array<int, Action>
     */
    protected function getHeaderActions(): array
    {
        return [
            Action::make('newCustomer')
                ->label('New customer')
                ->modalHeading('New customer')
                ->modalSubmitActionLabel('Create')
                ->modalWidth('2xl')
                ->schema([
                    TextInput::make('business_name')
                        ->label('Customer name')
                        ->required()
                        ->maxLength(200),

                    TextInput::make('phone')
                        ->required()
                        ->maxLength(30)
                        ->tel(),

                    TextInput::make('nic')
                        ->label('NIC')
                        ->maxLength(20),

                    TextInput::make('email')
                        ->email()
                        ->maxLength(150),

                    RelationSelect::standalone(
                        name: 'city_id',
                        modelClass: City::class,
                        titleAttribute: 'name',
                    )->label('City'),
                ])
                ->action(function (array $data): void {
                    $customer = Customer::create(
                        $data + ['is_active' => true]
                    );

                    $this->dispatch(
                        'pos-customer-created',
                        customer: [
                            'id' => $customer->id,
                            'business_name' => $customer->business_name,
                            'phone' => $customer->phone,
                            'nic' => $customer->nic,
                        ],
                    );

                    Notification::make()
                        ->title('Customer created successfully')
                        ->success()
                        ->send();
                }),
        ];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Context resolution (ported verbatim from PosController)
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Resolves the till session behind the current POS device, if any is open.
     */
    public function resolveActiveSession(): ?PosSession
    {
        $tillId = session('pos_till_id');

        // Deliberately unscoped by branch: this lookup *establishes* the branch,
        // so it cannot be filtered by it without a circular dependency — the
        // scope would fall back to the first accessible branch and hide a
        // legitimately open session on a till in another branch. The till id is
        // itself the scoping key and access was checked in assignTill().
        return $tillId
            ? PosSession::withoutGlobalScope(BranchScope::class)
                ->with('till')
                ->where('till_id', $tillId)
                ->whereHas('status', fn ($q) => $q->where('code', Status::SESSION_OPEN))
                ->latest()
                ->first()
            : null;
    }

    /**
     * The single branch this POS session sells from.
     *
     * The terminal must never sell in "aggregate" or against an implicitly
     * guessed branch: a sale and every stock operation it touches are tied to
     * exactly one branch. Resolution order, most to least explicit:
     *
     *   1. The open till session's branch (locked to the till for the shift);
     *   2. The branch this device is selling from — `pos_branch_id` session
     *      var, or the `branch_id` query param (the branch picker reloads with
     *      it, then persists it to the session);
     *   3. Exactly one accessible branch — single-branch deployments select it
     *      automatically, no prompt;
     *   4. null — the user has several branches and none chosen yet, so the
     *      terminal stays blocked until one is picked.
     *
     * It never consults CurrentContext's "All branches" / default-branch /
     * first-accessible-branch fallbacks.
     */
    public function resolveBranchId(?PosSession $activeSession): ?int
    {
        if ($activeSession) {
            return $activeSession->branch_id;
        }

        $context = app(CurrentContext::class);
        $branches = $context->branches();

        if ($branches->count() === 1) {
            return (int) $branches->first()->id;
        }

        // An explicit `?branch_id=` (the branch switcher) outranks the
        // remembered one — otherwise switching branches would silently no-op
        // once this device has a branch stored.
        $requested = (int) request()->integer('branch_id') ?: null;
        $requested ??= session()->get(self::BRANCH_SESSION_KEY);

        if ($requested !== null && $context->canAccessBranch((int) $requested)) {
            return (int) $requested;
        }

        // No active session, several branches, none chosen → caller decides.
        return null;
    }

    /**
     * Pins the shared branch context to the branch this POS session sells from.
     *
     * BranchScope (applied by BranchScoped models) and the BranchScoped
     * create-default both resolve their own branch from CurrentContext and
     * would otherwise fall back to the first accessible branch for a
     * multi-branch user — silently undoing the branch gate. Binding here makes
     * every scoped read (held sales, batches, sessions) and every scoped write
     * inside a POS action line up with the resolved selling branch.
     *
     * Lives outside the request lifecycle so it is scoped to the POS session
     * only: tenant-level middleware resolving context for the rest of the app
     * is unaffected, and the binding is not persisted between requests. Actions
     * that already carry their own explicit branch_id re-derive it and bypass.
     */
    protected function bindContextToBranch(?int $branchId): void
    {
        if ($branchId !== null) {
            app(CurrentContext::class)->setBranch($branchId);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // View data (the Inertia::render payload, rebuilt for Blade/Alpine)
    // ─────────────────────────────────────────────────────────────────────

    /**
     * @return array<string, mixed>
     */
    protected function getViewData(): array
    {
        $tillId = session('pos_till_id');
        $activeSession = null;

        if ($tillId) {
            // Unscoped for the same reason as resolveActiveSession(): the
            // session determines the branch, so it cannot be filtered by it.
            $activeSession = PosSession::withoutGlobalScope(BranchScope::class)
                ->with(['till.branch', 'status', 'openedBy'])
                ->where('till_id', $tillId)
                ->whereHas('status', fn ($q) => $q->where('code', Status::SESSION_OPEN))
                ->latest()
                ->first();
        }

        // One resolution path for the whole page — the same method every action
        // uses, so what the cashier sees and what the server sells from can
        // never disagree. A null here means "several branches, none chosen":
        // the view renders the blocking branch picker and nothing else works.
        $context = app(CurrentContext::class);
        $branchId = $this->resolveBranchId($activeSession);
        $this->bindContextToBranch($branchId);

        // Persist an explicit pick (query param on first load) so a reload,
        // a new shift, or an F5 mid-sale doesn't re-prompt on this device.
        if ($branchId !== null && ! $activeSession && session()->get(self::BRANCH_SESSION_KEY) !== $branchId) {
            session([self::BRANCH_SESSION_KEY => $branchId]);
        }

        $tills = $branchId ? $context->tillsForBranch($branchId) : collect();

        $products = $branchId ? $this->productsPayload($branchId) : ['normal' => [], 'packaging' => []];

        // Once a session is open, the till is locked in for the duration of the
        // shift — access is only (re)validated at selection time, not on every
        // subsequent page load, so a mid-shift access change never yanks the
        // till out from under a cashier with unfinished sales.
        // A till belongs to a branch, so there is nothing to select — and no
        // stale `pos_till_id` worth honouring — until a branch is chosen.
        $selectedTillId = $activeSession?->till_id;
        if (! $selectedTillId && $branchId) {
            if ($tillId && $tills->pluck('id')->contains($tillId)) {
                $selectedTillId = $tillId;
            } elseif ($tillId) {
                // Stale session var or access was revoked since — re-prompt.
                session()->forget('pos_till_id');
            }

            // Exactly one accessible till: select it automatically, matching
            // the branch selector's existing single-option behaviour.
            if (! $selectedTillId && $tills->count() === 1) {
                $selectedTillId = $tills->first()->id;
                session(['pos_till_id' => $selectedTillId]);
            }
        }

        $previousClosingBalance = $selectedTillId
            ? $this->previousClosingBalance((int) $selectedTillId)
            : null;

        $openingBalance = 0.0;

        return [
            'activeSession' => $activeSession ? [
                'id' => $activeSession->id,
                'till_name' => $activeSession->till?->name,
                'branch_id' => $activeSession->branch_id,
                'branch_name' => $activeSession->till?->branch?->name,
                'opening_balance' => (float) $activeSession->opening_balance,
                'opened_at' => $activeSession->opened_at?->toIso8601String(),
                'opened_by' => $activeSession->openedBy?->name,
                'expected_cash' => app(CashMovementService::class)->expectedBalance($activeSession),
            ] : null,
            'branches' => $context->branches()->map(fn ($b) => ['id' => $b->id, 'name' => $b->name])->values()->all(),
            'selectedBranchId' => $branchId,
            'branchRequired' => $branchId === null,
            'tills' => $tills->map(fn ($t) => ['id' => $t->id, 'name' => $t->name])->values()->all(),
            'selectedTillId' => $selectedTillId,
            'openingBalance' => $openingBalance,
            'previousClosingBalance' => $previousClosingBalance,
            'products' => $products['normal'],
            // Packaging/add-ons (bags, gift boxes, wrapping) are ordinary
            // products under one system category, split out here purely so the
            // terminal can give them their own fixed strip instead of burying
            // them in the scrolling grid. Empty array => the strip renders not
            // at all.
            'packagingProducts' => $products['packaging'],
            'bundles' => $branchId ? $this->bundlesPayload($branchId) : [],
            'customers' => Customer::where('is_active', true)->orderBy('business_name')->get(['id', 'business_name', 'phone'])->all(),
            'canCheckout' => Auth::user()->hasPermissionTo('pos.checkout'),
            // Each way a checkout can be discounted has its own permission,
            // enforced server-side at checkout (completeCheckout/holdSale) —
            // these flags only decide which controls the terminal renders.
            'canCartDiscount' => Auth::user()->hasPermissionTo('pos.cart_discount'),
            'canLineDiscount' => Auth::user()->hasPermissionTo('pos.line_discount'),
            'canVoucher' => Auth::user()->hasPermissionTo('pos.voucher'),
            'canRedeemLoyalty' => Auth::user()->hasPermissionTo('pos.loyalty_redeem'),
            'canWithdrawCash' => Auth::user()->hasPermissionTo('pos_cash_movements.create'),
            // The batch-override switch is only a UI affordance here — InvoiceService
            // re-checks the permission authoritatively on checkout (explicit batch
            // ids are ignored for cashiers without it).
            'canOverrideBatch' => Auth::user()->hasPermissionTo('inventory_batches.override'),
            'allowAmend' => app(PosSettings::class)->allowAmend(),
            'cashMovementsUrl' => CashMovementResource::getUrl(),
            'tillsUrl' => TillResource::getUrl(),
            'cashMovementReasons' => CashMovementReason::where('is_active', true)->orderBy('sort_order')
                ->get(['id', 'name', 'requires_note'])->values()->all(),
            // Every tender a tenant currently accepts — the toggleable
            // built-ins (cash/card/bank_transfer/cheque) plus any active
            // custom methods. CREDIT/OTHER are internal bookkeeping codes and
            // never appear here (see PaymentMethod::SYSTEM).
            'paymentMethods' => PaymentMethod::where('is_active', true)
                ->whereNotIn('code', PaymentMethod::SYSTEM)
                ->orderBy('sort_order')
                ->get(['code', 'name', 'requires_reference', 'requires_cheque_fields', 'fields'])
                ->map(fn (PaymentMethod $m) => [
                    'code' => $m->code,
                    'name' => $m->name,
                    'requires_reference' => $m->requires_reference,
                    'requires_cheque_fields' => $m->requires_cheque_fields,
                    'is_custom' => $m->isCustom(),
                    'fields' => $m->fields,
                ])
                ->values()->all(),
            'heldSales' => $branchId ? $this->heldSalesPayload($branchId) : [],
            'walkinOnly' => app(PosSettings::class)->walkinOnly(),
            // Master-control feature switches: when off, the terminal must not
            // render the voucher / loyalty-redemption UI at all (the checkout
            // payload is also rejected server-side in completeCheckout()).
            'loyaltyEnabled' => app(PosSettings::class)->loyaltyEnabled(),
            'vouchersEnabled' => app(PosSettings::class)->vouchersEnabled(),
            // Live cart-value rules ("Spend & Save"), highest threshold first —
            // the client previews the automatic rule discount from this same
            // server-side lookup, and InvoiceService::create() re-evaluates it
            // authoritatively at checkout, so the terminal can show the gross
            // discount but never smuggle one.
            'cartRules' => app(CartDiscountService::class)->activeRules()->map(fn (CartDiscount $rule): array => [
                'id' => $rule->id,
                'name' => $rule->name,
                'min_cart_value' => (float) $rule->min_cart_value,
                'discount_type' => $rule->discountType?->code,
                'discount_value' => (float) $rule->discount_value,
                'max_discount_amount' => $rule->max_discount_amount !== null ? (float) $rule->max_discount_amount : null,
            ])->values()->all(),
            // Same rule: while off, no VAT row anywhere in the cart/receipt
            // preview, and completeCheckout()/holdSale() zero it regardless of
            // what the payload claims.
            'vatEnabled' => app(VatSettings::class)->enabled(),
            // The tenant's configured business name (Master Control → Invoice →
            // Business name, falling back to the branding app name and then the
            // platform default) — the terminal brands itself with this instead
            // of the system name.
            'businessName' => app(PosSettings::class)->businessName(),
            // The namespace shop-printed labels live in. The terminal uses it
            // to tell "this is one of our own labels, ask the server" from
            // "this is a product SKU, match it client-side" — without it every
            // unrecognised scan would cost a round-trip.
            'labelPrefix' => BarcodeLabel::CODE_PREFIX,
        ];
    }

    /**
     * The sellable catalog in the one shape the terminal's cards use, split
     * into the ordinary grid and the packaging/add-ons strip. One query, one
     * mapping — a packaging product is a product, and the only thing that
     * makes it different here is which array it lands in.
     *
     * @return array{normal: array<int, array<string, mixed>>, packaging: array<int, array<string, mixed>>}
     */
    protected function productsPayload(?int $branchId): array
    {
        $packagingCategoryId = MainCategory::packaging()?->id;
        $vatEnabled = app(VatSettings::class)->enabled();

        $products = Product::where('is_active', true)
            ->with('mainCategory')
            ->with('subCategory')
            ->with('baseUnit:id,code,name,is_fractional')
            ->withAvailableSerialCount($branchId)
            ->withStockFor($branchId)
            ->orderBy('name')
            ->get();

        // The lot is the price owner: the card shows the sellable-lot price
        // range at this branch (one grouped query — never one query per
        // product). `price_min === price_max` reads as a single price.
        $priceRanges = $branchId
            ? app(BatchService::class)->priceRangesFor($products->pluck('id')->all(), $branchId)
            : [];

        // The front lot per product — the FEFO/FIFO-front sellable lot a
        // barcode scan draws from without opening the lot picker (manual
        // clicks still go through the modal). Resolved here exactly as the
        // server resolves it (BatchService::frontLotId), so a scanned sale
        // never trips the checkout's "not the next lot to sell" override
        // check.
        $frontLots = $branchId ? $this->frontLotsPayload($products, $branchId) : [];

        // The deal that applies to each product RIGHT NOW, keyed by product id
        // (lowest-id deal wins; same batched lookup DealService provides to
        // InvoiceService::create(), so the badge the cashier sees is exactly
        // the discount that will actually be charged).
        $dealsByProduct = app(DealService::class)->activeDealsForProducts($products->pluck('id')->all());

        $mapped = $products
            ->map(fn ($p) => [
                'id' => $p->id,
                'name' => $p->name,
                'sku' => $p->sku,
                // The manufacturer's own barcode, when one is on file — a scan
                // is matched against this first, then falls back to sku. See
                // scanAdd() in the view. Absent (not empty string) when unset,
                // so the client's === match never confuses "no barcode" with
                // an empty scan.
                'barcode' => $p->barcode,
                'price_min' => $priceRanges[$p->id]['price_min'] ?? null,
                'price_max' => $priceRanges[$p->id]['price_max'] ?? null,
                'lot_count' => $priceRanges[$p->id]['lot_count'] ?? 0,
                'is_batch_tracked' => (bool) $p->is_batch_tracked,
                // While VAT is off, the browser never even sees a rate — a
                // product that had one set before the switch was flipped off
                // must not leak into the cart display.
                'vat_rate' => $vatEnabled ? (float) $p->vat_rate : 0.0,
                // Resolved, not raw: the column holds a storage path, and on a
                // cloud source the file is on the bucket host, not this origin.
                'image_url' => $p->image_display_url,
                'main_category_id' => $p->main_category_id,
                'main_category' => $p->mainCategory?->name,
                'sub_category' => $p->subCategory?->name,
                'quantity' => (float) ($p->stock ?? 0),
                'base_unit' => $p->baseUnit ? ['code' => $p->baseUnit->code, 'name' => $p->baseUnit->name, 'is_fractional' => $p->baseUnit->is_fractional] : null,
                // The next lot to sell from, for the barcode path (see
                // frontLotsPayload) — null when the product has no sellable
                // lot (most likely no price on file).
                'front_lot' => $frontLots[$p->id] ?? null,
                // Data-driven: whether serials are actually on file (batched
                // via withCount in the query — never per-product).
                'has_serials' => ($p->available_serials_count ?? 0) > 0,
                'deal' => isset($dealsByProduct[$p->id]) ? [
                    'id' => $dealsByProduct[$p->id]->id,
                    'name' => $dealsByProduct[$p->id]->name,
                    'discount_type' => $dealsByProduct[$p->id]->discountType?->code,
                    'discount_value' => (float) $dealsByProduct[$p->id]->discount_value,
                ] : null,
            ]);

        $isPackaging = fn (array $p): bool => $packagingCategoryId !== null
            && $p['main_category_id'] === $packagingCategoryId;

        return [
            'normal' => $mapped->reject($isPackaging)
                ->sortByDesc(fn ($p) => $p['quantity'] > 0 ? 1 : 0)
                ->values()
                ->all(),
            // Grouped by subcategory in the strip, so order by that first and
            // keep the in-group ordering alphabetical rather than by stock.
            'packaging' => $mapped->filter($isPackaging)
                ->sortBy(fn ($p) => [$p['sub_category'] ?? '', $p['name']])
                ->values()
                ->all(),
        ];
    }

    /**
     * The front (next-to-sell) sellable lot of each product at the branch —
     * FEFO for expiry-tracked products (soonest expiry first), FIFO for the
     * rest (oldest receipt first). This is the lot a barcode scan draws from
     * without opening the lot picker; manual clicks still pick their own
     * consignment in the modal.
     *
     * One query for every product's sellable lots; the per-product ordering
     * happens in PHP because each product orders by its own expiry flag. The
     * shape matches a `productLots()` lot, so the client adds the line with
     * the same code path.
     *
     * @return array<int, array<string, mixed>|null> product_id => lot
     */
    protected function frontLotsPayload(Collection $products, int $branchId): array
    {
        $lotsByProduct = StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->whereIn('product_id', $products->pluck('id')->all())
            ->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'])
            ->groupBy('product_id');

        return $products
            ->mapWithKeys(function (Product $product) use ($lotsByProduct): array {
                $lots = $lotsByProduct->get($product->id) ?? collect();

                $front = $product->is_expiry_tracked
                    ? $lots->sortBy([
                        fn ($lot) => $lot->expiry_date === null ? 1 : 0,
                        fn ($lot) => $lot->expiry_date?->getTimestamp() ?? PHP_INT_MAX,
                        fn ($lot) => $lot->created_at->getTimestamp(),
                    ])->first()
                    : $lots->sortBy(fn ($lot) => $lot->created_at->getTimestamp())->first();

                return [$product->id => $front ? [
                    'id' => $front->id,
                    'internal_batch_number' => $front->internal_batch_number,
                    'sale_price' => (float) $front->sale_price,
                    'expiry_date' => $front->expiry_date?->toDateString(),
                    'available_quantity' => (float) $front->available_quantity,
                ] : null];
            })
            ->all();
    }

    /**
     * The sellable bundle catalog. A bundle is sellable only when it is
     * switched on AND inside its date window — starts_at (inclusive, if set)
     * through ends_at (inclusive, if set). An inactive, expired, or
     * not-yet-started bundle is neither visible here nor (re-checked
     * authoritatively in InvoiceService::sellBundleLine) purchasable.
     *
     * @return array<int, array<string, mixed>>
     */
    protected function bundlesPayload(?int $branchId): array
    {
        $today = now()->toDateString();

        return ProductBundle::where('is_active', true)
            ->availableAtBranch($branchId)
            ->where(function ($q) use ($today) {
                $q->whereNull('starts_at')->orWhereDate('starts_at', '<=', $today);
            })
            ->where(function ($q) use ($today) {
                $q->whereNull('ends_at')->orWhereDate('ends_at', '>=', $today);
            })
            ->with('items.product:id,name')
            ->get()
            ->map(fn ($b) => [
                'id' => $b->id,
                'name' => $b->name,
                'sku' => $b->sku,
                'sale_price' => (float) $b->sale_price,
                'image_url' => $b->image_display_url,
                'components' => $b->items->map(fn ($i) => ['name' => $i->product?->name, 'quantity' => (float) $i->quantity])->values()->all(),
            ])
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    protected function heldSalesPayload(?int $branchId): array
    {
        if (! $branchId) {
            return [];
        }

        $heldSales = HeldSale::where('branch_id', $branchId)
            ->with(['items', 'customer:id,business_name', 'creator:id,name'])
            ->orderByDesc('held_at')
            ->get();

        if ($heldSales->isEmpty()) {
            return [];
        }

        // Batch all deal/cart-rule lookups once for the whole list instead of
        // per-sale (previously N held sales = N deal queries + N cart-rule queries).
        $allProductIds = $heldSales->flatMap(fn (HeldSale $sale) => $sale->items->pluck('product_id'))
            ->filter()
            ->unique()
            ->values()
            ->all();
        $dealsByProduct = app(DealService::class)->activeDealsForProducts($allProductIds);
        $vatEnabled = app(VatSettings::class)->enabled();
        $cartRules = app(CartDiscountService::class)->activeRules();

        return $heldSales
            ->map(fn (HeldSale $sale) => $this->heldSaleSummary($sale, $dealsByProduct, $vatEnabled, $cartRules))
            ->values()
            ->all();
    }

    /**
     * Same shape a held sale takes in heldSalesPayload()'s list — also used
     * by holdSale() to hand the freshly-created row straight back to the
     * client, so it can append to the held list locally instead of
     * re-fetching the whole page.
     *
     * @param array<int, \App\Models\Promotions\Deal> $dealsByProduct
     * @param \Illuminate\Support\Collection<int, \App\Models\Promotions\CartDiscount>|null $cartRules
     * @return array<string, mixed>
     */
    private function heldSaleSummary(HeldSale $sale, ?array $dealsByProduct = null, ?bool $vatEnabled = null, $cartRules = null): array
    {
        // One batched deal lookup per sale when called standalone (holdSale),
        // or reuse the pre-fetched map when called from heldSalesPayload().
        $dealsByProduct ??= app(DealService::class)->activeDealsForProducts(
            $sale->items->pluck('product_id')->filter()->unique()->values()->all(),
        );

        $netEstimate = $sale->items->sum(function ($item) use ($dealsByProduct) {
            $gross = (float) $item->quantity * (float) $item->unit_price;
            $value = (float) $item->discount_value;
            if ($item->discount_type && $value > 0 && $gross > 0) {
                $discount = $item->discount_type === 'percentage' ? $gross * ($value / 100) : $value * (float) $item->quantity;
                $gross -= min(max($discount, 0), $gross);
            }
            if ($item->product_id) {
                $deal = $dealsByProduct[(int) $item->product_id] ?? null;
                if ($deal) {
                    $gross -= app(DealService::class)->computeDiscount($deal, (float) $item->quantity * (float) $item->unit_price, (float) $item->quantity);
                }
            }

            return max($gross, 0);
        });

        // Same eligible base the engine evaluates rules against
        // (net subtotal + VAT), so a held sale's threshold reads correctly
        // even when VAT carries it over — display-only, like the rest of
        // this summary.
        $vatEnabled ??= app(VatSettings::class)->enabled();
        $vatEstimate = $vatEnabled
            ? $sale->items->sum(fn ($item) => (float) $item->quantity * (float) $item->unit_price * ((float) ($item->vat_rate ?? 0) / 100))
            : 0.0;
        if ($cartRules !== null) {
            $eligible = $netEstimate + $vatEstimate;
            $cartRule = null;
            foreach ($cartRules as $rule) {
                if ($eligible >= (float) $rule->min_cart_value) {
                    $cartRule = $rule;
                    break;
                }
            }
            $cartRuleDiscount = $cartRule ? app(CartDiscountService::class)->computeDiscount($cartRule, $eligible) : 0.0;
        } else {
            $cartRule = app(CartDiscountService::class)->bestRule($netEstimate + $vatEstimate);
            $cartRuleDiscount = $cartRule ? app(CartDiscountService::class)->computeDiscount($cartRule, $netEstimate + $vatEstimate) : 0.0;
        }

        return [
            'id' => $sale->id,
            'reference' => $sale->reference,
            'item_count' => (float) $sale->items->sum('quantity'),
            // Display-only estimate (gross minus each line's own discount and
            // any live deal on the line's product); the authoritative total is
            // recomputed by InvoiceService when the sale is actually resumed
            // and checked out.
            'total' => round(max(0, $netEstimate - $cartRuleDiscount), 2),
            'cart_rule_discount' => $cartRuleDiscount > 0 ? round($cartRuleDiscount, 2) : 0.0,
            'cart_rule_name' => $cartRule?->name,
            'customer_name' => $sale->customer?->business_name,
            'held_at' => $sale->held_at->toIso8601String(),
            'cashier_name' => $sale->creator?->name,
        ];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Customer-dependent lookups (deferred in the source behind Inertia
    // partial reloads; here they are explicit round-trips the client makes
    // only when a customer is actually selected).
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Every pure-read lookup on this page is `#[Renderless]`, and that is a
     * performance requirement, not a micro-optimisation.
     *
     * A Filament page renders through `BasePage::render()`, which calls
     * `getViewData()`. Livewire re-renders the component after *every*
     * `$wire.method()` call, so without this attribute a lookup that runs one
     * indexed query also rebuilds the entire terminal payload — the whole
     * product catalogue with its stock, price ranges, front lots and deals,
     * every bundle, every active customer, the held sales, the tender list,
     * the settings — then re-renders a ~3,500-line Blade view and ships the
     * resulting HTML back. That made clicking a product card feel like a page
     * load.
     *
     * And all of it was waste: the terminal's root carries `wire:ignore`, so
     * Livewire's morph never touches the Alpine subtree the cashier is looking
     * at. Nothing on this page is refreshed BY a re-render — the mutating
     * actions either return their new state to Alpine (completeCheckout, which
     * then ages the cart's stock client-side rather than paying for a reload)
     * or force a real navigation (chooseBranch, assignTill, startSession,
     * closeSession). So renderless is safe here for the same reason it is
     * valuable: the HTML was never read.
     *
     * Keep that in mind before dropping the attribute onto a *writing* action:
     * the reason to leave those alone is not the render, it is that they carry
     * side effects (uploads, `$this->dispatch`, Filament notifications) whose
     * plumbing is easier to reason about on the normal path.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function customerContext(?int $customerId): array
    {
        $loyaltyProgram = app(PosSettings::class)->loyaltyEnabled()
            ? app(LoyaltyService::class)->activeProgram()
            : null;

        return [
            // `amount` is what the terminal may still spend from each note, not
            // its face value: a note part-used on an earlier sale stays issued
            // and must be offered at what is LEFT on it. `face_amount` and
            // `applied_amount` ride along so the cashier can see the note's
            // history rather than just a number that shrank.
            'credit_notes' => $customerId
                ? CreditNote::where('customer_id', $customerId)
                    ->spendable()
                    ->orderBy('id')
                    ->get()
                    ->map(fn (CreditNote $cn): array => [
                        'id' => $cn->id,
                        'cn_number' => $cn->cn_number,
                        'amount' => $cn->remainingAmount(),
                        'face_amount' => (float) $cn->amount,
                        'applied_amount' => (float) $cn->applied_amount,
                    ])
                    ->all()
                : [],
            'credit_balance' => $customerId && ($customer = Customer::find($customerId))
                ? app(CustomerCreditService::class)->balance($customer)
                : 0.0,
            'loyalty_program' => $loyaltyProgram ? [
                'redemption_rate' => (float) $loyaltyProgram->redemption_rate,
                'minimum_points_to_redeem' => (float) $loyaltyProgram->minimum_points_to_redeem,
            ] : null,
            'loyalty_balance' => ($loyaltyProgram && $customerId)
                ? (float) (CustomerLoyaltyBalance::where('customer_id', $customerId)
                    ->where('program_id', $loyaltyProgram->id)
                    ->first()?->points_balance ?? 0)
                : 0.0,
        ];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Checkout lookups (ported from StockLookupController + VoucherController
    // + PosController::storePaymentAttachment — the read-only endpoints behind
    // the serial picker, voucher preview, and the custom payment
    // method file upload)
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Sellable lots of a product at a branch, grouped by the receipt that
     * created them, FEFO-first within each receipt. Drives the POS lot
     * picker: every sale is priced from the selected lot, so the operator
     * picks the consignment the customer's units come from.
     *
     * Opening-balance lots (backfilled by migration, or received on an
     * "opening stock" GRN) group under a pseudo-header.
     *
     * @return array<int, array{
     *   grn_number: string|null, confirmed_at: string|null, supplier: string|null,
     *   reference: string|null, note: string|null, lots: array<int, array<string, mixed>>
     * }>
     */
    #[Renderless]
    public function productLots(int $productId, ?int $branchId = null): array
    {
        $branchId ??= $this->resolveBranchId($this->resolveActiveSession());
        // A list endpoint: fail loudly rather than returning "no lots",
        // which would read as "this product is out of stock everywhere".
        abort_unless($branchId, 409, 'Select a branch to sell from first.');
        $this->bindContextToBranch($branchId);

        $product = Product::find($productId);
        if ($product && ! $product->is_batch_tracked) {
            // Non-batch products are a single unified pool — no lot picker,
            // price comes from the active unified lot / branch stock directly.
            return [];
        }

        $batches = app(BatchService::class)->sellableFor($productId, $branchId);

        $grouped = [];
        $fefoFrontMarked = false;
        foreach ($batches as $batch) {
            $grn = $batch->grn;
            $key = $grn?->id ? 'grn-'.$grn->id : 'opening';

            if (! isset($grouped[$key])) {
                $grouped[$key] = [
                    'grn_number' => $grn?->grn_number,
                    'confirmed_at' => $grn?->confirmed_at?->toIso8601String(),
                    'supplier' => $grn?->supplier?->name,
                    'reference' => $grn?->reference,
                    'note' => $grn?->note,
                    'lots' => [],
                ];
            }

            // sellableFor() is sell-ordered (FEFO for expiry-tracked
            // products, FIFO otherwise), so the very first lot is the
            // "next to sell" — the modal's default; everything else needs
            // the override permission, enforced on the server too.
            $isFefoFront = ! $fefoFrontMarked;
            $fefoFrontMarked = true;

            $grouped[$key]['lots'][] = [
                'id' => $batch->id,
                'internal_batch_number' => $batch->internal_batch_number,
                'sale_price' => (float) $batch->sale_price,
                'batch_cost' => $batch->batch_cost !== null ? (float) $batch->batch_cost : null,
                'expiry_date' => $batch->expiry_date?->toDateString(),
                'manufacture_date' => $batch->manufacture_date?->toDateString(),
                'available_quantity' => (float) $batch->available_quantity,
                'status' => $batch->status?->name,
                'days_to_expiry' => $batch->expiry_date
                    ? (int) ceil(now()->startOfDay()->diffInDays($batch->expiry_date, false))
                    : null,
                'is_fefo_front' => $isFefoFront,
            ];
        }

        // Opening-balance lots (no receipt) float to the top as their own
        // group; receipt groups come in receipt order.
        uksort($grouped, fn ($a, $b) => $a === 'opening' ? -1 : ($b === 'opening' ? 1 : strcmp($a, $b)));

        return array_values($grouped);
    }

    /**
     * Available serial numbers for a product at a branch, optionally scoped
     * to one lot — a serial must come from the same consignment the cart
     * line is priced from, so the picker only offers that lot's serials.
     *
     * @return array<int, array<string, mixed>>
     */
    #[Renderless]
    public function availableSerials(int $productId, ?int $branchId = null, ?int $batchId = null): array
    {
        $branchId ??= $this->resolveBranchId($this->resolveActiveSession());
        // A list endpoint: fail loudly rather than returning "no serials",
        // which would read as "this product has no stock anywhere".
        abort_unless($branchId, 409, 'Select a branch to sell from first.');
        $this->bindContextToBranch($branchId);

        return ProductSerial::query()
            ->where('product_id', $productId)
            ->available()
            ->inBranch($branchId)
            ->when($batchId, fn ($q) => $q->where('stock_batch_id', $batchId))
            ->orderBy('serial_number')
            ->get(['id', 'serial_number'])
            ->toArray();
    }

    /**
     * Turns a scanned shop-printed label into a finished cart line.
     *
     * This is the whole point of the label module: a manufacturer's barcode
     * identifies a product and leaves the cashier picking a lot, a serial and a
     * quantity by hand, so a scan is the *start* of the interaction. A label
     * minted in BarcodeLabelResource already carries all three, so this returns
     * everything the terminal needs and the scan IS the interaction.
     *
     * The terminal only calls this for codes in the label namespace
     * (BarcodeLabel::CODE_PREFIX), so an ordinary SKU scan stays a
     * zero-round-trip client-side match — see scanAdd() in the view.
     *
     * Renderless for the same reason every other lookup here is: the terminal's
     * root carries wire:ignore, so re-rendering the whole catalog behind a
     * one-row lookup is pure waste at the busiest moment there is.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function resolveScan(string $code, ?int $branchId = null): array
    {
        abort_unless(Auth::user()->hasPermissionTo('pos.access'), 403);

        $branchId ??= $this->resolveBranchId($this->resolveActiveSession());

        if (! $branchId) {
            return ['ok' => false, 'message' => 'Select a branch to sell from first.'];
        }

        $this->bindContextToBranch($branchId);

        return app(BarcodeLabelService::class)->resolve($code, (int) $branchId);
    }

    /**
     * Live voucher validation for the checkout sheet — mirrors
     * VoucherController::preview(). The discount shown is a preview only;
     * InvoiceService recomputes everything authoritatively on submit.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function previewVoucher(string $code, ?int $customerId = null, ?int $branchId = null, float $cartTotal = 0): array
    {
        abort_unless(Auth::user()->hasPermissionTo('pos.checkout'), 403);
        abort_unless(Auth::user()->hasPermissionTo('pos.voucher'), 403);

        if (! app(PosSettings::class)->vouchersEnabled()) {
            return ['ok' => false, 'message' => 'Vouchers are disabled for this workspace.'];
        }

        $branchId ??= $this->resolveBranchId($this->resolveActiveSession());
        if (! $branchId) {
            return ['ok' => false, 'message' => 'Select a branch to sell from first.'];
        }

        try {
            $voucher = app(VoucherService::class)->validate(
                $code,
                $customerId,
                (int) $branchId,
                $cartTotal,
            );

            return [
                'ok' => true,
                'name' => $voucher->name,
                'discount' => app(VoucherService::class)->computeDiscount($voucher, $cartTotal),
            ];
        } catch (RuntimeException $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Stores a file for a custom payment method's "File Attachment" field
     * mid-checkout, before the sale is completed — the returned path is
     * submitted back as that field's value on completeCheckout.
     *
     * @return array<string, mixed>
     */
    public function uploadPaymentAttachment(): array
    {
        abort_unless(Auth::user()->hasPermissionTo('pos.checkout'), 403);

        $validator = Validator::make(
            ['file' => $this->paymentAttachment],
            ['file' => ['required', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120']],
        );

        if ($validator->fails()) {
            return ['ok' => false, 'errors' => $validator->errors()->all()];
        }

        try {
            $path = app(TenantStorage::class)->putFile('payment-attachments', $this->paymentAttachment);
        } catch (StorageQuotaExceededException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        return ['ok' => true, 'path' => $path, 'url' => app(TenantStorage::class)->displayUrl($path)];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Checkout
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Ported from PosController::checkoutComplete(). Same validation rules,
     * same permission enforcement, same credit resolution, same transaction
     * boundary, same post-commit overpayment handling.
     *
     * Marked Renderless so checkout never pays the cost of re-rendering the
     * full POS catalog (products, stock, deals, bundles, held sales) — the
     * terminal ages stock client-side and the server returns only the receipt
     * payload. This is the single largest latency win for "Create invoice".
     *
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    #[Renderless]
    public function completeCheckout(array $payload): array
    {
        abort_unless(Auth::user()->hasPermissionTo('pos.checkout'), 403);

        // Precondition errors (no branch, no discount permission) must surface
        // before field validation, so the terminal shows the actionable
        // message rather than a pile of "batch_id required" noise when the
        // operator has never even picked a branch.
        $activeSession = $this->resolveActiveSession();
        $branchId = $this->resolveBranchId($activeSession);

        if (! $branchId) {
            return ['ok' => false, 'errors' => ['Select a branch to sell from first.']];
        }

        $this->bindContextToBranch($branchId);

        // Field-level enforcement (not just hidden UI controls): a user without
        // the matching `pos.*_discount` / `pos.voucher` / `pos.loyalty_redeem`
        // permission cannot smuggle any discount through the payload even
        // though the frontend never renders the control for them. Each
        // discount type carries its own permission, so a user with only the
        // line-discount grant can discount lines but never the whole cart.
        $discountErrors = $this->discountPermissionErrors($payload);
        if ($discountErrors !== []) {
            return ['ok' => false, 'errors' => $discountErrors];
        }

        // Every code currently accepted by this tenant — built-ins the
        // operator hasn't toggled off, plus any active custom methods.
        $activeCodes = PaymentMethod::where('is_active', true)
            ->whereNotIn('code', PaymentMethod::SYSTEM)
            ->pluck('code')
            ->all();

        $validator = Validator::make($payload, [
            'payments' => 'nullable|array',
            'payments.*.method' => ['required', Rule::in($activeCodes)],
            'payments.*.amount' => 'required|numeric|min:0.01|max:99999999.99',
            'payments.*.cheque_number' => 'nullable|string|max:50',
            'payments.*.cheque_bank' => 'nullable|string|max:100',
            'payments.*.cheque_branch' => 'nullable|string|max:100',
            'payments.*.cheque_date' => 'nullable|date',
            'payments.*.reference' => 'nullable|string|max:100',
            'payments.*.custom_fields' => 'nullable|array',
            'discount' => 'nullable|numeric|min:0|max:99999999.99',
            'customer_id' => 'nullable|integer|exists:customers,id',
            'items' => 'required|array|min:1',
            'items.*.product_id' => 'required_without:items.*.bundle_id|nullable|integer|exists:products,id',
            'items.*.bundle_id' => 'required_without:items.*.product_id|nullable|integer|exists:product_bundles,id',
            'items.*.name' => 'nullable|string',
            'items.*.quantity' => 'required|numeric|min:0.01|max:1000000',
            'items.*.price' => 'nullable|numeric|min:0|max:99999999.99',
            'items.*.vat_rate' => 'nullable|numeric|min:0|max:100',
            // The lot IS the price (InvoiceService reprices from it
            // authoritatively). A channel without a lot picker may omit
            // `batch_id`: the service then falls back to the FEFO-front
            // sellable lot, and hard-fails when none exists. Bundle lines
            // carry no lot of their own, hence nullable.
            'items.*.batch_id' => 'nullable|integer|exists:stock_batches,id',
            'items.*.serial_ids' => 'nullable|array',
            'items.*.serial_ids.*' => 'integer|exists:product_serials,id',
            'items.*.discount_type' => 'nullable|in:fixed,percentage',
            'items.*.discount_value' => 'nullable|numeric|min:0|max:99999999.99',
            'order_notes' => 'nullable|string|max:500',
            'credit_note_ids' => 'nullable|array',
            'credit_note_ids.*' => 'integer|exists:credit_notes,id',
            'voucher_codes' => 'nullable|array',
            'voucher_codes.*' => 'string|max:50',
            'redeem_points' => 'nullable|numeric|min:0',
            'overpay_action' => 'nullable|in:cash,credit_note',
            'overpay_amount' => 'nullable|numeric|min:0.01|max:99999999.99',
        ]);

        if ($validator->fails()) {
            return ['ok' => false, 'errors' => $validator->errors()->all()];
        }

        // Batch payment-method validation to avoid N queries (previous: 2 queries per split).
        if (! empty($payload['payments'])) {
            $methodCodes = collect($payload['payments'])->pluck('method')->filter()->unique()->values()->all();
            $methodsByCode = PaymentMethod::whereIn('code', $methodCodes)->where('is_active', true)->get()->keyBy('code');
            foreach ($payload['payments'] ?? [] as $payment) {
                $code = $payment['method'] ?? '';
                $method = $methodsByCode->get($code);
                // Mirror validateCustomFields but from pre-fetched map (avoids per-split query).
                if ($method && ! in_array($code, PaymentMethod::BUILT_IN, true) && $method->fields) {
                    foreach ($method->fields as $field) {
                        if (empty($field['required'])) continue;
                        $value = ($payment['custom_fields'] ?? [])[$field['key']] ?? null;
                        if ($value === null || trim((string) $value) === '') {
                            return ['ok' => false, 'errors' => ["\"{$field['label']}\" is required for {$method->name}."]];
                        }
                    }
                }
                // Mirror validateRequiredFields from pre-fetched map.
                if ($method) {
                    if ($method->requires_reference && trim((string) ($payment['reference'] ?? '')) === '') {
                        return ['ok' => false, 'errors' => ["A reference is required for {$method->name}."]];
                    }
                    if ($method->requires_cheque_fields) {
                        foreach (['cheque_number' => 'Cheque number', 'cheque_bank' => 'Bank', 'cheque_date' => 'Cheque date'] as $k => $label) {
                            if (trim((string) ($payment[$k] ?? '')) === '') {
                                return ['ok' => false, 'errors' => ["{$label} is required for {$method->name}."]];
                            }
                        }
                    }
                }
            }
        }

        // Master-control feature switches: the frontend never renders these
        // controls while a feature is off, but a crafted payload must still be
        // refused here — the same "hidden control, enforced server-side" rule
        // as the discount check above.
        if (! app(PosSettings::class)->vouchersEnabled() && collect($payload['voucher_codes'] ?? [])->filter(fn ($code) => trim((string) $code) !== '')->isNotEmpty()) {
            return ['ok' => false, 'errors' => ['Vouchers are disabled for this workspace.']];
        }

        if (! app(PosSettings::class)->loyaltyEnabled() && (float) ($payload['redeem_points'] ?? 0) > 0) {
            return ['ok' => false, 'errors' => ['Loyalty points are disabled for this workspace.']];
        }

        try {
            $customerId = ! empty($payload['customer_id']) ? (int) $payload['customer_id'] : null;
            $discountInput = (float) ($payload['discount'] ?? 0);
            // Hidden control, enforced server-side (same rule as vouchers/loyalty
            // above): InvoiceService::create() re-zeroes this regardless, but
            // building the lines with it already off keeps this method's own
            // numbers consistent with what actually gets charged.
            $vatEnabled = app(VatSettings::class)->enabled();

            // Batch product names to avoid N Product::find queries (one per line).
            $productIdsForNames = collect($payload['items'])
                ->filter(fn ($i) => empty($i['bundle_id']) && empty($i['name']) && ! empty($i['product_id']))
                ->pluck('product_id')
                ->unique()
                ->values()
                ->all();
            $productNames = $productIdsForNames
                ? Product::whereIn('id', $productIdsForNames)->pluck('name', 'id')->all()
                : [];

            $lines = array_map(function (array $item) use ($vatEnabled, $productNames): array {
                if (! empty($item['bundle_id'])) {
                    return [
                        'product_bundle_id' => (int) $item['bundle_id'],
                        'quantity' => (float) $item['quantity'],
                        'unit_price' => (float) $item['price'],
                        'vat_rate' => 0,
                        'vat_amount' => 0,
                        'line_total' => (float) $item['price'] * (float) $item['quantity'],
                    ];
                }

                $vatRate = $vatEnabled ? (float) ($item['vat_rate'] ?? 0) : 0.0;

                return [
                    'product_id' => (int) $item['product_id'],
                    'product_name' => $item['name'] ?? $productNames[(int) ($item['product_id'] ?? 0)] ?? 'Unknown',
                    'quantity' => (float) $item['quantity'],
                    'unit_price' => (float) $item['price'],
                    'vat_rate' => $vatRate,
                    'vat_amount' => (float) $item['price'] * (float) $item['quantity'] * ($vatRate / 100),
                    'line_total' => (float) $item['price'] * (float) $item['quantity'],
                    'batch_id' => isset($item['batch_id']) ? (int) $item['batch_id'] : null,
                    'serial_ids' => $item['serial_ids'] ?? null,
                    'discount_type' => $item['discount_type'] ?? null,
                    'discount_value' => (float) ($item['discount_value'] ?? 0),
                ];
            }, $payload['items']);

            $subtotal = array_sum(array_column($lines, 'line_total'));
            $vatTotal = array_sum(array_column($lines, 'vat_amount'));
            // Rough estimate only (InvoiceService::create() is the authoritative
            // source for the real totals) — just enough to keep the credit-note
            // cap from ballooning past what a heavily line-discounted sale
            // actually owes. Live deals are included, evaluated against the
            // SAME server-side lookup InvoiceService will use.
            $dealsByProduct = app(DealService::class)->activeDealsForProducts(array_column($lines, 'product_id'));
            $lineDiscountEstimate = array_sum(array_map(function (array $item) use ($dealsByProduct): float {
                $gross = (float) $item['price'] * (float) $item['quantity'];
                if (! empty($item['bundle_id'])) {
                    return 0.0;
                }
                $type = $item['discount_type'] ?? null;
                $value = (float) ($item['discount_value'] ?? 0);
                $estimate = 0.0;
                if ($type && $value > 0 && $gross > 0) {
                    $amount = $type === 'percentage' ? $gross * ($value / 100) : $value * (float) $item['quantity'];
                    $estimate += min(max($amount, 0), $gross);
                }
                $deal = $dealsByProduct[(int) $item['product_id']] ?? null;
                if ($deal) {
                    $estimate += app(DealService::class)->computeDiscount($deal, $gross, (float) $item['quantity']);
                }

                return $estimate;
            }, $payload['items']));

            // Same eligible base the engine evaluates cart-value rules against
            // (net subtotal + VAT, pre-invoice-discount), so the credit cap
            // keeps step with the automatic "Spend & Save" discount too.
            $cartRuleEstimate = 0.0;
            $cartRuleBase = $subtotal - $lineDiscountEstimate + $vatTotal;
            $cartRule = app(CartDiscountService::class)->bestRule($cartRuleBase);
            if ($cartRule) {
                $cartRuleEstimate = app(CartDiscountService::class)->computeDiscount($cartRule, $cartRuleBase);
            }
            $owedBeforeCredit = max(0, $subtotal - $discountInput - $lineDiscountEstimate - $cartRuleEstimate + $vatTotal);

            $creditNoteIds = array_map('intval', $payload['credit_note_ids'] ?? []);

            if (count($creditNoteIds) > 0 && app(PosSettings::class)->walkinOnly()) {
                return ['ok' => false, 'errors' => ['Credit notes are disabled in walk-in only mode.']];
            }

            $credit = $this->resolveCheckoutCredit(
                $customerId,
                $creditNoteIds,
                $owedBeforeCredit,
                app(CustomerCreditService::class),
            );

            // An "overpayment" credit note is only real when the sale itself is
            // fully covered by the tendered splits — otherwise a crafted payload
            // could pay Rs. 400 on a Rs. 4000 sale and mint a Rs. 3600 credit
            // note out of thin air. The frontend computes overpay as
            // tendered − payable, so it never hits this; the guard is the
            // server-side backstop.
            $overpayRequested = (float) ($payload['overpay_amount'] ?? 0);
            if ($customerId
                && ($payload['overpay_action'] ?? null) === 'credit_note'
                && $overpayRequested > 0.001
                && app(PosSettings::class)->walkinOnly()
            ) {
                return ['ok' => false, 'errors' => ['Credit notes are disabled in walk-in only mode.']];
            }

            if ($customerId
                && ($payload['overpay_action'] ?? null) === 'credit_note'
                && $overpayRequested > 0.001
                && array_sum(array_column(array_map(
                    fn (array $p): array => ['method' => $p['method'], 'amount' => (float) $p['amount']],
                    $payload['payments'] ?? [],
                ), 'amount')) + 0.001 < max(0, $owedBeforeCredit - $credit['applied'])
            ) {
                return ['ok' => false, 'errors' => ['The sale is not fully paid, so an overpayment credit note cannot be issued.']];
            }

            $splits = array_map(fn (array $p): array => [
                'method' => $p['method'],
                'amount' => (float) $p['amount'],
                'reference' => $p['reference'] ?? null,
                'cheque_number' => $p['cheque_number'] ?? null,
                'cheque_bank' => $p['cheque_bank'] ?? null,
                'cheque_branch_code' => $p['cheque_branch'] ?? null,
                'cheque_date' => $p['cheque_date'] ?? null,
                'custom_fields' => $p['custom_fields'] ?? null,
            ], $payload['payments'] ?? []);

            // Credit is a tender, so it joins the other splits rather than
            // being folded into the discount: the sale is still worth what it
            // rang up to, and the notes are how part of it was paid.
            // PaymentService draws each note down as it books them, inside the
            // same transaction as the invoice — so a losing request in a
            // concurrent double-submit (same note spent twice) can never leave
            // an invoice committed against credit that was never drawn.
            $splits = array_merge($splits, $credit['splits']);

            $amendedFromInvoiceId = session('pos_amended_from_invoice_id');

            $invoice = DB::transaction(fn (): Invoice => app(InvoiceService::class)->create(
                header: [
                    'channel' => 'till',
                    'branch_id' => $branchId,
                    'customer_id' => $customerId,
                    'till_id' => $activeSession?->till_id,
                    'pos_session_id' => $activeSession?->id,
                    'amended_from_invoice_id' => $amendedFromInvoiceId,
                    'discount' => $discountInput,
                    'voucher_codes' => $payload['voucher_codes'] ?? [],
                    'redeem_points' => (float) ($payload['redeem_points'] ?? 0),
                    'note' => $payload['order_notes'] ?? null,
                ],
                lines: $lines,
                splits: $splits,
                confirmShortfall: true,
            ));

            if ($customerId && ($payload['overpay_action'] ?? null) === 'credit_note' && (float) ($payload['overpay_amount'] ?? 0) > 0.001) {
                if (app(PosSettings::class)->walkinOnly()) {
                    return ['ok' => false, 'errors' => ['Credit notes are disabled in walk-in only mode.']];
                }

                app(CreditNoteService::class)->issueForCheckoutOverpayment($customerId, (float) $payload['overpay_amount'], $invoice);
            }

            // A cash overpayment means the customer handed over more than the
            // payable figure and the excess is returned from the drawer. The
            // settlement splits above were capped at the balance
            // (buildSplits() client-side, PaymentService server-side), so the
            // invoice would otherwise only ever carry the settled portion —
            // never what was actually tendered or the change handed back. A
            // credit-note overpayment is the opposite: nothing returns as
            // cash, the excess becomes store credit instead. Change is only
            // real on a settled sale, so the snapshot also requires a zero
            // balance — a crafted payload claiming an overpay on an unpaid
            // invoice never gets a tendered/change story printed.
            if ($overpayRequested > 0.001
                && ($payload['overpay_action'] ?? null) !== 'credit_note'
                && $invoice->isFullyPaid()
            ) {
                $tendered = array_sum(array_map(fn (array $s): float => (float) $s['amount'], $splits)) + $overpayRequested;
                $invoice->update([
                    'tendered_amount' => round($tendered, 2),
                    'change_amount' => round($overpayRequested, 2),
                ]);
            }

            session()->forget('pos_cart');
            session()->forget('pos_amended_from_invoice_id');

            // receiptDocument() enforces the same rule again server-side — this
            // only decides whether the POS receipt modal has anything to fetch
            // one second after the sale.
            $canPrintInvoice = InvoiceResource::canPrint($invoice);

            return [
                'ok' => true,
                'invoice_id' => $invoice->id,
                'invoice_number' => $invoice->invoice_number,
                'message' => 'Sale '.$invoice->invoice_number.' completed.',
                'receipt' => [
                    'invoice_id' => $invoice->id,
                    'invoice_number' => $invoice->invoice_number,
                    'can_print' => $canPrintInvoice,
                ],
            ];
        } catch (InsufficientStockException|RuntimeException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }
    }

    /**
     * Renders the receipt for the sale-complete modal, inline, on the given
     * stock. Used to embed via an iframe pointed at InvoicePrintController —
     * that page is a standalone document with its own toolbar and auto-print
     * timer, meant for its own tab. Rendering the exact same partial here
     * instead lets the modal show the receipt directly in its own DOM, where
     * the client can scope `window.print()` to just this element rather than
     * hijacking the whole embedded document.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function receiptDocument(int $invoiceId, string $paper = 'thermal80'): array
    {
        abort_unless(in_array($paper, InvoicePrintController::PAPERS, true), 404);

        // Same reasoning as lastSale(): bind to the till's branch before
        // touching the branch-scoped Invoice model, rather than trusting
        // whatever the app-wide top-bar branch selector happens to hold —
        // that can legitimately differ from the branch this sale was rung
        // up under for a multi-branch cashier.
        $this->bindContextToBranch($this->resolveBranchId($this->resolveActiveSession()));

        $invoice = Invoice::with(InvoicePrintController::RELATIONS)->findOrFail($invoiceId);

        abort_unless(InvoiceResource::canPrint($invoice), 403);

        $context = app(CurrentContext::class);
        $business = InvoiceBusiness::forTenant($context->tenant());
        $doc = InvoiceDocument::fromInvoice($invoice, $business, $context->currencySymbol());

        $layout = $paper === 'thermal80' ? 'invoices.print.thermal80' : 'invoices.print.a4';

        return [
            'ok' => true,
            'paper' => $paper,
            'html' => view($layout, ['doc' => $doc])->render(),
        ];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Held sales
    // ─────────────────────────────────────────────────────────────────────

    /**
     * The per-type discount permission errors in a checkout or hold payload.
     *
     * Every way a payload can carry a discount is gated by its own
     * permission, so a user with only the line-discount grant can discount
     * lines but never the whole cart, apply a voucher, or redeem loyalty
     * points. Automatic discounts (deals, cart-value rules, bundle pricing)
     * are not checked here — they come from the configured promotions
     * catalog, not from anything in the payload.
     *
     * Shared by completeCheckout() and holdSale() so a discount a user cannot
     * apply at checkout can also never be parked on a held sale.
     *
     * @param  array<string, mixed>  $payload
     * @return array<int, string>
     */
    protected function discountPermissionErrors(array $payload): array
    {
        $user = Auth::user();
        $errors = [];

        $hasCartDiscount = (float) ($payload['discount'] ?? 0) > 0
            || (float) ($payload['discount_value'] ?? 0) > 0;

        if ($hasCartDiscount && ! $user->hasPermissionTo('pos.cart_discount')) {
            $errors[] = 'You do not have permission to apply a cart (invoice) discount.';
        }

        $hasLineDiscount = collect($payload['items'] ?? [])
            ->contains(fn ($item) => (float) ($item['discount_value'] ?? 0) > 0);

        if ($hasLineDiscount && ! $user->hasPermissionTo('pos.line_discount')) {
            $errors[] = 'You do not have permission to apply a line-item discount.';
        }

        $hasVoucher = collect($payload['voucher_codes'] ?? [])
            ->filter(fn ($code) => trim((string) $code) !== '')
            ->isNotEmpty();

        if ($hasVoucher && ! $user->hasPermissionTo('pos.voucher')) {
            $errors[] = 'You do not have permission to apply a voucher.';
        }

        if ((float) ($payload['redeem_points'] ?? 0) > 0 && ! $user->hasPermissionTo('pos.loyalty_redeem')) {
            $errors[] = 'You do not have permission to redeem loyalty points.';
        }

        return $errors;
    }

    /**
     * Suspend the current cart so the cashier can serve another customer.
     * Never touches stock or creates an invoice.
     *
     * Renderless: the client appends the returned held_sale locally; no
     * full catalog re-render is needed.
     *
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    #[Renderless]
    public function holdSale(array $payload): array
    {
        // Same precedence as completeCheckout(): precondition errors
        // (branch, discount permissions) surface before field validation.
        $activeSession = $this->resolveActiveSession();
        $branchId = $this->resolveBranchId($activeSession);

        if (! $branchId) {
            return ['ok' => false, 'errors' => ['Select a branch to sell from first.']];
        }

        $this->bindContextToBranch($branchId);

        // A held sale saves whatever discount the cart carries, so the same
        // per-type permissions completeCheckout() enforces are enforced here —
        // otherwise a user could hold a discounted cart and resume it past the
        // gate later.
        $discountErrors = $this->discountPermissionErrors($payload);
        if ($discountErrors !== []) {
            return ['ok' => false, 'errors' => $discountErrors];
        }

        $validator = Validator::make($payload, [
            'items' => 'required|array|min:1',
            'items.*.product_id' => 'required_without:items.*.bundle_id|nullable|integer|exists:products,id',
            'items.*.bundle_id' => 'required_without:items.*.product_id|nullable|integer|exists:product_bundles,id',
            'items.*.name' => 'required|string',
            'items.*.quantity' => 'required|numeric|min:0.01|max:1000000',
            'items.*.price' => 'nullable|numeric|min:0|max:99999999.99',
            'items.*.vat_rate' => 'nullable|numeric|min:0|max:100',
            // The lot IS the price (InvoiceService reprices from it
            // authoritatively when the held sale is checked out). A channel
            // without a lot picker may omit `batch_id`; the service then
            // falls back to the FEFO-front sellable lot. Bundle lines carry
            // no lot of their own, hence nullable.
            'items.*.batch_id' => 'nullable|integer|exists:stock_batches,id',
            'items.*.serial_ids' => 'nullable|array',
            'items.*.discount_type' => 'nullable|in:fixed,percentage',
            'items.*.discount_value' => 'nullable|numeric|min:0|max:99999999.99',
            'discount_type' => 'nullable|in:fixed,percentage',
            'discount_value' => 'nullable|numeric|min:0|max:99999999.99',
            'customer_id' => 'nullable|integer|exists:customers,id',
        ]);

        if ($validator->fails()) {
            return ['ok' => false, 'errors' => $validator->errors()->all()];
        }

        $vatEnabled = app(VatSettings::class)->enabled();

        $lines = array_map(fn (array $item): array => [
            'product_id' => ! empty($item['product_id']) ? (int) $item['product_id'] : null,
            'product_bundle_id' => ! empty($item['bundle_id']) ? (int) $item['bundle_id'] : null,
            'product_name' => $item['name'],
            'quantity' => (float) $item['quantity'],
            'unit_price' => (float) $item['price'],
            'vat_rate' => $vatEnabled ? (float) ($item['vat_rate'] ?? 0) : 0.0,
            'discount_type' => $item['discount_type'] ?? null,
            'discount_value' => (float) ($item['discount_value'] ?? 0),
            'stock_batch_id' => isset($item['batch_id']) ? (int) $item['batch_id'] : null,
            'serial_ids' => $item['serial_ids'] ?? null,
        ], $payload['items']);

        $heldSale = app(HeldSaleService::class)->hold([
            'branch_id' => $branchId,
            'till_id' => $activeSession?->till_id,
            'pos_session_id' => $activeSession?->id,
            'customer_id' => ! empty($payload['customer_id']) ? (int) $payload['customer_id'] : null,
            'discount_type' => $payload['discount_type'] ?? null,
            'discount_value' => (float) ($payload['discount_value'] ?? 0),
        ], $lines);

        $heldSale->loadMissing(['customer:id,business_name', 'creator:id,name']);

        return [
            'ok' => true,
            'message' => "Sale held as {$heldSale->reference}.",
            'held_sale' => $this->heldSaleSummary($heldSale),
        ];
    }

    /**
     * Restore a held sale's cart contents — the client hydrates its Alpine
     * cart directly from this payload, with no page navigation.
     *
     * Renderless: the held sale is deleted server-side and the cart payload
     * is returned; the client removes the held row locally without a full
     * page reload.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function resumeSale(int $heldSaleId): array
    {
        $heldSale = HeldSale::findOrFail($heldSaleId);

        abort_unless(
            app(CurrentContext::class)->canAccessBranch((int) $heldSale->branch_id),
            403,
            'You do not have access to this held sale\'s branch.',
        );

        return ['ok' => true, 'payload' => app(HeldSaleService::class)->resume($heldSale)];
    }

    /**
     * @return array<string, mixed>
     */
    #[Renderless]
    public function discardHeldSale(int $heldSaleId): array
    {
        $heldSale = HeldSale::findOrFail($heldSaleId);

        abort_unless(
            app(CurrentContext::class)->canAccessBranch((int) $heldSale->branch_id),
            403,
            'You do not have access to this held sale\'s branch.',
        );

        app(HeldSaleService::class)->discard($heldSale);

        return ['ok' => true, 'message' => 'Held sale discarded.'];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Till & session
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Pins the POS to the chosen branch — persisted in the session so a reload
     * or a new shift on this device does not re-prompt. The selection only
     * sticks while no session is open; once a till session starts, the branch
     * is locked to the till's branch and this is ignored.
     *
     * @return array<string, mixed>
     */
    public function selectBranch(int $branchId): array
    {
        $context = app(CurrentContext::class);

        if (! $context->canAccessBranch($branchId)) {
            return ['ok' => false, 'errors' => ['You do not have access to that branch.']];
        }

        session([self::BRANCH_SESSION_KEY => $branchId]);

        return ['ok' => true];
    }

    /**
     * @return array<string, mixed>
     */
    public function assignTill(int $tillId): array
    {
        // Unscoped for the same reason as resolveActiveSession(): assigning a
        // till is what establishes the branch, so the lookup itself can't be
        // filtered by it. Access is verified explicitly below.
        $till = Till::withoutGlobalScope(BranchScope::class)->findOrFail($tillId);
        $context = app(CurrentContext::class);

        abort_unless($context->canAccessBranch((int) $till->branch_id), 403, 'You do not have access to this till\'s branch.');

        // Never trust the frontend selection — re-validate against the
        // user's actual till-access grants server-side, not just the branch.
        // Null (full admin) or empty (no explicit restriction yet) means
        // unrestricted — every branch-accessible till may be opened, so
        // master-created tills appear immediately without manual assignment.
        $accessibleTillIds = $context->accessibleTillIds();
        abort_if($accessibleTillIds !== null && ! $accessibleTillIds->isEmpty() && ! $accessibleTillIds->contains($till->id), 403, 'You do not have access to this till.');

        // A till is the more specific choice, so its branch wins — this keeps
        // the remembered branch and till from ever disagreeing.
        session([
            'pos_till_id' => $till->id,
            self::BRANCH_SESSION_KEY => (int) $till->branch_id,
        ]);

        return ['ok' => true];
    }

    /**
     * Get the previous closed session's counted closing balance for a till.
     *
     * This is the amount that should carry forward as the next
     * session's opening cash balance.
     */
    protected function previousClosingBalance(int $tillId): ?float
    {
        return app(PosSessionService::class)->previousClosingBalance($tillId);
    }

    #[Renderless]
    public function latestPreviousClosingBalance(?int $tillId = null): ?float
    {
        if (! $tillId) {
            return null;
        }

        return $this->previousClosingBalance((int) $tillId);
    }

    /**
     * @return array<string, mixed>
     */
    public function startSession(float $openingBalance, ?string $note = null): array
    {
        $tillId = session('pos_till_id');

        if (! $tillId) {
            return ['ok' => false, 'errors' => ['Select a till first.']];
        }

        // Unscoped for the same reason as assignTill(): the acting user's
        // ambient branch context may not yet match this till's branch.
        $till = Till::withoutGlobalScope(BranchScope::class)->findOrFail($tillId);

        if ($openingBalance < 0 || $openingBalance > 99999999.99) {
            return ['ok' => false, 'errors' => ['The opening balance is invalid.']];
        }

        // The service refuses to open a second session on a till that already
        // has one, or one whose balance departs from the till's previous
        // closing balance without a reason — guards against double-submits,
        // stale tabs, and unexplained cash discrepancies.
        try {
            app(PosSessionService::class)->open(
                branchId: (int) $till->branch_id,
                tillId: (int) $till->id,
                openingBalance: $openingBalance,
                note: $note,
            );
        } catch (RuntimeException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        return ['ok' => true];
    }

    /**
     * @return array<string, mixed>
     */
    public function closeSession(?float $closingBalance, ?string $note = null): array
    {
        $tillId = session('pos_till_id');

        if (! $tillId) {
            return ['ok' => true];
        }

        $session = PosSession::where('till_id', $tillId)
            ->whereHas('status', fn ($q) => $q->where('code', Status::SESSION_OPEN))
            ->latest()
            ->first();

        if ($session) {
            /*
             * If the cashier entered counted cash, use that actual amount.
             *
             * If they left counted cash blank, use the system's Expected Balance
             * as the closing balance. This means the next opening can still carry
             * the correct amount forward.
             */
            if ($closingBalance === null) {
                $closingBalance = (float) app(CashMovementService::class)
                    ->expectedBalance($session);
            }

            if ($closingBalance < 0 || $closingBalance > 99999999.99) {
                return ['ok' => false, 'errors' => ['The closing balance is invalid.']];
            }

            app(PosSessionService::class)->close(
                $session,
                $closingBalance,
                $note
            );
        }

        session()->forget('pos_till_id');

        return ['ok' => true];
    }

    /**
     * @return array<string, mixed>
     */
    #[Renderless]
    public function sessionReconciliation(): array
    {
        $activeSession = $this->resolveActiveSession();

        if (! $activeSession) {
            return ['ok' => false, 'errors' => ['No active till session.']];
        }

        return ['ok' => true, 'reconciliation' => app(PosSessionService::class)->reconciliation($activeSession)];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Cash movements
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Withdraw cash from, or add cash to, the active till drawer. A reason
     * is mandatory for withdrawals; a note is additionally required when the
     * chosen reason demands one (e.g. "Other").
     *
     * Renderless: cash movements only affect the expected balance badge,
     * which the client can patch from the returned value without a full
     * catalog reload.
     *
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    #[Renderless]
    public function createCashMovement(array $payload): array
    {
        abort_unless(Auth::user()->hasPermissionTo('pos_cash_movements.create'), 403);

        $validator = Validator::make($payload, [
            'type' => 'required|in:in,out',
            'amount' => 'required|numeric|min:0.01|max:99999999.99',
            'reason_id' => 'required_if:type,out|nullable|integer|exists:cash_movement_reasons,id',
            'note' => 'nullable|string|max:255',
        ]);

        if ($validator->fails()) {
            return ['ok' => false, 'errors' => $validator->errors()->all()];
        }

        $activeSession = $this->resolveActiveSession();
        if (! $activeSession) {
            return ['ok' => false, 'errors' => ['No active till session.']];
        }

        if ($payload['type'] === 'out' && ! empty($payload['reason_id'])) {
            $reason = CashMovementReason::find($payload['reason_id']);
            if ($reason?->requires_note && ! trim((string) ($payload['note'] ?? ''))) {
                return ['ok' => false, 'errors' => ['A note is required for this reason.']];
            }
        }

        try {
            if ($payload['type'] === 'out') {
                app(CashMovementService::class)->withdraw(
                    $activeSession,
                    (float) $payload['amount'],
                    (int) $payload['reason_id'],
                    $payload['note'] ?? null,
                );
            } else {
                app(CashMovementService::class)->cashIn(
                    $activeSession,
                    (float) $payload['amount'],
                    $payload['note'] ?? null,
                );
            }
        } catch (RuntimeException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        // Return fresh expected balance so the Alpine badge can patch itself
        // without a full page re-render (renderless).
        $freshExpected = app(CashMovementService::class)->expectedBalance($activeSession->refresh());

        return ['ok' => true, 'message' => 'Cash movement recorded.', 'expected_balance' => $freshExpected];
    }

    // ─────────────────────────────────────────────────────────────────────
    // Last sale / amendment
    // ─────────────────────────────────────────────────────────────────────

    /**
     * @return array<string, mixed>|null
     */
    #[Renderless]
    public function lastSale(): ?array
    {
        $activeSession = $this->resolveActiveSession();
        if (! $activeSession) {
            return null;
        }

        // Invoice is branch-scoped, but this is an action method, not a page
        // render — nothing else has pinned CurrentContext to the till's
        // branch on this request yet (see bindContextToBranch()'s docblock).
        // Without this, the app-wide branch context (set independently, e.g.
        // by the top-bar selector) can silently hide the sale just rung up.
        $this->bindContextToBranch($this->resolveBranchId($activeSession));

        $lastInvoice = Invoice::query()
            ->with('customer:id,business_name')
            ->where('pos_session_id', $activeSession->id)
            ->where('channel', 'till')
            ->latest('id')
            ->first();

        if (! $lastInvoice) {
            return null;
        }

        return [
            'invoice' => $lastInvoice->toArray(),
            'can_amend' => app(PosSettings::class)->canAmendInvoice($lastInvoice->created_at),
        ];
    }

    /**
     * Void the last sale and return its contents so the cashier can re-ring
     * it. The replacement invoice records `amended_from_invoice_id`, so the
     * correction is never silently invisible.
     *
     * @return array<string, mixed>
     */
    #[Renderless]
    public function amendSale(int $invoiceId): array
    {
        $activeSession = $this->resolveActiveSession();
        if (! $activeSession) {
            return ['ok' => false, 'errors' => ['Invoice does not belong to the active till session.']];
        }

        // Same reasoning as lastSale(): bind to the till's branch before
        // touching the branch-scoped Invoice model, rather than trusting
        // whatever CurrentContext happens to hold on this request.
        $this->bindContextToBranch($this->resolveBranchId($activeSession));

        $invoice = Invoice::findOrFail($invoiceId);

        if ($invoice->pos_session_id !== $activeSession->id) {
            return ['ok' => false, 'errors' => ['Invoice does not belong to the active till session.']];
        }

        if (! app(PosSettings::class)->canAmendInvoice($invoice->created_at)) {
            return ['ok' => false, 'errors' => ['This sale can no longer be amended.']];
        }

        $invoice->load('items');

        $payload = [
            'header' => [
                'customer_id' => $invoice->customer_id,
                'discount_type' => $invoice->discount > 0 ? 'fixed' : null,
                'discount_value' => (float) $invoice->discount,
            ],
            'items' => $invoice->items->map(fn ($item) => [
                'product_id' => $item->product_id,
                'product_bundle_id' => null,
                'product_name' => $item->product_name,
                'quantity' => (float) $item->quantity,
                'unit_price' => (float) $item->unit_price,
                'vat_rate' => (float) $item->vat_rate,
                'discount_type' => $item->discount_type,
                'discount_value' => (float) $item->discount_value,
                'stock_batch_id' => $item->stock_batch_id,
                'serial_ids' => $item->product_serial_id ? [$item->product_serial_id] : [],
            ])->values()->all(),
        ];

        try {
            DB::transaction(function () use ($invoice, $activeSession): void {
                $locked = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);

                app(InvoiceService::class)->delete($locked);

                $cashMovement = CashMovement::where('pos_session_id', $activeSession->id)
                    ->where('source', CashMovement::SOURCE_SALE)
                    ->where('note', "Sale {$invoice->invoice_number}")
                    ->first();

                if ($cashMovement) {
                    $cashMovement->delete();
                }

                session(['pos_amended_from_invoice_id' => $invoice->id]);
            });
        } catch (RuntimeException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        return ['ok' => true, 'payload' => $payload];
    }
}