<?php

namespace App\Services;

use App\Models\Catalog\Product;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\PaymentMethod;
use App\Models\Lookups\Status;
use App\Models\Returns\CreditNote;
use App\Models\Sales\Invoice;
use App\Models\Sales\SalesOrder;
use App\Models\Sales\SalesOrderItem;
use App\Models\Sales\SalesOrderPayment;
use App\Models\Scopes\BranchScope;
use App\Support\Quantity;
use App\Support\WhatsApp;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Everything that happens to a sales order between "customer asks for it" and
 * "we invoice it".
 *
 * The deliberate split of responsibility: this service owns the *promise* —
 * lines, quoted prices, advances, deadline, lifecycle status — and touches
 * neither stock nor the cash book. {@see InvoiceService} owns the *settlement*
 * and is called at conversion, with the order's lines and advances handed over
 * intact. That is why an order can be edited while an invoice cannot: the
 * order is the living document and the invoice is its snapshot. When the
 * tenant has auto-invoicing on, conversion happens inside create() itself, and
 * later edits to a converted order resync the invoice
 * ({@see InvoiceService::resyncFromSalesOrder()}).
 *
 * Custom lines carry no product_id, so InvoiceService::sellLine() sells them as
 * free-form items — no catalog lookup, no stock movement, no serial rules.
 * Made-to-order goods need none of that.
 */
class SalesOrderService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly InvoiceService $invoices,
        private readonly VatSettings $vat,
    ) {}

    /**
     * @param  array{
     *   branch_id:int, customer_id?:int|null, delivery_address_id?:int|null,
     *   sales_rep_id?:int|null, order_date?:string|null, deadline_date?:string|null,
     *   discount_type?:string|null, discount_value?:float, note?:string|null,
     *   status_code?:string|null
     * }  $header
     * @param  array<int,array<string,mixed>>  $lines
     * @param  array<int,array<string,mixed>>  $payments
     */
    public function create(array $header, array $lines, array $payments = []): SalesOrder
    {
        if ($lines === []) {
            throw new RuntimeException('A sales order needs at least one line.');
        }

        $this->assertDiscountAllowed($header, $lines);

        return DB::transaction(function () use ($header, $lines, $payments): SalesOrder {
            $order = SalesOrder::create([
                'order_number' => $this->numbers->next('sales_order'),
                'branch_id' => $header['branch_id'],
                'customer_id' => $header['customer_id'] ?? null,
                'delivery_address_id' => $header['delivery_address_id'] ?? null,
                'sales_rep_id' => $header['sales_rep_id'] ?? null,
                'status_id' => $this->statusId($header['status_code'] ?? Status::SO_CONFIRMED),
                'payment_status_id' => Status::idOf(Status::CTX_INVOICE_PAYMENT, Status::UNPAID),
                'order_date' => $header['order_date'] ?? now()->toDateString(),
                'deadline_date' => $header['deadline_date'] ?? null,
                'discount_type' => $header['discount_type'] ?? null,
                'discount_value' => (float) ($header['discount_value'] ?? 0),
                'note' => $header['note'] ?? null,
            ]);

            $this->syncLines($order, $lines);
            $this->syncPayments($order, $payments);
            $this->recalculate($order);
            $this->assertAdvancesWithinTotal($order);

            AuditLog::record('sales_order.created', $order, [
                'reference' => $order->order_number,
                'customer' => $order->customer?->business_name,
                'items' => count($lines),
                'custom_items' => count(array_filter($lines, fn (array $l): bool => (bool) ($l['is_custom'] ?? false))),
                'deadline' => $order->deadline_date?->toDateString(),
                'total' => (string) $order->net_total,
                'branch' => $order->branch?->name,
            ]);

            // Auto-invoice: the tenant's choice, applied in this same write.
            // convertToInvoice() runs its own transaction, so on failure the
            // whole create rolls back — a broken conversion never leaves a
            // half-written order behind.
            if (app(AppSettings::class)->salesOrderAutoInvoice()) {
                $this->convertToInvoice($order);
            }

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

    /**
     * @param  array<string,mixed>  $header
     * @param  array<int,array<string,mixed>>  $lines
     * @param  array<int,array<string,mixed>>  $payments
     */
    public function update(SalesOrder $order, array $header, array $lines, array $payments = []): SalesOrder
    {
        $this->assertEditable($order);

        if ($lines === []) {
            throw new RuntimeException('A sales order needs at least one line.');
        }

        // Once an order is invoiced, its money is banked: the branch and
        // customer the invoice was raised under, and the advances that were
        // converted into transactions, are fixed. Lines, discounts, dates and
        // notes may all still change — those get resynced onto the invoice.
        $converted = $order->isConverted();
        if ($converted) {
            $this->assertConvertedEditsStayed($order, $header, $payments);
        }

        // A user without the `isms_sales_orders.discount` permission can edit
        // an order but never add, raise, or remove a discount on it. The
        // submitted discounts are compared against the record's own (an empty
        // submission just means the form rendered no discount control), and
        // the persisted discounts win on save — see update() below — so a
        // hidden form field can never silently strip one.
        $canDiscount = $this->assertDiscountEditsAllowed($order, $header, $lines);
        if (! $canDiscount) {
            $lines = $this->preserveLineDiscounts($order, $lines);
        }

        return DB::transaction(function () use ($order, $header, $lines, $payments, $converted, $canDiscount): SalesOrder {
            // Written whole, not merged: the edit form always submits every one
            // of these, so a key arriving null means the user cleared it (a
            // dropped deadline, a removed discount) and must be persisted as
            // null rather than quietly keeping the old value. The discount
            // fields are the one exception: without the discount permission
            // the editor's own (matching) values are ignored in favour of the
            // record's, which is what makes the discount survive the edit.
            $order->update([
                'branch_id' => $header['branch_id'] ?? $order->branch_id,
                'customer_id' => $header['customer_id'] ?? null,
                'delivery_address_id' => $header['delivery_address_id'] ?? null,
                'sales_rep_id' => $header['sales_rep_id'] ?? null,
                'order_date' => $header['order_date'] ?? $order->order_date,
                'deadline_date' => $header['deadline_date'] ?? null,
                'discount_type' => $canDiscount ? ($header['discount_type'] ?? null) : $order->discount_type,
                'discount_value' => $canDiscount ? (float) ($header['discount_value'] ?? 0) : (float) $order->discount_value,
                'note' => $header['note'] ?? null,
            ]);

            $this->syncLines($order, $lines);
            $this->syncPayments($order, $payments);
            $this->recalculate($order);
            $this->assertAdvancesWithinTotal($order);

            // A converted order's invoice is rebuilt from the new lines and
            // totals, without touching the banked money — see
            // InvoiceService::resyncFromSalesOrder() for the rules.
            if ($converted && $order->invoice) {
                $this->invoices->resyncFromSalesOrder($order->invoice, $order->load('items'));
            }

            AuditLog::record('sales_order.updated', $order, [
                'reference' => $order->order_number,
                'items' => count($lines),
                'total' => (string) $order->net_total,
            ]);

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

    /**
     * The discount gate for a brand-new order: no discount permission, no
     * discount in the payload — header or line — at all.
     *
     * @param  array<string, mixed>  $header
     * @param  array<int, array<string, mixed>>  $lines
     */
    private function assertDiscountAllowed(array $header, array $lines): void
    {
        if (Auth::user()?->hasPermissionTo('isms_sales_orders.discount')) {
            return;
        }

        if ($this->discountPresent($header, $lines)) {
            throw new RuntimeException('You do not have permission to apply a discount to a sales order.');
        }
    }

    /**
     * The discount gate for an edit: without the permission the submitted
     * discounts must not add anything the record does not already carry — an
     * empty submission just means the form rendered no discount control, so
     * it is allowed to pass and preserveLineDiscounts() keeps the record's
     * values. Returns whether the actor may set discounts.
     *
     * @param  array<string, mixed>  $header
     * @param  array<int, array<string, mixed>>  $lines
     */
    private function assertDiscountEditsAllowed(SalesOrder $order, array $header, array $lines): bool
    {
        if (Auth::user()?->hasPermissionTo('isms_sales_orders.discount')) {
            return true;
        }

        // Header: a submission carrying a type or a value must equal the
        // record's own — anything else is a change of the discount.
        $submittedHeaderEmpty = ($header['discount_type'] ?? null) === null
            && round((float) ($header['discount_value'] ?? 0), 2) === 0.0;

        if (! $submittedHeaderEmpty) {
            $submittedHeader = [
                'type' => $header['discount_type'] ?? null,
                'value' => round((float) ($header['discount_value'] ?? 0), 2),
            ];
            $persistedHeader = [
                'type' => $order->discount_type,
                'value' => round((float) $order->discount_value, 2),
            ];

            if ($submittedHeader !== $persistedHeader) {
                throw new RuntimeException('You do not have permission to change this order\'s discount.');
            }
        }

        // Lines: a submitted line claiming a discount must carry exactly the
        // discount the record has on that same (product, custom) line. A line
        // claiming nothing is exempt — it inherits the record's discount.
        $persisted = $this->persistedLineDiscounts($order);

        foreach ($lines as $line) {
            $submittedType = $line['discount_type'] ?? null;
            $submittedValue = round((float) ($line['discount_value'] ?? 0), 2);

            if ($submittedType === null && $submittedValue === 0.0) {
                continue;
            }

            $identity = (bool) ($line['is_custom'] ?? false)
                ? 'custom'
                : 'p'.(int) ($line['product_id'] ?? 0);

            $expected = $persisted[$identity] ?? ['type' => null, 'value' => 0.0];
            if ($submittedType !== $expected['type'] || $submittedValue !== $expected['value']) {
                throw new RuntimeException('You do not have permission to change this order\'s line discounts.');
            }
        }

        return false;
    }

    /**
     * The record's line discounts keyed by line identity — 'custom' for
     * bespoke lines, 'p{product_id}' for catalog lines. When several custom
     * lines carry discounts only the last one survives the keying; that
     * merely makes the edit gate refuse rather than guess, which is the safe
     * direction (such lines cannot be created with a discount through the UI
     * anyway).
     *
     * @return array<string, array{type: string|null, value: float}>
     */
    private function persistedLineDiscounts(SalesOrder $order): array
    {
        $persisted = [];

        foreach ($order->items as $item) {
            $identity = $item->is_custom
                ? 'custom'
                : 'p'.(int) $item->product_id;

            $persisted[$identity] = [
                'type' => $item->discount_type,
                'value' => round((float) $item->discount_value, 2),
            ];
        }

        return $persisted;
    }

    /**
     * Overlays the record's line discounts back onto a submission where the
     * form rendered no discount controls, so saving without the discount
     * permission can never strip a discount from a line that keeps it. Only
     * lines the edit form itself round-tripped carry discount keys; lines
     * built fresh in the grid have none and stay clean — a new line never
     * inherits a discount just because a same-identity line once had one.
     *
     * @param  array<int, array<string, mixed>>  $lines
     * @return array<int, array<string, mixed>>
     */
    private function preserveLineDiscounts(SalesOrder $order, array $lines): array
    {
        $persisted = $this->persistedLineDiscounts($order);

        return array_map(function (array $line) use ($persisted): array {
            if (! array_key_exists('discount_type', $line) && ! array_key_exists('discount_value', $line)) {
                return $line;
            }

            $identity = (bool) ($line['is_custom'] ?? false)
                ? 'custom'
                : 'p'.(int) ($line['product_id'] ?? 0);

            if (! isset($persisted[$identity])) {
                return $line;
            }

            $line['discount_type'] = $persisted[$identity]['type'];
            $line['discount_value'] = $persisted[$identity]['value'];

            return $line;
        }, $lines);
    }

    /**
     * Whether the payload carries any discount at all — a header discount
     * needs both a type and a positive value; a line discount just a positive
     * value (the type is implied by the value).
     *
     * @param  array<string, mixed>  $header
     * @param  array<int, array<string, mixed>>  $lines
     */
    private function discountPresent(array $header, array $lines): bool
    {
        $hasHeader = ($header['discount_type'] ?? null) !== null
            && (float) ($header['discount_value'] ?? 0) > 0;

        $hasLine = collect($lines)
            ->contains(fn ($line) => (float) ($line['discount_value'] ?? 0) > 0);

        return $hasHeader || $hasLine;
    }

    /**
     * The fields a converted order can no longer change, because conversion
     * banked them: the branch and customer the invoice was raised under, and
     * the advance payments that became transactions on that invoice.
     *
     * @param  array<string,mixed>  $header
     * @param  array<int,array<string,mixed>>  $payments
     */
    private function assertConvertedEditsStayed(SalesOrder $order, array $header, array $payments): void
    {
        if ((int) ($header['branch_id'] ?? $order->branch_id) !== (int) $order->branch_id) {
            throw new RuntimeException('This order is already invoiced — its branch cannot be changed.');
        }

        if (($header['customer_id'] ?? $order->customer_id) != $order->customer_id) {
            throw new RuntimeException('This order is already invoiced — its customer cannot be changed.');
        }

        $normalize = fn (array $payment): array => [
            'method' => $payment['method'] ?? null,
            'amount' => round((float) ($payment['amount'] ?? 0), 2),
            'reference' => $payment['reference'] ?? null,
            'credit_note_id' => $payment['credit_note_id'] ?? null,
            'cheque_number' => $payment['cheque_number'] ?? null,
            'cheque_bank' => $payment['cheque_bank'] ?? null,
            'cheque_branch' => $payment['cheque_branch'] ?? null,
            'cheque_date' => isset($payment['cheque_date']) ? (string) $payment['cheque_date'] : null,
            'custom_fields' => $payment['custom_fields'] ?? null,
        ];

        $submitted = collect($payments)
            ->filter(fn (array $p): bool => round((float) ($p['amount'] ?? 0), 2) > 0.001)
            ->map($normalize)
            ->sortBy('amount')
            ->values()
            ->all();

        $persisted = $order->payments()->get()
            ->filter(fn (SalesOrderPayment $p): bool => (float) $p->amount > 0.001)
            ->map(fn (SalesOrderPayment $p): array => [
                'method' => $p->method,
                'amount' => round((float) $p->amount, 2),
                'reference' => $p->reference,
                'credit_note_id' => $p->credit_note_id,
                'cheque_number' => $p->cheque_number,
                'cheque_bank' => $p->cheque_bank,
                'cheque_branch' => $p->cheque_branch,
                'cheque_date' => $p->cheque_date?->toDateString(),
                'custom_fields' => $p->custom_fields,
            ])
            ->sortBy('amount')
            ->values()
            ->all();

        if ($submitted !== $persisted) {
            throw new RuntimeException('This order is already invoiced — its advance payments were banked and cannot be changed.');
        }
    }

    /**
     * Replace the order's lines wholesale.
     *
     * A full delete-and-rewrite rather than an id-aware diff: order lines carry
     * no downstream references (no stock movements, no claims, nothing points
     * at a sales_order_item), so there is no identity worth preserving and a
     * rewrite cannot orphan anything.
     *
     * @param  array<int,array<string,mixed>>  $lines
     */
    private function syncLines(SalesOrder $order, array $lines): void
    {
        $order->items()->delete();

        // Catalog lines are priced from their selected lot here, exactly as
        // InvoiceService prices them at checkout — the client's `unit_price`
        // is a UI convenience, never the source of truth. Custom lines have no
        // catalog entry to check against, so their quoted price stands as
        // typed; that is the whole point of a custom order.
        $productIds = collect($lines)->reject(fn (array $l): bool => (bool) ($l['is_custom'] ?? false))
            ->pluck('product_id')->filter()->unique();
        $products = Product::whereIn('id', $productIds)->get()->keyBy('id');

        foreach ($lines as $line) {
            $isCustom = (bool) ($line['is_custom'] ?? false);

            if ($isCustom) {
                $order->items()->create($this->shapeLine($line, [
                    'product_id' => null,
                    'is_custom' => true,
                    'product_name' => trim((string) ($line['product_name'] ?? '')) ?: 'Custom item',
                    'product_sku' => null,
                    'custom_description' => $line['custom_description'] ?? null,
                    'custom_specification' => $line['custom_specification'] ?? null,
                    'unit_price' => (float) ($line['unit_price'] ?? 0),
                ]));

                continue;
            }

            $product = $products->get((int) ($line['product_id'] ?? 0))
                ?? throw new RuntimeException("Product #{$line['product_id']} not found.");

            $quote = $this->quoteLot($product, $line, (int) $order->branch_id);

            $order->items()->create($this->shapeLine($line, [
                'product_id' => $product->id,
                'is_custom' => false,
                'product_name' => $product->name,
                'product_sku' => $product->sku,
                'unit_id' => $line['unit_id'] ?? null,
                'stock_batch_id' => $quote['batch_id'],
                'unit_price' => $quote['unit_price'],
                'vat_rate' => $this->vatRate($product->vat_rate),
            ]));
        }
    }

    /**
     * Resolve the lot a catalog line is quoted against, and its price.
     *
     * Non-batch-tracked products (batches are an internal receiving detail,
     * not a selling decision): price and stock come from the unified pool
     * (BatchService::unifiedBatchFor(), the latest GRN price), never from a
     * client-supplied batch_id or a FEFO/FIFO choice — there is no
     * override-permission gate here, because there is no "lot" to have
     * deviated from.
     *
     * Batch-tracked products, three paths in order:
     *
     * 1. Explicit batch selection (`batch_id` on the line). Kept strict — if
     *    the user (or a stale edit-form snapshot) picked a specific lot, that
     *    lot must actually belong to this product/branch, be active, be in
     *    stock, and carry a price. No fallback here: a stale or bad
     *    selection is refused outright rather than silently substituting a
     *    different batch. A non-front choice still needs
     *    `inventory_batches.override` — the client's snapshot is a UI
     *    convenience, not a trusted source, so the front lot is re-derived
     *    server-side rather than taking the client's word for it.
     *
     * 2. No selection: fall back to the normal sellable-lot resolution
     *    (FEFO for expiry-tracked products, FIFO otherwise) — the same lot
     *    the override check above would accept anyway.
     *
     * 3. Nothing currently sellable (out of stock at this branch): a sales
     *    order is still allowed to quote a price, since fulfilment happens
     *    at invoice conversion, not here. The most recently received priced
     *    batch is used for the price only — `batch_id` is returned as null
     *    so nothing pretends stock exists. InvoiceService re-resolves and
     *    reprices the real fulfilment batch at conversion time, once stock
     *    is actually available.
     *
     * @param  array<string,mixed>  $line
     * @return array{batch_id: int|null, unit_price: float}
     */
    private function quoteLot(Product $product, array $line, int $branchId): array
    {
        if (! $product->is_batch_tracked) {
            $batch = app(BatchService::class)->unifiedBatchFor($product->id, $branchId)
                ?? app(BatchService::class)->sellableFor($product->id, $branchId, $product->is_expiry_tracked)->first();

            if ($batch && $batch->isActive() && (float) $batch->available_quantity > 0 && $batch->sale_price !== null) {
                return ['batch_id' => $batch->id, 'unit_price' => (float) $batch->sale_price];
            }

            return $this->quoteFromLatestReceipt($product, $branchId);
        }

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

        if ($batchId > 0) {
            // Only the branch scope is lifted — this id came from the client,
            // and dropping the tenant scope too would let a batch_id from a
            // different tenant be loaded if ids ever happen to collide.
            $batch = StockBatch::withoutGlobalScope(BranchScope::class)->find($batchId);

            if (! $batch) {
                throw new RuntimeException("Selected batch for {$product->name} was not found.");
            }
            if ((int) $batch->product_id !== $product->id) {
                throw new RuntimeException("Lot #{$batch->id} does not belong to product {$product->name}.");
            }
            if ((int) $batch->branch_id !== $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 (! Auth::user()?->hasPermissionTo('inventory_batches.override')) {
                $frontLotId = app(BatchService::class)->frontLotId($product->id, $branchId, $product->is_expiry_tracked);

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

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

        $batch = app(BatchService::class)->sellableFor($product->id, $branchId, $product->is_expiry_tracked)->first();

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

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

        return $this->quoteFromLatestReceipt($product, $branchId);
    }

    /**
     * Out of stock at this branch: a sales order is a promise about the
     * future, not a shelf check (see class docblock), so quote from the most
     * recently received priced batch instead of refusing the line. `batch_id`
     * stays null — InvoiceService resolves and reprices the real fulfilment
     * batch itself at conversion, once stock actually exists.
     *
     * @return array{batch_id: null, unit_price: float}
     */
    private function quoteFromLatestReceipt(Product $product, int $branchId): array
    {
        $quoteBatch = StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('product_id', $product->id)
            ->where('branch_id', $branchId)
            ->active()
            ->whereNotNull('sale_price')
            ->orderByDesc('created_at')
            ->first();

        if (! $quoteBatch) {
            throw new RuntimeException("Product {$product->name} has no stock and no priced batch available for quotation.");
        }

        return ['batch_id' => null, 'unit_price' => (float) $quoteBatch->sale_price];
    }

    /**
     * Fold a raw line into a persistable item row, computing the money columns
     * from whichever unit price the caller above settled on.
     *
     * @param  array<string,mixed>  $line
     * @param  array<string,mixed>  $resolved
     * @return array<string,mixed>
     */
    private function shapeLine(array $line, array $resolved): array
    {
        $quantity = max(0, (float) ($line['quantity'] ?? 0));
        $unitPrice = (float) $resolved['unit_price'];
        $vatRate = $this->vatRate($resolved['vat_rate'] ?? $line['vat_rate'] ?? 0);

        $grossTotal = round($quantity * $unitPrice, 2);
        $discountAmount = $this->lineDiscount(
            $grossTotal,
            $quantity,
            $line['discount_type'] ?? null,
            (float) ($line['discount_value'] ?? 0),
        );
        $lineTotal = round($grossTotal - $discountAmount, 2);

        return $resolved + [
            'unit_id' => $line['unit_id'] ?? null,
            'quantity' => $quantity,
            'vat_rate' => $vatRate,
            'vat_amount' => round($lineTotal * $vatRate / 100, 2),
            'gross_total' => $grossTotal,
            'discount_type' => $line['discount_type'] ?? null,
            'discount_value' => (float) ($line['discount_value'] ?? 0),
            'discount_amount' => $discountAmount,
            'line_total' => $lineTotal,
        ];
    }

    /**
     * Clamp a fixed/percentage discount to the amount it comes off — same rule
     * InvoiceService applies, so an order and the invoice it becomes can never
     * disagree about a line's value.
     */
    private function lineDiscount(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 (mirrors InvoiceService); the
        // header-level call passes quantity 1, so a whole-order flat discount
        // still applies once against the running total.
        $discount = $type === 'percentage' ? $amount * ($value / 100) : $value * $quantity;

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

    /**
     * The single choke point every VAT rate on a sales order — catalog,
     * custom, or carried forward at invoice conversion — passes through.
     * While the tenant's VAT master switch is off, this always returns 0,
     * mirroring InvoiceService::vatRate() for the same reason: a hidden
     * control in the UI is not enough on its own.
     */
    private function vatRate(mixed $rate): float
    {
        return $this->vat->enabled() ? (float) $rate : 0.0;
    }

    /**
     * Record a single advance against an order — the "Add payment" path
     * (view page, edit page and listing), distinct from the edit-form repeater
     * which rewrites the whole payment set.
     *
     * Payments are accepted on both open and already-invoiced orders — a
     * customer settling their balance after conversion is the normal case,
     * not an edge case — so this only refuses a cancelled order
     * (assertAcceptsPayment()), unlike cancel()/convertToInvoice() which use
     * the stricter assertOpen() and must remain fully open.
     *
     * Enforces the same rules the edit form does (method must be active, the
     * advance can never exceed the order's value) plus the per-method field
     * rules from PaymentMethod (`requires_reference`, `requires_cheque_fields`,
     * custom-method `fields`) so the modal can never persist a tender the
     * checkout flow would refuse. File-type custom fields are skipped here —
     * they are captured by the POS terminal, not the order desk.
     *
     * @param  array<string, mixed>  $payment
     */
    public function recordPayment(SalesOrder $order, array $payment): SalesOrder
    {
        $this->assertAcceptsPayment($order);

        $method = PaymentMethod::findActiveByCode((string) ($payment['method'] ?? ''));

        if (! $method) {
            throw new RuntimeException('That payment method is not available.');
        }

        if ($error = PaymentMethod::validateRequiredFields($method->code, $payment)) {
            throw new RuntimeException($error);
        }

        $amount = round((float) ($payment['amount'] ?? 0), 2);

        if ($amount <= 0.001) {
            throw new RuntimeException('A payment needs an amount greater than zero.');
        }

        if (! empty($payment['credit_note_id'])) {
            $this->assertCreditNoteUsable($order, (int) $payment['credit_note_id'], $amount);
        }

        $this->assertCustomFields($method, $payment['custom_fields'] ?? []);

        return DB::transaction(function () use ($order, $payment, $amount): SalesOrder {
            $order->payments()->create([
                'method' => $payment['method'],
                'amount' => $amount,
                'reference' => $payment['reference'] ?? null,
                'cheque_number' => $payment['cheque_number'] ?? null,
                'cheque_bank' => $payment['cheque_bank'] ?? null,
                'cheque_branch' => $payment['cheque_branch'] ?? null,
                'cheque_date' => $payment['cheque_date'] ?? null,
                'credit_note_id' => $payment['credit_note_id'] ?? null,
                'custom_fields' => $payment['custom_fields'] ?? null,
                'received_at' => $payment['received_at'] ?? now()->toDateString(),
                'created_by' => Auth::guard('web')->id(),
            ]);

            $this->recalculate($order);
            $this->assertAdvancesWithinTotal($order);

            AuditLog::record('sales_order.payment_added', $order, [
                'reference' => $order->order_number,
                'method' => $payment['method'],
                'amount' => 'Rs. '.number_format($amount, 2),
            ]);

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

    /**
     * A credit note can only ever be claimed against its own customer's sale —
     * mirroring the options filter the form offers, enforced server-side here
     * for the add-payment path.
     */
    private function assertCreditNoteUsable(SalesOrder $order, int $creditNoteId, float $amount): void
    {
        if (! $order->customer_id) {
            throw new RuntimeException('An order needs a customer before a credit note can be applied.');
        }

        $note = CreditNote::query()->find($creditNoteId);

        if (! $note || (int) $note->customer_id !== (int) $order->customer_id) {
            throw new RuntimeException('That credit note does not belong to this order\'s customer.');
        }

        if (! $note->status || $note->status->code !== Status::ISSUED) {
            throw new RuntimeException('That credit note is not available to apply.');
        }

        // A note is spent down over time, so what matters is what is LEFT on
        // it, not its face value. The advance is only a promise at this stage
        // (the note is drawn down for real at conversion, through
        // PaymentService) — but taking an advance the note cannot cover would
        // hand the customer an order that fails the moment it is invoiced.
        $remaining = $note->remainingAmount();

        if ($amount > $remaining + 0.001) {
            throw new RuntimeException(
                'Credit note '.$note->cn_number.' only has Rs. '.number_format($remaining, 2)
                .' left; Rs. '.number_format($amount, 2).' was entered.'
            );
        }
    }

    /**
     * Required custom-method fields, minus file attachments — those are
     * captured by the POS terminal, so an order-desk advance must not be
     * blocked on them.
     *
     * @param  array<string, mixed>  $submitted
     */
    private function assertCustomFields(PaymentMethod $method, array $submitted): void
    {
        foreach ($method->fields ?? [] as $field) {
            if (($field['type'] ?? 'text') === 'file' || empty($field['required'])) {
                continue;
            }

            $value = $submitted[$field['key']] ?? null;
            if ($value === null || trim((string) $value) === '') {
                throw new RuntimeException("\"{$field['label']}\" is required for {$method->name}.");
            }
        }
    }

    /**
     * @param  array<int,array<string,mixed>>  $payments
     */
    private function syncPayments(SalesOrder $order, array $payments): void
    {
        $order->payments()->delete();

        foreach ($payments as $payment) {
            $amount = round((float) ($payment['amount'] ?? 0), 2);

            if ($amount <= 0.001) {
                continue;
            }

            // Every write path funnels through here (create, update and the
            // add-payment action all call this), so this is the one place a
            // credit advance has to be checked against what the note actually
            // still holds. Catching it now, rather than at conversion, is the
            // difference between a rejected form and an order that cannot be
            // invoiced weeks later.
            if (! empty($payment['credit_note_id'])) {
                $this->assertCreditNoteUsable($order, (int) $payment['credit_note_id'], $amount);
            }

            $order->payments()->create([
                'method' => $payment['method'],
                'amount' => $amount,
                'reference' => $payment['reference'] ?? null,
                'cheque_number' => $payment['cheque_number'] ?? null,
                'cheque_bank' => $payment['cheque_bank'] ?? null,
                'cheque_branch' => $payment['cheque_branch'] ?? null,
                'cheque_date' => $payment['cheque_date'] ?? null,
                'credit_note_id' => $payment['credit_note_id'] ?? null,
                'custom_fields' => $payment['custom_fields'] ?? null,
                'received_at' => $payment['received_at'] ?? now()->toDateString(),
                'created_by' => Auth::guard('web')->id(),
            ]);
        }
    }

    /**
     * Re-derive the order's totals and payment status from its own lines and
     * advances. The single place those columns are ever written, so a listing
     * can trust them without summing children.
     */
    public function recalculate(SalesOrder $order): void
    {
        $items = $order->items()->get();

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

        $netOfLines = round($subtotal - $lineDiscounts, 2);
        $headerDiscount = $this->lineDiscount($netOfLines, 1, $order->discount_type, (float) $order->discount_value);

        $discount = round($lineDiscounts + $headerDiscount, 2);
        $netTotal = round(max(0, $subtotal - $discount + $vatTotal), 2);

        $advancePaid = round((float) $order->payments()->sum('amount'), 2);
        // The balance floors at zero (never negative) — advances equal to the
        // order value are a fully-paid order, and the exact cap is enforced by
        // assertAdvancesWithinTotal() so a bigger advance can never be recorded.
        $balance = round(max(0, $netTotal - $advancePaid), 2);

        $order->update([
            'subtotal' => $subtotal,
            'vat_total' => $vatTotal,
            'discount' => $discount,
            'net_total' => $netTotal,
            'advance_paid' => $advancePaid,
            'balance' => $balance,
            'payment_status_id' => Status::idOf(
                Status::CTX_INVOICE_PAYMENT,
                $this->paymentStatusCode($netTotal, $advancePaid),
            ),
        ]);
    }

    private function paymentStatusCode(float $netTotal, float $paid): string
    {
        if ($paid <= 0.001) {
            return Status::UNPAID;
        }

        return $paid + 0.001 >= $netTotal ? Status::PAID : Status::PARTIAL;
    }

    /**
     * Advances are money taken against a quoted total, so they can never
     * exceed what the order is worth. Without this cap an order for Rs. 400
     * could "take" a Rs. 2000 advance that no invoice could ever absorb —
     * conversion would fail against PaymentService's balance cap, and a
     * credit-note advance would over-claim a note larger than the sale.
     * The excess must be refused at the door, not discovered at invoicing.
     */
    private function assertAdvancesWithinTotal(SalesOrder $order): void
    {
        $advancePaid = (float) $order->advance_paid;
        $netTotal = (float) $order->net_total;

        if ($advancePaid > $netTotal + 0.001) {
            throw new RuntimeException(
                'Advance payments of Rs. '.number_format($advancePaid, 2)
                .' exceed the order total of Rs. '.number_format($netTotal, 2)
                .'. Reduce the advances — an invoice can never settle more than the order is worth.',
            );
        }
    }

    /**
     * Move the order along its lifecycle.
     *
     * `invoiced` is not settable by hand — an order becomes invoiced by being
     * converted, never by someone picking the word from a dropdown, or the
     * status would start lying about whether an invoice exists.
     */
    public function updateStatus(SalesOrder $order, string $statusCode): SalesOrder
    {
        if ($statusCode === Status::SO_INVOICED && ! $order->isConverted()) {
            throw new RuntimeException('Convert the order into an invoice to mark it invoiced.');
        }

        if ($order->isConverted() && $statusCode !== Status::SO_INVOICED) {
            throw new RuntimeException('This order has already been invoiced.');
        }

        $from = $order->status?->name;
        $order->update(['status_id' => $this->statusId($statusCode)]);

        AuditLog::record('sales_order.status_changed', $order->refresh(), [
            'reference' => $order->order_number,
            'from' => $from,
            'to' => $order->status?->name,
        ]);

        return $order;
    }

    /**
     * The pre-filled WhatsApp message for an order — header, every line, the
     * totals and what is still owed. Plain text with only the *bold* markup
     * WhatsApp renders itself; no HTML, no markdown.
     *
     * Custom (made-to-order) lines carry their specification with them: the
     * whole reason an order goes to the workshop group is that somebody there
     * needs to read what the customer actually asked for.
     */
    public function whatsappMessage(SalesOrder $order): string
    {
        $order->loadMissing(['branch', 'customer', 'rep', 'status', 'items.unit']);

        $symbol = app(CurrentContext::class)->currencySymbol();
        $money = fn (float $amount): string => $symbol.number_format($amount, 2);

        $out = "*SALES ORDER {$order->order_number}*\n";

        if ($order->customer !== null) {
            $out .= "Customer: {$order->customer->business_name}";
            $out .= filled($order->customer->phone) ? " ({$order->customer->phone})\n" : "\n";
        }
        if ($order->branch !== null) {
            $out .= "Branch: {$order->branch->name}\n";
        }
        if ($order->rep !== null) {
            $out .= "Sales rep: {$order->rep->full_name}\n";
        }
        if ($order->order_date !== null) {
            $out .= 'Order date: '.$order->order_date->format('Y-m-d')."\n";
        }
        if ($order->deadline_date !== null) {
            $out .= '*Delivery deadline: '.$order->deadline_date->format('Y-m-d')."*\n";
        }
        if ($order->status !== null) {
            $out .= "Status: {$order->status->name}\n";
        }

        $out .= "\nItems:\n";

        foreach ($order->items as $index => $item) {
            $out .= sprintf(
                "%d. %s — %s × %s = %s\n",
                $index + 1,
                $item->product_name ?: 'Item',
                Quantity::format($item->quantity, $item->unit),
                $money((float) $item->unit_price),
                $money((float) $item->line_total),
            );

            if (filled($item->custom_description)) {
                $out .= "   {$item->custom_description}\n";
            }

            if (filled($item->custom_specification)) {
                $out .= "   Spec: {$item->custom_specification}\n";
            }
        }

        $out .= "\nSubtotal: {$money((float) $order->subtotal)}\n";

        if ((float) $order->discount > 0) {
            $out .= "Discount: -{$money((float) $order->discount)}\n";
        }

        if ((float) $order->vat_total > 0) {
            $out .= "VAT: {$money((float) $order->vat_total)}\n";
        }

        $out .= "*Total: {$money((float) $order->net_total)}*\n";

        if ((float) $order->advance_paid > 0) {
            $out .= "Advance paid: {$money((float) $order->advance_paid)}\n";
            $out .= "Balance due: {$money((float) $order->balance)}\n";
        }

        if (filled($order->note)) {
            $out .= "\nNote: {$order->note}\n";
        }

        return $out;
    }

    /**
     * Where to send {@see whatsappMessage()}, or null when the tenant has no
     * WhatsApp destination configured (Master Control → tenant → Sales Orders)
     * or what is configured is not a usable one.
     *
     * A group destination comes back WITHOUT the message attached — WhatsApp
     * group invite links cannot carry one. Callers pair the link with the
     * message text some other way; see the listing's WhatsApp button, which
     * copies it to the clipboard.
     */
    public function whatsappLink(SalesOrder $order): ?string
    {
        $target = app(AppSettings::class)->salesOrderWhatsappTarget();

        if ($target === null) {
            return null;
        }

        return WhatsApp::linkForTarget($target, $this->whatsappMessage($order));
    }

    public function cancel(SalesOrder $order, ?string $reason = null): SalesOrder
    {
        $this->assertOpen($order, 'cancelled');

        $order->update(['status_id' => $this->statusId(Status::SO_CANCELLED)]);

        AuditLog::record('sales_order.cancelled', $order, [
            'reference' => $order->order_number,
            'reason' => $reason,
        ]);

        return $order->refresh();
    }

    /**
     * Turn the order into a real invoice.
     *
     * This is the only point where anything irreversible happens: stock leaves,
     * the invoice number is issued, and the advances become banked payments.
     * All of it runs through InvoiceService/PaymentService untouched, so a
     * sales-order invoice is indistinguishable from one raised any other way.
     */
    public function convertToInvoice(SalesOrder $order): Invoice
    {
        $this->assertOpen($order, 'invoiced');

        if (! $order->customer_id) {
            throw new RuntimeException('A sales order needs a customer before it can be invoiced.');
        }

        return DB::transaction(function () use ($order): Invoice {
            $order->load(['items', 'payments']);

            $lines = $order->items->map(fn (SalesOrderItem $item): array => array_filter([
                // Custom lines pass product_id => null, which is exactly what
                // makes InvoiceService treat them as free-form: no repricing,
                // no stock movement, no serial rules.
                'product_id' => $item->is_custom ? null : $item->product_id,
                'unit_id' => $item->unit_id,
                'product_name' => $item->product_name,
                'product_sku' => $item->product_sku,
                'quantity' => (float) $item->quantity,
                'unit_price' => (float) $item->unit_price,
                'batch_id' => $item->is_custom ? null : $item->stock_batch_id,
                'vat_rate' => $this->vatRate($item->vat_rate),
                'discount_type' => $item->discount_type,
                'discount_value' => (float) $item->discount_value,
            ], fn ($value): bool => $value !== null))->all();

            $splits = $order->payments->map(fn (SalesOrderPayment $payment): array => array_filter([
                'method' => $payment->method,
                'amount' => (float) $payment->amount,
                'reference' => $payment->reference,
                'cheque_number' => $payment->cheque_number,
                'cheque_bank' => $payment->cheque_bank,
                'cheque_branch_code' => $payment->cheque_branch,
                'cheque_date' => $payment->cheque_date?->toDateString(),
                'credit_note_id' => $payment->credit_note_id,
                'custom_fields' => $payment->custom_fields,
            ], fn ($value): bool => $value !== null))->all();

            $invoice = $this->invoices->create(
                header: [
                    'channel' => 'sales_order',
                    'branch_id' => (int) $order->branch_id,
                    'customer_id' => (int) $order->customer_id,
                    'delivery_address_id' => $order->delivery_address_id,
                    'sales_rep_id' => $order->sales_rep_id,
                    // Line-level discounts travel on the lines themselves and
                    // are re-derived by InvoiceService; only the order-level
                    // discount is passed as a header amount, so nothing is
                    // deducted twice.
                    'discount' => round((float) $order->discount - (float) $order->items->sum('discount_amount'), 2),
                    'note' => $order->note,
                ],
                lines: $lines,
                splits: $splits,
                // Advances rarely cover the whole order — a shortfall is the
                // expected case here, not an error.
                confirmShortfall: true,
            );

            // InvoiceService reprices every catalog line from its lot as it
            // writes the invoice, so an order quoted months ago can invoice at
            // a different figure. Copy the invoice's real totals back onto the
            // order rather than leaving the two records contradicting each
            // other, and record the drift so the change is traceable.
            $quotedTotal = (float) $order->net_total;
            $invoicedTotal = (float) $invoice->net_total;

            $order->update([
                'converted_invoice_id' => $invoice->id,
                'converted_at' => now(),
                'status_id' => $this->statusId(Status::SO_INVOICED),
                'payment_status_id' => $invoice->payment_status_id,
                'subtotal' => $invoice->subtotal,
                'vat_total' => $invoice->vat_total,
                'discount' => $invoice->discount,
                'net_total' => $invoice->net_total,
                'advance_paid' => $invoice->paid_amount,
                'balance' => $invoice->balance,
            ]);

            AuditLog::record('sales_order.invoiced', $order, array_filter([
                'reference' => $order->order_number,
                'invoice' => $invoice->invoice_number,
                'total' => (string) $invoicedTotal,
                'repriced_from' => abs($quotedTotal - $invoicedTotal) > 0.001 ? (string) $quotedTotal : null,
            ], fn ($value): bool => $value !== null));

            return $invoice;
        });
    }

    private function assertOpen(SalesOrder $order, string $verb): void
    {
        if ($order->isConverted()) {
            throw new RuntimeException("Order {$order->order_number} has already been invoiced and cannot be {$verb}.");
        }

        if ($order->isCancelled()) {
            throw new RuntimeException("Order {$order->order_number} is cancelled and cannot be {$verb}.");
        }
    }

    /**
     * The narrower gate for accepting a payment: unlike assertOpen(), an
     * already-invoiced order is fine — settling the balance after conversion
     * is expected, not exceptional. Only a cancelled order refuses money.
     */
    private function assertAcceptsPayment(SalesOrder $order): void
    {
        if ($order->isCancelled()) {
            throw new RuntimeException("Order {$order->order_number} is cancelled and cannot accept a payment.");
        }
    }

    /**
     * The only thing that makes an order fully frozen is cancellation. A
     * converted order is still editable — update() resyncs its invoice — which
     * is the whole point of auto-invoicing: the order desk books the sale
     * immediately and can still fix it afterwards.
     */
    private function assertEditable(SalesOrder $order): void
    {
        if ($order->isCancelled()) {
            throw new RuntimeException("Order {$order->order_number} is cancelled and cannot be edited.");
        }
    }

    private function statusId(string $code): int
    {
        return Status::idOf(Status::CTX_SALES_ORDER, $code)
            ?? throw new RuntimeException("Unknown sales order status: {$code}");
    }
}