<?php

namespace App\Support;

use App\Models\CustomerOrder;
use App\Models\CustomerOrderItem;
use App\Models\FactoryProduct;
use App\Models\Sale;
use App\Models\SaleItem;
use App\Models\SalePack;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class SaleFulfillment
{
    /**
     * @param  list<array{
     *     factory_product_id: int,
     *     customer_order_item_id?: int|null,
     *     quantity: float|int|string,
     *     pack_weights?: list<float|int|string>,
     *     total_weight?: float|int|string,
     *     weight_price: float|int|string
     * }>  $items
     */
    public static function createSale(
        int $customerId,
        ?int $customerOrderId,
        string $soldAt,
        array $items,
        ?string $notes = null,
        float $discount = 0,
    ): Sale {
        if ($items === []) {
            throw new \InvalidArgumentException(__('At least one product is required for a sale.'));
        }

        $items = self::normalizeSaleItems($items);
        $discount = round(max(0, $discount), 2);
        $totalAmount = self::calculateItemsTotalAmount($items);
        $netAmount = self::calculateNetAmount($totalAmount, $discount);

        return DB::transaction(function () use ($customerId, $customerOrderId, $soldAt, $items, $notes, $discount, $totalAmount, $netAmount): Sale {
            self::validateItemsStock($items);

            $sale = Sale::query()->create([
                'sale_number' => self::generateSaleNumber($soldAt),
                'customer_id' => $customerId,
                'customer_order_id' => $customerOrderId,
                'type' => SaleType::SALE,
                'sold_at' => $soldAt,
                'total_amount' => $totalAmount,
                'discount' => $discount,
                'net_amount' => $netAmount,
                'notes' => $notes,
            ]);

            foreach ($items as $item) {
                self::storeItem($sale, $item, deductStock: true, adjustSoldQuantity: 1);
            }

            $sale = $sale->fresh(['items.product.unit', 'packs.product.unit', 'customer', 'order']);
            SaleDocument::captureInvoice($sale);

            return $sale;
        });
    }

    /**
     * @param  list<array{
     *     sale_pack_ids: list<int>,
     *     return_weight: float|int|string,
     *     return_condition?: string
     * }>  $returnLines
     */
    public static function returnPacks(Sale $sale, array $returnLines, ?string $notes = null): Sale
    {
        if ($sale->type !== SaleType::SALE) {
            throw new \InvalidArgumentException(__('Only product sales can be returned.'));
        }

        if ($returnLines === []) {
            throw new \InvalidArgumentException(__('Select at least one pack to return.'));
        }

        $allPackIdsRaw = collect($returnLines)
            ->flatMap(fn (array $line): array => array_map('intval', $line['sale_pack_ids'] ?? []))
            ->values();

        if ($allPackIdsRaw->count() !== $allPackIdsRaw->unique()->count()) {
            throw new \InvalidArgumentException(__('Each pack can only be returned once.'));
        }

        $allPackIds = $allPackIdsRaw->unique()->values()->all();

        if ($allPackIds === []) {
            throw new \InvalidArgumentException(__('Select at least one pack to return.'));
        }

        return DB::transaction(function () use ($sale, $returnLines, $allPackIds, $notes): Sale {
            $packs = SalePack::query()
                ->where('sale_id', $sale->id)
                ->whereIn('id', $allPackIds)
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            if ($packs->count() !== count($allPackIds)) {
                throw new \InvalidArgumentException(__('One or more packs could not be found on this sale.'));
            }

            foreach ($packs as $pack) {
                if ($pack->is_returned) {
                    throw new \InvalidArgumentException(__('One or more packs were already returned.'));
                }
            }

            self::validateReturnQuantitiesPerProductLine($sale, $packs);

            $sale->loadMissing('items.product');
            $itemsById = $sale->items->keyBy('id');
            $totalAmount = 0.0;

            foreach ($returnLines as $line) {
                $linePackIds = array_values(array_unique(array_map('intval', $line['sale_pack_ids'] ?? [])));

                if ($linePackIds === []) {
                    continue;
                }

                $returnWeight = round((float) ($line['return_weight'] ?? 0), 3);

                if ($returnWeight <= 0) {
                    throw new \InvalidArgumentException(__('Return weight must be greater than zero.'));
                }

                $linePacks = collect($linePackIds)
                    ->map(fn (int $id): SalePack => $packs->get($id))
                    ->filter()
                    ->sortBy('id')
                    ->values();

                $maxWeight = round($linePacks->sum(fn (SalePack $pack): float => (float) $pack->pack_weight), 3);

                if ($returnWeight > $maxWeight) {
                    throw new \InvalidArgumentException(__('Return weight cannot exceed the sold weight for the selected quantity.'));
                }

                $firstPack = $linePacks->first();
                $item = $firstPack?->sale_item_id !== null
                    ? $itemsById->get($firstPack->sale_item_id)
                    : null;
                $weightPrice = (float) ($item?->weight_price ?? 0);

                $returnCondition = SaleReturnCondition::normalize($line['return_condition'] ?? SaleReturnCondition::GOOD);

                if (
                    in_array($returnCondition, [SaleReturnCondition::DEFECTIVE, SaleReturnCondition::EXPIRED], true)
                    && array_key_exists('return_amount', $line)
                    && $line['return_amount'] !== null
                    && $line['return_amount'] !== ''
                ) {
                    $totalAmount += round((float) $line['return_amount'], 2);
                } else {
                    $mode = SaleLineQuantityMode::normalize($item?->quantity_mode);
                    $base = $mode === SaleLineQuantityMode::PACK
                        ? round($linePacks->count() * $weightPrice, 2)
                        : round($returnWeight * $weightPrice, 2);

                    $itemQuantity = (float) ($item?->quantity ?? 0);
                    $itemWeight = (float) ($item?->total_weight ?? 0);
                    $cottonCost = 0.0;

                    if ($item !== null) {
                        $fullCotton = self::calculateCottonCost([
                            'return_cotton' => $item->return_cotton,
                            'cotton_price' => $item->cotton_price,
                        ]);

                        if ($mode === SaleLineQuantityMode::PACK && $itemQuantity > 0.001) {
                            $cottonCost = round($fullCotton * ($linePacks->count() / $itemQuantity), 2);
                        } elseif ($mode === SaleLineQuantityMode::WEIGHT && $itemWeight > 0.001) {
                            $cottonCost = round($fullCotton * ($returnWeight / $itemWeight), 2);
                        }
                    }

                    $totalAmount += max(0, $base - $cottonCost);
                }
            }

            $totalAmount = round($totalAmount, 2);

            $returnSale = Sale::query()->create([
                'sale_number' => self::generateSaleNumber(),
                'customer_id' => $sale->customer_id,
                'customer_order_id' => $sale->customer_order_id,
                'type' => SaleType::RETURN,
                'related_sale_id' => $sale->id,
                'sold_at' => now(),
                'total_amount' => $totalAmount,
                'discount' => 0,
                'net_amount' => $totalAmount,
                'notes' => $notes,
            ]);

            foreach ($returnLines as $line) {
                $linePackIds = array_values(array_unique(array_map('intval', $line['sale_pack_ids'] ?? [])));

                if ($linePackIds === []) {
                    continue;
                }

                $returnWeight = round((float) ($line['return_weight'] ?? 0), 3);
                $linePacks = collect($linePackIds)
                    ->map(fn (int $id): SalePack => $packs->get($id))
                    ->filter()
                    ->sortBy('id')
                    ->values();

                $returnCondition = SaleReturnCondition::normalize($line['return_condition'] ?? SaleReturnCondition::GOOD);

                $distributedWeights = self::distributeReturnWeight($linePacks, $returnWeight);

                foreach ($linePacks->values() as $index => $pack) {
                    $returnPack = SalePack::query()->create([
                        'sale_id' => $returnSale->id,
                        'factory_product_id' => $pack->factory_product_id,
                        'customer_order_item_id' => $pack->customer_order_item_id,
                        'pack_weight' => $distributedWeights[$index] ?? $pack->pack_weight,
                        'original_sale_pack_id' => $pack->id,
                        'return_condition' => $returnCondition,
                    ]);

                    $pack->is_returned = true;
                    $pack->save();

                    self::recordReturnStockAdjustment($pack, $returnPack, $returnCondition);

                    if ($pack->customer_order_item_id !== null) {
                        self::adjustSoldQuantity((int) $pack->customer_order_item_id, -1);
                    }
                }
            }

            self::recalculateOriginalSaleFromActivePacks($sale);

            $returnSale = $returnSale->fresh(['packs.product.unit', 'customer', 'order', 'relatedSale']);
            SaleDocument::captureCreditNote($returnSale);

            return $returnSale;
        });
    }

    /**
     * @param  Collection<int, SalePack>  $selectedPacks
     */
    private static function validateReturnQuantitiesPerProductLine(Sale $sale, Collection $selectedPacks): void
    {
        $returnablePacks = SalePack::query()
            ->where('sale_id', $sale->id)
            ->where('is_returned', false)
            ->whereNull('original_sale_pack_id')
            ->get();

        $returnableIds = $returnablePacks->pluck('id')->map(fn ($id): int => (int) $id)->all();

        foreach ($selectedPacks as $pack) {
            if (! in_array((int) $pack->id, $returnableIds, true)) {
                throw new \InvalidArgumentException(__('One or more packs were already returned.'));
            }
        }

        $returnableByLine = $returnablePacks
            ->groupBy(fn (SalePack $pack): string => self::productLineKeyForPack($pack))
            ->map(fn (Collection $group): int => $group->count());

        $requestedByLine = $selectedPacks
            ->groupBy(fn (SalePack $pack): string => self::productLineKeyForPack($pack))
            ->map(fn (Collection $group): int => $group->count());

        foreach ($requestedByLine as $lineKey => $requestedCount) {
            $availableCount = (int) ($returnableByLine->get($lineKey, 0));

            if ($requestedCount > $availableCount) {
                throw new \InvalidArgumentException(__('Return quantity cannot exceed the sold quantity for a product.'));
            }
        }
    }

    private static function productLineKeyForPack(SalePack $pack): string
    {
        if ($pack->sale_item_id) {
            return 'item:'.$pack->sale_item_id;
        }

        return 'product:'.$pack->factory_product_id;
    }

    /**
     * @param  Collection<int, SalePack>  $returnedPacks
     */
    private static function resolveOrderItemIdForExchangeReplacement(
        Sale $sale,
        int $factoryProductId,
        Collection $returnedPacks,
    ): ?int {
        $fromReturned = $returnedPacks
            ->first(fn (SalePack $pack): bool => (int) $pack->factory_product_id === $factoryProductId
                && $pack->customer_order_item_id !== null);

        if ($fromReturned !== null) {
            return (int) $fromReturned->customer_order_item_id;
        }

        if ($sale->customer_order_id === null) {
            return null;
        }

        $orderItem = CustomerOrderItem::query()
            ->where('customer_order_id', $sale->customer_order_id)
            ->where('factory_product_id', $factoryProductId)
            ->whereNot('status', CustomerOrderItemStatus::CANCELLED)
            ->orderBy('id')
            ->first();

        return $orderItem?->id;
    }

    private static function mirrorExchangeSaleItem(
        Sale $exchangeSale,
        array $line,
        ?int $orderItemId,
    ): void {
        $resolved = self::resolvePackWeights($line);

        SaleItem::query()->create([
            'sale_id' => $exchangeSale->id,
            'factory_product_id' => (int) $line['factory_product_id'],
            'customer_order_item_id' => $orderItemId,
            'quantity' => $resolved['pack_count'],
            'total_weight' => $resolved['total_weight'],
            'quantity_mode' => SaleLineQuantityMode::normalize($line['quantity_mode'] ?? null),
            'weight_price' => round((float) $line['weight_price'], 2),
            'return_cotton' => round(max(0, (float) ($line['return_cotton'] ?? 0)), 3),
            'cotton_price' => round(max(0, (float) ($line['cotton_price'] ?? 0)), 2),
        ]);
    }

    /**
     * @param  Collection<int, SalePack>  $returnedPacks
     * @param  array{
     *     factory_product_id: int,
     *     quantity: int,
     *     total_weight: float|int|string,
     *     weight_price: float|int|string
     * }  $line
     */
    private static function storeExchangeReplacementItem(
        Sale $sale,
        array $line,
        Collection $returnedPacks,
        ?int $orderItemId,
        bool $deductStock = true,
        bool $adjustSoldQuantity = true,
    ): SaleItem {
        return self::storeItem($sale, [
            'factory_product_id' => (int) $line['factory_product_id'],
            'customer_order_item_id' => $orderItemId,
            'quantity_mode' => $line['quantity_mode'] ?? SaleLineQuantityMode::PACK,
            'quantity' => (int) $line['quantity'],
            'total_weight' => $line['total_weight'],
            'weight_price' => $line['weight_price'],
            'return_cotton' => $line['return_cotton'] ?? 0,
            'cotton_price' => $line['cotton_price'] ?? 0,
        ], deductStock: $deductStock, adjustSoldQuantity: $adjustSoldQuantity && $orderItemId !== null ? 1 : 0);
    }

    private const EXCHANGE_REPLACEMENTS_SYNCED_MARKER = '[replacements-synced]';

    public static function exchangeReplacementsSynced(Sale $exchangeSale): bool
    {
        return str_contains($exchangeSale->notes ?? '', self::EXCHANGE_REPLACEMENTS_SYNCED_MARKER);
    }

    /**
     * Backfill replacement packs onto the original sale for exchanges recorded before
     * replacements were stored on the original bill.
     */
    public static function syncExchangeReplacementsToOriginalSale(Sale $exchangeSale, bool $dryRun = false): int
    {
        if ($exchangeSale->type !== SaleType::EXCHANGE || $exchangeSale->related_sale_id === null) {
            return 0;
        }

        if (self::exchangeReplacementsSynced($exchangeSale)) {
            return 0;
        }

        return DB::transaction(function () use ($exchangeSale, $dryRun): int {
            $exchangeSale = Sale::query()->whereKey($exchangeSale->id)->lockForUpdate()->firstOrFail();
            $originalSale = Sale::query()->whereKey($exchangeSale->related_sale_id)->lockForUpdate()->firstOrFail();

            if ($originalSale->type !== SaleType::SALE) {
                return 0;
            }

            $exchangeSale->loadMissing('items');
            $originalSale->loadMissing(['items', 'packs']);

            if ($exchangeSale->items->isEmpty()) {
                return 0;
            }

            $returnedOriginalPacks = collect();

            if ($exchangeSale->exchange_group_id !== null) {
                $returnSale = Sale::query()
                    ->where('exchange_group_id', $exchangeSale->exchange_group_id)
                    ->where('type', SaleType::RETURN)
                    ->with('packs')
                    ->first();

                if ($returnSale !== null) {
                    $originalPackIds = $returnSale->packs
                        ->pluck('original_sale_pack_id')
                        ->filter()
                        ->map(fn ($id): int => (int) $id)
                        ->values()
                        ->all();

                    if ($originalPackIds !== []) {
                        $returnedOriginalPacks = SalePack::query()
                            ->where('sale_id', $originalSale->id)
                            ->whereIn('id', $originalPackIds)
                            ->get();
                    }
                }
            }

            $added = 0;

            foreach (SalePack::query()->where('sale_id', $exchangeSale->id)->lockForUpdate()->get() as $pack) {
                if (! $dryRun) {
                    $pack->update(['sale_id' => $originalSale->id]);
                }

                $added++;
            }

            if (! $dryRun) {
                self::linkOrphanPacksToSaleItems($originalSale);
                $originalSale->load(['items', 'packs']);
            }

            foreach ($exchangeSale->items as $item) {
                $expectedQty = (int) round((float) $item->quantity);

                if ($expectedQty < 1) {
                    continue;
                }

                $productId = (int) $item->factory_product_id;
                $exchangeMoment = $exchangeSale->sold_at ?? $exchangeSale->created_at;

                $alreadyAddedForExchange = $originalSale->packs
                    ->filter(function (SalePack $pack) use ($productId, $exchangeMoment): bool {
                        if ((int) $pack->factory_product_id !== $productId) {
                            return false;
                        }

                        if ($pack->is_returned || $pack->original_sale_pack_id !== null) {
                            return false;
                        }

                        if ($exchangeMoment === null || $pack->created_at === null) {
                            return false;
                        }

                        return $pack->created_at->between(
                            $exchangeMoment->copy()->subSeconds(30),
                            $exchangeMoment->copy()->addMinutes(5),
                        );
                    })
                    ->count();

                $missing = $expectedQty - $alreadyAddedForExchange;

                if ($missing < 1) {
                    continue;
                }

                $line = [
                    'factory_product_id' => $productId,
                    'quantity' => $missing,
                    'total_weight' => self::prorateExchangeLineWeight($item, $missing, $expectedQty),
                    'weight_price' => $item->weight_price,
                ];

                $orderItemId = $item->customer_order_item_id !== null
                    ? (int) $item->customer_order_item_id
                    : null;

                if (! $dryRun) {
                    self::storeExchangeReplacementItem(
                        $originalSale,
                        $line,
                        $returnedOriginalPacks,
                        $orderItemId,
                        deductStock: false,
                        adjustSoldQuantity: false,
                    );
                    $originalSale->load('packs');
                }

                $added += $missing;
            }

            if ($added === 0) {
                if (! $dryRun) {
                    self::markExchangeReplacementsSynced($exchangeSale);
                }

                return 0;
            }

            if ($dryRun) {
                return $added;
            }

            self::recalculateOriginalSaleFromActivePacks($originalSale);
            self::syncSoldQuantityFromActivePacks($originalSale);
            $originalSale = $originalSale->fresh(['items.product.unit', 'packs.product.unit', 'customer', 'order']);
            SaleDocument::captureInvoice($originalSale);
            self::markExchangeReplacementsSynced($exchangeSale);

            return $added;
        });
    }

    private static function markExchangeReplacementsSynced(Sale $exchangeSale): void
    {
        if (self::exchangeReplacementsSynced($exchangeSale)) {
            return;
        }

        $notes = trim(($exchangeSale->notes ?? '').' '.self::EXCHANGE_REPLACEMENTS_SYNCED_MARKER);

        $exchangeSale->update(['notes' => $notes]);
    }

    private static function prorateExchangeLineWeight(SaleItem $item, int $missing, int $expectedQty): float
    {
        $totalWeight = round((float) $item->total_weight, 3);

        if ($expectedQty <= 0 || $missing >= $expectedQty) {
            return $totalWeight;
        }

        return round($totalWeight * ($missing / $expectedQty), 3);
    }

    public static function syncSoldQuantityFromActivePacks(Sale $sale): void
    {
        if ($sale->customer_order_id === null) {
            return;
        }

        $sale->loadMissing(['packs']);

        $orderItemIds = $sale->packs
            ->pluck('customer_order_item_id')
            ->filter()
            ->map(fn ($id): int => (int) $id)
            ->unique()
            ->values();

        foreach ($orderItemIds as $orderItemId) {
            $count = $sale->packs
                ->filter(fn (SalePack $pack): bool => (int) $pack->customer_order_item_id === $orderItemId
                    && ! $pack->is_returned
                    && $pack->original_sale_pack_id === null)
                ->count();

            CustomerOrderItem::query()
                ->whereKey($orderItemId)
                ->update(['sold_quantity' => $count]);
        }
    }

    /**
     * @param  Collection<int, SalePack>  $packs
     * @param  list<array{sale_pack_ids?: list<int>}>  $returnLines
     * @return array<int, int>
     */
    private static function incomingQuantityByProductFromReturnLines(Collection $packs, array $returnLines): array
    {
        $incomingByProduct = [];

        foreach ($returnLines as $line) {
            foreach (array_unique(array_map('intval', $line['sale_pack_ids'] ?? [])) as $packId) {
                $pack = $packs->get($packId);

                if ($pack === null) {
                    continue;
                }

                $productId = (int) $pack->factory_product_id;
                $incomingByProduct[$productId] = ($incomingByProduct[$productId] ?? 0) + 1;
            }
        }

        return $incomingByProduct;
    }

    private static function recordReturnStockAdjustment(
        SalePack $originalPack,
        SalePack $returnPack,
        string $returnCondition,
    ): void {
        $movement = [
            'reference_type' => SalePack::class,
            'reference_id' => $returnPack->id,
        ];

        match ($returnCondition) {
            SaleReturnCondition::DEFECTIVE => NonSaleableFinishedGoodsStock::adjustDefective(
                (int) $originalPack->factory_product_id,
                1,
                $movement,
            ),
            SaleReturnCondition::EXPIRED => NonSaleableFinishedGoodsStock::adjustExpired(
                (int) $originalPack->factory_product_id,
                1,
                $movement,
            ),
            default => FinishedGoodsStock::adjust((int) $originalPack->factory_product_id, 1, [
                ...$movement,
                'type' => StockMovementType::SALE_RETURN_IN,
            ]),
        };
    }

    /**
     * @param  Collection<int, SalePack>  $packs
     * @return list<float>
     */
    private static function distributeReturnWeight(Collection $packs, float $returnWeight): array
    {
        $returnWeight = round($returnWeight, 3);

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

        $originalSum = round($packs->sum(fn (SalePack $pack): float => (float) $pack->pack_weight), 3);

        if ($originalSum <= 0) {
            $even = round($returnWeight / $packs->count(), 3);

            return array_fill(0, $packs->count(), $even);
        }

        $weights = [];
        $assigned = 0.0;
        $lastIndex = $packs->count() - 1;

        foreach ($packs->values() as $index => $pack) {
            if ($index === $lastIndex) {
                $weights[] = round($returnWeight - $assigned, 3);

                continue;
            }

            $share = round($returnWeight * ((float) $pack->pack_weight / $originalSum), 3);
            $weights[] = $share;
            $assigned += $share;
        }

        return $weights;
    }

    public static function recalculateOriginalSaleFromActivePacks(Sale $sale): void
    {
        $sale = Sale::query()->whereKey($sale->id)->lockForUpdate()->firstOrFail();

        if ($sale->type !== SaleType::SALE) {
            return;
        }

        $sale->load(['items.packs', 'packs']);

        self::linkOrphanPacksToSaleItems($sale);

        $sale->load(['items.packs', 'packs']);

        $totalAmount = 0.0;

        if ($sale->items->isNotEmpty()) {
            foreach ($sale->items as $item) {
                $activePacks = $item->packs
                    ->filter(fn (SalePack $pack): bool => ! $pack->is_returned && $pack->original_sale_pack_id === null)
                    ->sortBy('id')
                    ->values();

                $quantity = $activePacks->count();
                $totalWeight = round(
                    $activePacks->sum(fn (SalePack $pack): float => (float) $pack->pack_weight),
                    3,
                );
                $previousQuantity = (float) $item->quantity;

                if ($previousQuantity > 0.001 && $quantity < $previousQuantity) {
                    $ratio = $quantity / $previousQuantity;
                    $item->return_cotton = round((float) $item->return_cotton * $ratio, 3);
                }

                $item->quantity = $quantity;
                $item->total_weight = $totalWeight;
                $item->save();

                $totalAmount += self::calculateLineTotal([
                    'quantity_mode' => $item->quantity_mode,
                    'quantity' => $quantity,
                    'total_weight' => $totalWeight,
                    'weight_price' => $item->weight_price,
                    'return_cotton' => $item->return_cotton,
                    'cotton_price' => $item->cotton_price,
                ]);
            }

            $orphanPacks = $sale->packs
                ->filter(fn (SalePack $pack): bool => ! $pack->is_returned
                    && $pack->original_sale_pack_id === null
                    && ($pack->sale_item_id === null || ! $sale->items->contains('id', $pack->sale_item_id)));

            foreach (self::productLinesFromPacks($orphanPacks) as $line) {
                $weightPrice = self::resolveWeightPriceForOrphanPacks($sale, $line);

                $totalAmount += round((float) $line['total_weight'] * $weightPrice, 2);
            }
        } else {
            $activePacks = $sale->packs
                ->filter(fn (SalePack $pack): bool => ! $pack->is_returned && $pack->original_sale_pack_id === null);

            foreach (self::productLinesFromPacks($activePacks) as $line) {
                $totalAmount += round((float) $line['total_weight'] * (float) ($line['weight_price'] ?? 0), 2);
            }
        }

        $totalAmount = round($totalAmount, 2);
        $discount = round(max(0, (float) $sale->discount), 2);

        if ($discount > $totalAmount + 0.0001) {
            $discount = $totalAmount;
        }

        $sale->total_amount = $totalAmount;
        $sale->discount = $discount;
        $sale->net_amount = self::calculateNetAmount($totalAmount, $discount);
        $sale->save();
    }

    private static function linkOrphanPacksToSaleItems(Sale $sale): void
    {
        $orphanPacks = $sale->packs->filter(
            fn (SalePack $pack): bool => ! $pack->is_returned
                && $pack->original_sale_pack_id === null
                && ($pack->sale_item_id === null || ! $sale->items->contains('id', $pack->sale_item_id)),
        );

        foreach ($orphanPacks as $pack) {
            $item = $sale->items
                ->filter(fn (SaleItem $item): bool => (int) $item->factory_product_id === (int) $pack->factory_product_id)
                ->sortByDesc('id')
                ->first();

            if ($item === null) {
                continue;
            }

            $pack->sale_item_id = $item->id;
            $pack->customer_order_item_id = $pack->customer_order_item_id ?? $item->customer_order_item_id;
            $pack->save();
        }
    }

    /**
     * @param  array{factory_product_id?: int|null, total_weight?: string|null}  $line
     */
    private static function resolveWeightPriceForOrphanPacks(Sale $sale, array $line): float
    {
        $factoryProductId = (int) ($line['factory_product_id'] ?? 0);

        $fromItem = $sale->items
            ->first(fn (SaleItem $item): bool => (int) $item->factory_product_id === $factoryProductId);

        if ($fromItem !== null) {
            return (float) $fromItem->weight_price;
        }

        return 0.0;
    }

    /**
     * @param  list<array{
     *     sale_pack_ids: list<int>,
     *     return_weight: float|int|string,
     *     weight_price: float|int|string
     * }>  $returnLines
     * @param  list<array{
     *     factory_product_id: int,
     *     quantity: int,
     *     total_weight: float|int|string,
     *     weight_price: float|int|string
     * }>  $replacementLines
     */
    public static function exchangePacks(
        Sale $sale,
        array $returnLines,
        array $replacementLines,
        ?string $notes = null,
    ): Sale {
        if ($sale->type !== SaleType::SALE) {
            throw new \InvalidArgumentException(__('Only product sales can be exchanged.'));
        }

        if ($returnLines === []) {
            throw new \InvalidArgumentException(__('Select at least one product to exchange.'));
        }

        if ($replacementLines === []) {
            throw new \InvalidArgumentException(__('Add at least one replacement product.'));
        }

        $allPackIdsRaw = collect($returnLines)
            ->flatMap(fn (array $line): array => array_map('intval', $line['sale_pack_ids'] ?? []))
            ->values();

        if ($allPackIdsRaw->count() !== $allPackIdsRaw->unique()->count()) {
            throw new \InvalidArgumentException(__('Each pack can only be exchanged once.'));
        }

        $allPackIds = $allPackIdsRaw->unique()->values()->all();

        if ($allPackIds === []) {
            throw new \InvalidArgumentException(__('Select at least one product to exchange.'));
        }

        return DB::transaction(function () use ($sale, $returnLines, $replacementLines, $allPackIds, $notes): Sale {
            $replacementLines = self::normalizeSaleItems($replacementLines);
            $packs = SalePack::query()
                ->where('sale_id', $sale->id)
                ->whereIn('id', $allPackIds)
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            if ($packs->count() !== count($allPackIds)) {
                throw new \InvalidArgumentException(__('One or more packs could not be found on this sale.'));
            }

            foreach ($packs as $pack) {
                if ($pack->is_returned) {
                    throw new \InvalidArgumentException(__('One or more packs were already returned or exchanged.'));
                }
            }

            self::validateReturnQuantitiesPerProductLine($sale, $packs);

            $sale->loadMissing('items.product');
            $itemsById = $sale->items->keyBy('id');
            $returnTotalAmount = 0.0;
            $neededByProduct = [];

            foreach ($replacementLines as $line) {
                $factoryProductId = (int) ($line['factory_product_id'] ?? 0);
                $quantity = (int) ($line['quantity'] ?? 0);
                $totalWeight = round((float) ($line['total_weight'] ?? 0), 3);
                $weightPrice = round((float) ($line['weight_price'] ?? 0), 2);

                if ($factoryProductId < 1 || $quantity < 1) {
                    throw new \InvalidArgumentException(__('Add at least one replacement product.'));
                }

                if ($totalWeight <= 0) {
                    throw new \InvalidArgumentException(__('Total weight must be greater than zero.'));
                }

                if ($weightPrice < 0) {
                    throw new \InvalidArgumentException(__('Weight price must be zero or greater.'));
                }

                FactoryProduct::query()->findOrFail($factoryProductId);
                $neededByProduct[$factoryProductId] = ($neededByProduct[$factoryProductId] ?? 0) + $quantity;
            }

            $exchangeTotalAmount = self::calculateItemsTotalAmount($replacementLines);
            $incomingByProduct = self::incomingQuantityByProductFromReturnLines($packs, $returnLines);

            foreach ($neededByProduct as $factoryProductId => $needed) {
                $replacementProduct = FactoryProduct::query()->findOrFail($factoryProductId);
                $incoming = (int) ($incomingByProduct[$factoryProductId] ?? 0);
                $availableStock = (int) floor(FinishedGoodsStock::available($factoryProductId) + 0.0001) + $incoming;

                if ($availableStock < $needed) {
                    throw new \InvalidArgumentException(__('Quantity for :product cannot exceed stock on hand (:available).', [
                        'product' => $replacementProduct->name,
                        'available' => $availableStock,
                    ]));
                }
            }

            foreach ($returnLines as $line) {
                $linePackIds = array_values(array_unique(array_map('intval', $line['sale_pack_ids'] ?? [])));

                if ($linePackIds === []) {
                    continue;
                }

                $linePacks = collect($linePackIds)
                    ->map(fn (int $id): SalePack => $packs->get($id))
                    ->filter()
                    ->sortBy('id')
                    ->values();

                $returnWeight = round((float) ($line['return_weight'] ?? 0), 3);

                if ($returnWeight <= 0) {
                    throw new \InvalidArgumentException(__('Return weight must be greater than zero.'));
                }

                $maxReturnWeight = round($linePacks->sum(fn (SalePack $pack): float => (float) $pack->pack_weight), 3);

                if ($returnWeight > $maxReturnWeight) {
                    throw new \InvalidArgumentException(__('Return weight cannot exceed the sold weight for the selected quantity.'));
                }

                $firstPack = $linePacks->first();
                $item = $firstPack?->sale_item_id !== null
                    ? $itemsById->get($firstPack->sale_item_id)
                    : null;
                $weightPrice = array_key_exists('weight_price', $line)
                    ? round((float) $line['weight_price'], 2)
                    : (float) ($item?->weight_price ?? 0);

                if ($weightPrice < 0) {
                    throw new \InvalidArgumentException(__('Weight price must be zero or greater.'));
                }

                $mode = SaleLineQuantityMode::normalize($item?->quantity_mode);
                $base = $mode === SaleLineQuantityMode::PACK
                    ? round($linePacks->count() * $weightPrice, 2)
                    : round($returnWeight * $weightPrice, 2);

                $itemQuantity = (float) ($item?->quantity ?? 0);
                $itemWeight = (float) ($item?->total_weight ?? 0);
                $cottonCost = 0.0;

                if ($item !== null) {
                    $fullCotton = self::calculateCottonCost([
                        'return_cotton' => $item->return_cotton,
                        'cotton_price' => $item->cotton_price,
                    ]);

                    if ($mode === SaleLineQuantityMode::PACK && $itemQuantity > 0.001) {
                        $cottonCost = round($fullCotton * ($linePacks->count() / $itemQuantity), 2);
                    } elseif ($mode === SaleLineQuantityMode::WEIGHT && $itemWeight > 0.001) {
                        $cottonCost = round($fullCotton * ($returnWeight / $itemWeight), 2);
                    }
                }

                $returnTotalAmount += max(0, $base - $cottonCost);
            }

            $returnTotalAmount = round($returnTotalAmount, 2);
            $exchangeBalance = round($exchangeTotalAmount - $returnTotalAmount, 2);

            $exchangeGroupId = (string) Str::uuid();

            $returnSale = Sale::query()->create([
                'sale_number' => self::generateSaleNumber(),
                'customer_id' => $sale->customer_id,
                'customer_order_id' => $sale->customer_order_id,
                'type' => SaleType::RETURN,
                'related_sale_id' => $sale->id,
                'exchange_group_id' => $exchangeGroupId,
                'sold_at' => now(),
                'total_amount' => $returnTotalAmount,
                'discount' => 0,
                'net_amount' => $returnTotalAmount,
                'notes' => $notes,
            ]);

            foreach ($returnLines as $line) {
                $linePackIds = array_values(array_unique(array_map('intval', $line['sale_pack_ids'] ?? [])));

                if ($linePackIds === []) {
                    continue;
                }

                $returnWeight = round((float) ($line['return_weight'] ?? 0), 3);
                $linePacks = collect($linePackIds)
                    ->map(fn (int $id): SalePack => $packs->get($id))
                    ->filter()
                    ->sortBy('id')
                    ->values();
                $distributedReturnWeights = self::distributeReturnWeight($linePacks, $returnWeight);

                foreach ($linePacks->values() as $index => $originalPack) {
                    $returnPack = SalePack::query()->create([
                        'sale_id' => $returnSale->id,
                        'factory_product_id' => $originalPack->factory_product_id,
                        'customer_order_item_id' => $originalPack->customer_order_item_id,
                        'pack_weight' => $distributedReturnWeights[$index] ?? $originalPack->pack_weight,
                        'original_sale_pack_id' => $originalPack->id,
                    ]);

                    $originalPack->is_returned = true;
                    $originalPack->save();

                    FinishedGoodsStock::adjust((int) $originalPack->factory_product_id, 1, [
                        'type' => StockMovementType::SALE_RETURN_IN,
                        'reference_type' => SalePack::class,
                        'reference_id' => $returnPack->id,
                    ]);

                    if ($originalPack->customer_order_item_id !== null) {
                        self::adjustSoldQuantity((int) $originalPack->customer_order_item_id, -1);
                    }
                }
            }

            $exchangeSale = Sale::query()->create([
                'sale_number' => self::generateSaleNumber(),
                'customer_id' => $sale->customer_id,
                'customer_order_id' => $sale->customer_order_id,
                'type' => SaleType::EXCHANGE,
                'related_sale_id' => $sale->id,
                'exchange_group_id' => $exchangeGroupId,
                'sold_at' => now(),
                'total_amount' => max(0, round($exchangeTotalAmount - $returnTotalAmount, 2)),
                'discount' => 0,
                'net_amount' => max(0, $exchangeBalance),
                'notes' => $notes,
            ]);

            /** @var Collection<int, SalePack> $replacementPacks */
            $replacementPacks = collect();

            foreach ($replacementLines as $line) {
                $factoryProductId = (int) $line['factory_product_id'];
                $orderItemId = self::resolveOrderItemIdForExchangeReplacement(
                    $sale,
                    $factoryProductId,
                    $packs,
                );

                $saleItem = self::storeExchangeReplacementItem(
                    $sale,
                    $line,
                    $packs,
                    $orderItemId,
                );

                self::mirrorExchangeSaleItem($exchangeSale, $line, $orderItemId);
                $replacementPacks = $replacementPacks->merge(
                    $saleItem->packs()->with('product.unit:id,name')->get(),
                );
            }

            self::recalculateOriginalSaleFromActivePacks($sale);

            $sale = $sale->fresh(['items.product.unit', 'packs.product.unit', 'customer', 'order']);
            SaleDocument::captureInvoice($sale);

            $exchangeSale = $exchangeSale->fresh(['items.product.unit', 'packs.product.unit', 'customer', 'order', 'relatedSale']);
            SaleDocument::captureExchangeMemo($returnSale, $exchangeSale, $sale, $replacementPacks);
            self::markExchangeReplacementsSynced($exchangeSale);

            return $exchangeSale;
        });
    }

    /**
     * @param  array{
     *     factory_product_id?: int,
     *     return_weight?: float|int|string,
     *     pack_weight: float|int|string
     * }  $replacement
     */
    public static function exchangePack(SalePack $originalPack, array $replacement, ?string $notes = null): Sale
    {
        $originalPack->loadMissing('sale');

        if ($originalPack->sale === null) {
            throw new \InvalidArgumentException(__('Only sold packs can be exchanged.'));
        }

        return self::exchangePacks(
            $originalPack->sale,
            [[
                'sale_pack_ids' => [(int) $originalPack->id],
                'return_weight' => $replacement['return_weight'] ?? $originalPack->pack_weight,
                'weight_price' => $replacement['weight_price'] ?? 0,
            ]],
            [[
                'factory_product_id' => (int) ($replacement['factory_product_id'] ?? $originalPack->factory_product_id),
                'quantity' => 1,
                'total_weight' => $replacement['pack_weight'],
                'weight_price' => $replacement['weight_price'] ?? 0,
            ]],
            $notes,
        );
    }

    /**
     * @param  array{
     *     quantity: float|int|string,
     *     pack_weights?: list<float|int|string>,
     *     total_weight?: float|int|string
     * }  $item
     * @return array{weights: list<float>, total_weight: float, pack_count: int}
     */
    private static function resolvePackWeights(array $item): array
    {
        $packCount = (int) round((float) $item['quantity']);

        if ($packCount < 1) {
            throw new \InvalidArgumentException(__('Quantity must be at least one.'));
        }

        if (abs((float) $item['quantity'] - $packCount) > 0.001) {
            throw new \InvalidArgumentException(__('Quantity must be a whole number of packs.'));
        }

        if (isset($item['pack_weights']) && is_array($item['pack_weights'])) {
            if (count($item['pack_weights']) !== $packCount) {
                throw new \InvalidArgumentException(__('Enter a weight for each pack.'));
            }

            $weights = [];

            foreach ($item['pack_weights'] as $weight) {
                $normalized = round((float) $weight, 3);

                if ($normalized <= 0) {
                    throw new \InvalidArgumentException(__('Pack weight must be greater than zero.'));
                }

                $weights[] = $normalized;
            }

            return [
                'weights' => $weights,
                'total_weight' => round(array_sum($weights), 3),
                'pack_count' => $packCount,
            ];
        }

        if (! isset($item['total_weight'])) {
            throw new \InvalidArgumentException(__('Total weight must be greater than zero.'));
        }

        $totalWeight = round((float) $item['total_weight'], 3);

        if ($totalWeight <= 0) {
            throw new \InvalidArgumentException(__('Total weight must be greater than zero.'));
        }

        $packWeight = round($totalWeight / $packCount, 3);

        return [
            'weights' => array_fill(0, $packCount, $packWeight),
            'total_weight' => $totalWeight,
            'pack_count' => $packCount,
        ];
    }

    /**
     * @param  list<array<string, mixed>>  $items
     * @return list<array<string, mixed>>
     */
    private static function normalizeSaleItems(array $items): array
    {
        return array_map(
            fn (array $item): array => self::normalizeSaleLineItem($item),
            $items,
        );
    }

    /**
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private static function normalizeSaleLineItem(array $item): array
    {
        $mode = SaleLineQuantityMode::normalize($item['quantity_mode'] ?? null);
        $item['quantity_mode'] = $mode;
        $item['return_cotton'] = round(max(0, (float) ($item['return_cotton'] ?? 0)), 3);
        $item['cotton_price'] = round(max(0, (float) ($item['cotton_price'] ?? 0)), 2);

        return $item;
    }

    /**
     * @param  list<array{
     *     factory_product_id: int,
     *     quantity: float|int|string
     * }>  $items
     */
    private static function validateItemsStock(array $items): void
    {
        $neededByProduct = [];

        foreach ($items as $item) {
            $productId = (int) $item['factory_product_id'];
            $packCount = (int) round((float) $item['quantity']);

            if ($packCount < 1) {
                continue;
            }

            $neededByProduct[$productId] = ($neededByProduct[$productId] ?? 0) + $packCount;
        }

        foreach ($neededByProduct as $productId => $needed) {
            $product = FactoryProduct::query()
                ->whereKey($productId)
                ->lockForUpdate()
                ->firstOrFail();

            $available = (int) floor((float) $product->stock_on_hand + 0.0001);

            if ($needed > $available) {
                throw new \InvalidArgumentException(__('Quantity for :product cannot exceed stock on hand (:available).', [
                    'product' => $product->name,
                    'available' => $available,
                ]));
            }
        }
    }

    /**
     * @param  array{
     *     factory_product_id: int,
     *     customer_order_item_id?: int|null,
     *     quantity: float|int|string,
     *     pack_weights?: list<float|int|string>,
     *     total_weight?: float|int|string,
     *     weight_price: float|int|string
     * }  $item
     */
    private static function storeItem(
        Sale $sale,
        array $item,
        bool $deductStock,
        int $adjustSoldQuantity,
    ): SaleItem {
        $factoryProductId = (int) $item['factory_product_id'];
        $item = self::normalizeSaleLineItem($item);
        $resolved = self::resolvePackWeights($item);
        $quantity = round((float) $item['quantity'], 3);
        $totalWeight = $resolved['total_weight'];
        $weightPrice = round((float) $item['weight_price'], 2);
        $returnCotton = round(max(0, (float) ($item['return_cotton'] ?? 0)), 3);
        $cottonPrice = round(max(0, (float) ($item['cotton_price'] ?? 0)), 2);
        $orderItemId = isset($item['customer_order_item_id'])
            ? (int) $item['customer_order_item_id']
            : null;
        $packCount = $resolved['pack_count'];

        if ($weightPrice < 0) {
            throw new \InvalidArgumentException(__('Weight price cannot be negative.'));
        }

        FactoryProduct::query()->findOrFail($factoryProductId);

        if ($orderItemId !== null) {
            self::validateOrderItemCanSell($orderItemId, $packCount);
        }

        $saleItem = SaleItem::query()->create([
            'sale_id' => $sale->id,
            'factory_product_id' => $factoryProductId,
            'customer_order_item_id' => $orderItemId,
            'quantity_mode' => SaleLineQuantityMode::normalize($item['quantity_mode'] ?? null),
            'quantity' => $quantity,
            'total_weight' => $totalWeight,
            'weight_price' => $weightPrice,
            'return_cotton' => $returnCotton,
            'cotton_price' => $cottonPrice,
        ]);

        foreach ($resolved['weights'] as $packWeight) {
            $salePack = SalePack::query()->create([
                'sale_id' => $sale->id,
                'sale_item_id' => $saleItem->id,
                'factory_product_id' => $factoryProductId,
                'customer_order_item_id' => $orderItemId,
                'pack_weight' => $packWeight,
            ]);

            if ($deductStock) {
                FinishedGoodsStock::adjust($factoryProductId, -1, [
                    'type' => StockMovementType::SALE_OUT,
                    'reference_type' => SalePack::class,
                    'reference_id' => $salePack->id,
                ]);
            }
        }

        if ($orderItemId !== null) {
            self::adjustSoldQuantity($orderItemId, $packCount * $adjustSoldQuantity);
        }

        return $saleItem;
    }

    /**
     * @param  list<array{
     *     factory_product_id: int,
     *     customer_order_item_id?: int|null,
     *     pack_weight: float|int|string
     * }>  $packs
     */
    private static function storePacks(
        Sale $sale,
        array $packs,
        bool $deductStock,
        int $adjustSoldQuantity,
    ): void {
        $itemQuantities = [];

        foreach ($packs as $pack) {
            $factoryProductId = (int) $pack['factory_product_id'];
            $packWeight = round((float) $pack['pack_weight'], 3);
            $orderItemId = isset($pack['customer_order_item_id'])
                ? (int) $pack['customer_order_item_id']
                : null;

            if ($packWeight <= 0) {
                throw new \InvalidArgumentException(__('Pack weight must be greater than zero.'));
            }

            FactoryProduct::query()->findOrFail($factoryProductId);

            if ($orderItemId !== null) {
                $itemQuantities[$orderItemId] = ($itemQuantities[$orderItemId] ?? 0) + 1;
            }

            $salePack = SalePack::query()->create([
                'sale_id' => $sale->id,
                'factory_product_id' => $factoryProductId,
                'customer_order_item_id' => $orderItemId,
                'pack_weight' => $packWeight,
            ]);

            if ($deductStock) {
                FinishedGoodsStock::adjust($factoryProductId, -1, [
                    'type' => StockMovementType::SALE_OUT,
                    'reference_type' => SalePack::class,
                    'reference_id' => $salePack->id,
                ]);
            }
        }

        foreach ($itemQuantities as $orderItemId => $count) {
            self::validateOrderItemCanSell((int) $orderItemId, $count);
            self::adjustSoldQuantity((int) $orderItemId, $count * $adjustSoldQuantity);
        }
    }

    private static function validateOrderItemCanSell(int $orderItemId, int $packCount): void
    {
        $item = CustomerOrderItem::query()
            ->whereKey($orderItemId)
            ->lockForUpdate()
            ->firstOrFail();

        if ($item->status !== CustomerOrderItemStatus::READY) {
            throw new \InvalidArgumentException(__('Only ready order lines can be sold.'));
        }

        $remaining = CustomerOrderFulfillment::sellableQuantity($item);

        if ($packCount > $remaining + 0.001) {
            throw new \InvalidArgumentException(__('Cannot sell more packs than the remaining order quantity.'));
        }
    }

    private static function saleHasReturnedPacks(Sale $sale): bool
    {
        return SalePack::query()
            ->where('sale_id', $sale->id)
            ->where('is_returned', true)
            ->exists();
    }

    private static function adjustSoldQuantity(int $orderItemId, int $delta): void
    {
        $item = CustomerOrderItem::query()
            ->whereKey($orderItemId)
            ->lockForUpdate()
            ->firstOrFail();

        $item->sold_quantity = round(max(0, (float) $item->sold_quantity + $delta), 3);
        $item->save();

        if ($delta > 0) {
            CustomerOrderFulfillment::releaseUnpurchasedCommitment(
                CustomerOrderItem::query()->whereKey($orderItemId)->lockForUpdate()->firstOrFail(),
            );
            $item = CustomerOrderItem::query()->whereKey($orderItemId)->firstOrFail();
        }

        if ((float) $item->sold_quantity >= (float) $item->quantity - 0.001) {
            if ($item->status !== CustomerOrderItemStatus::DELIVERED) {
                CustomerOrderFulfillment::markItemDelivered($item);
            }
        } elseif ($item->status === CustomerOrderItemStatus::DELIVERED) {
            $item->status = CustomerOrderItemStatus::READY;
            $item->save();

            $order = CustomerOrder::query()->find($item->customer_order_id);

            if ($order !== null) {
                CustomerOrderFulfillment::refreshOrderStatus($order->fresh(['items']));
            }
        }
    }

    /**
     * @param  list<array{
     *     id: int,
     *     quantity: float|int|string,
     *     pack_weights?: list<float|int|string>,
     *     total_weight?: float|int|string,
     *     weight_price: float|int|string
     * }>  $items
     */
    public static function updateSale(
        Sale $sale,
        string $soldAt,
        ?string $notes,
        array $items,
        float $discount = 0,
    ): Sale {
        if ($sale->type !== SaleType::SALE) {
            throw new \InvalidArgumentException(__('Only product sales can be edited.'));
        }

        if ($items === []) {
            throw new \InvalidArgumentException(__('At least one product is required for a sale.'));
        }

        $sale->loadMissing('items');
        $itemProductMap = $sale->items->keyBy('id');
        $items = array_map(
            function (array $item) use ($itemProductMap): array {
                $saleItem = $itemProductMap->get((int) ($item['id'] ?? 0));

                return self::normalizeSaleLineItem([
                    ...$item,
                    'factory_product_id' => $saleItem?->factory_product_id,
                ]);
            },
            $items,
        );

        $discount = round(max(0, $discount), 2);
        $totalAmount = self::calculateItemsTotalAmount($items);
        $netAmount = self::calculateNetAmount($totalAmount, $discount);

        return DB::transaction(function () use ($sale, $soldAt, $notes, $items, $discount, $totalAmount, $netAmount): Sale {
            $sale->sold_at = $soldAt;
            $sale->notes = $notes;
            $sale->total_amount = $totalAmount;
            $sale->discount = $discount;
            $sale->net_amount = $netAmount;
            $sale->save();

            $itemIds = array_map(fn (array $item): int => (int) $item['id'], $items);

            $existingItems = SaleItem::query()
                ->where('sale_id', $sale->id)
                ->whereIn('id', $itemIds)
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            if ($existingItems->count() !== count($itemIds)) {
                throw new \InvalidArgumentException(__('One or more products could not be found on this sale.'));
            }

            foreach ($items as $itemData) {
                $saleItem = $existingItems->get((int) $itemData['id']);

                if ($saleItem === null) {
                    continue;
                }

                $itemData = self::normalizeSaleLineItem([
                    ...$itemData,
                    'factory_product_id' => $saleItem->factory_product_id,
                ]);
                $quantity = round((float) $itemData['quantity'], 3);
                $resolved = self::resolvePackWeights($itemData);
                $totalWeight = $resolved['total_weight'];
                $weightPrice = round((float) $itemData['weight_price'], 2);
                $returnCotton = round(max(0, (float) ($itemData['return_cotton'] ?? 0)), 3);
                $cottonPrice = round(max(0, (float) ($itemData['cotton_price'] ?? 0)), 2);
                $packCount = $resolved['pack_count'];

                if ($weightPrice < 0) {
                    throw new \InvalidArgumentException(__('Weight price cannot be negative.'));
                }

                $activePacks = SalePack::query()
                    ->where('sale_item_id', $saleItem->id)
                    ->where('is_returned', false)
                    ->whereNull('original_sale_pack_id')
                    ->orderBy('id')
                    ->lockForUpdate()
                    ->get();

                if ($packCount !== $activePacks->count()) {
                    throw new \InvalidArgumentException(__('Product quantity cannot be changed after packs were sold.'));
                }

                $saleItem->quantity_mode = SaleLineQuantityMode::normalize($itemData['quantity_mode'] ?? null);
                $saleItem->quantity = $quantity;
                $saleItem->total_weight = $totalWeight;
                $saleItem->weight_price = $weightPrice;
                $saleItem->return_cotton = $returnCotton;
                $saleItem->cotton_price = $cottonPrice;
                $saleItem->save();

                foreach ($activePacks->values() as $index => $pack) {
                    $pack->pack_weight = $resolved['weights'][$index];
                    $pack->save();
                }
            }

            $sale = $sale->fresh(['items.product.unit', 'packs.product.unit', 'customer', 'order']);

            if (! self::saleHasReturnedPacks($sale)) {
                SaleDocument::captureInvoice($sale);
            }

            return $sale;
        });
    }

    public static function generateSaleNumber(null|string|\DateTimeInterface $soldAt = null): string
    {
        return Sale::formatSaleNumber(
            self::allocateNextSaleSequence(),
            self::resolveSaleNumberDate($soldAt),
        );
    }

    public static function peekNextSaleNumber(null|string|\DateTimeInterface $soldAt = null): string
    {
        return Sale::formatSaleNumber(
            self::maxSaleSequence() + 1,
            self::resolveSaleNumberDate($soldAt),
        );
    }

    private static function resolveSaleNumberDate(null|string|\DateTimeInterface $soldAt): \DateTimeInterface
    {
        $now = now();

        if ($soldAt instanceof \DateTimeInterface) {
            return Carbon::instance($soldAt);
        }

        if (is_string($soldAt) && trim($soldAt) !== '') {
            $parsed = Carbon::parse($soldAt);

            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($soldAt)) === 1) {
                return $parsed->setTime($now->hour, $now->minute, $now->second);
            }

            return $parsed;
        }

        return $now;
    }

    private static function allocateNextSaleSequence(): int
    {
        return self::maxSaleSequence(lock: true) + 1;
    }

    private static function maxSaleSequence(bool $lock = false): int
    {
        $query = Sale::query();

        if ($lock) {
            $query->lockForUpdate();
        }

        return $query
            ->pluck('sale_number')
            ->map(fn (string $saleNumber): int => Sale::parseSaleSequence($saleNumber))
            ->max() ?? 0;
    }

    /**
     * @param  list<array{
     *     pack_weights?: list<float|int|string>,
     *     total_weight?: float|int|string,
     *     quantity: float|int|string,
     *     weight_price: float|int|string
     * }>  $items
     */
    public static function calculateItemsTotalAmount(array $items): float
    {
        $total = 0.0;

        foreach ($items as $item) {
            $total += self::calculateLineTotal($item);
        }

        return round($total, 2);
    }

    /**
     * @param  array{
     *     quantity_mode?: string|null,
     *     quantity?: float|int|string,
     *     total_weight?: float|int|string,
     *     weight_price?: float|int|string,
     *     return_cotton?: float|int|string,
     *     cotton_price?: float|int|string,
     *     pack_weights?: list<float|int|string>
     * }  $item
     */
    public static function calculateLineTotal(array $item): float
    {
        $mode = SaleLineQuantityMode::normalize($item['quantity_mode'] ?? null);

        if ($mode === SaleLineQuantityMode::PACK) {
            $base = round((float) ($item['quantity'] ?? 0) * (float) ($item['weight_price'] ?? 0), 2);
        } else {
            $totalWeight = isset($item['pack_weights']) && is_array($item['pack_weights'])
                ? round(array_sum(array_map('floatval', $item['pack_weights'])), 3)
                : round((float) ($item['total_weight'] ?? 0), 3);
            $base = round($totalWeight * (float) ($item['weight_price'] ?? 0), 2);
        }

        $cottonCost = self::calculateCottonCost($item);

        return round(max(0, $base - $cottonCost), 2);
    }

    /**
     * @param  array{return_cotton?: float|int|string, cotton_price?: float|int|string}  $item
     */
    public static function calculateCottonCost(array $item): float
    {
        return round(
            max(0, (float) ($item['return_cotton'] ?? 0)) * max(0, (float) ($item['cotton_price'] ?? 0)),
            2,
        );
    }

    public static function calculateNetAmount(float $totalAmount, float $discount): float
    {
        $discount = max(0, $discount);

        if ($discount > $totalAmount + 0.0001) {
            throw new \InvalidArgumentException(__('Discount cannot exceed the total amount.'));
        }

        return round(max(0, $totalAmount - $discount), 2);
    }

    /**
     * @param  Collection<int, SalePack>  $packs
     * @return list<array{
     *     product_name: string|null,
     *     unit_name: string|null,
     *     quantity: int,
     *     total_weight: string,
     *     weight_price: string|null,
     *     pack_ids: list<int>
     * }>
     */
    public static function productLinesFromPacks(Collection $packs): array
    {
        if ($packs->isEmpty()) {
            return [];
        }

        $packs->loadMissing('product.unit:id,name');

        return $packs
            ->groupBy(function (SalePack $pack): string {
                if ($pack->sale_item_id) {
                    return 'item:'.$pack->sale_item_id;
                }

                return 'product:'.$pack->factory_product_id;
            })
            ->map(function (Collection $group): array {
                /** @var SalePack $first */
                $first = $group->first();
                $sorted = $group->sortBy('id')->values();

                return [
                    'factory_product_id' => $first->factory_product_id,
                    'product_name' => $first->product?->name,
                    'unit_name' => $first->product?->unit?->name,
                    'quantity' => $sorted->count(),
                    'total_weight' => SaleDocument::formatQuantity(
                        round($sorted->sum(fn (SalePack $pack): float => (float) $pack->pack_weight), 3),
                    ),
                    'weight_price' => null,
                    'pack_ids' => $sorted->pluck('id')->map(fn ($id): int => (int) $id)->values()->all(),
                    'pack_weights' => $sorted
                        ->map(fn (SalePack $pack): string => SaleDocument::formatQuantity((float) $pack->pack_weight))
                        ->values()
                        ->all(),
                ];
            })
            ->values()
            ->all();
    }

    /**
     * @param  Collection<int, SalePack>  $returnablePacks
     * @return list<array{
     *     sale_item_id: int|null,
     *     product_name: string|null,
     *     unit_name: string|null,
     *     quantity: int,
     *     total_weight: string,
     *     weight_price: string|null,
     *     pack_ids: list<int>,
     *     pack_weights: list<string>
     * }>
     */
    public static function returnableLinesFromSale(Sale $sale, Collection $returnablePacks): array
    {
        if ($returnablePacks->isEmpty()) {
            return [];
        }

        $sale->loadMissing(['items.product.unit:id,name']);
        $itemsById = $sale->items->keyBy('id');

        return collect(self::productLinesFromPacks($returnablePacks))
            ->map(function (array $line) use ($returnablePacks, $itemsById): array {
                $firstPackId = $line['pack_ids'][0] ?? null;
                /** @var SalePack|null $pack */
                $pack = $firstPackId !== null
                    ? $returnablePacks->firstWhere('id', $firstPackId)
                    : null;
                $item = $pack?->sale_item_id !== null
                    ? $itemsById->get((int) $pack->sale_item_id)
                    : null;

                return [
                    'sale_item_id' => $item?->id,
                    'factory_product_id' => $item?->factory_product_id ?? $line['factory_product_id'] ?? $pack?->factory_product_id,
                    'product_name' => $item?->product?->name ?? $line['product_name'],
                    'unit_name' => $item?->product?->unit?->name ?? $line['unit_name'],
                    'quantity_mode' => SaleLineQuantityMode::normalize($item?->quantity_mode),
                    'quantity' => $line['quantity'],
                    'total_weight' => $line['total_weight'],
                    'weight_price' => $item !== null ? (string) $item->weight_price : $line['weight_price'],
                    'pack_ids' => $line['pack_ids'],
                    'pack_weights' => $line['pack_weights'],
                ];
            })
            ->filter(fn (array $line): bool => $line['quantity'] > 0 && $line['pack_ids'] !== [])
            ->values()
            ->all();
    }

    /**
     * @param  Collection<int, SalePack>  $packs
     * @return list<array{
     *     product_name: string|null,
     *     unit_name: string|null,
     *     quantity: string,
     *     total_weight: string,
     *     weight_price: string|null
     * }>
     */
    public static function displayLinesFromPacks(Collection $packs): array
    {
        return collect(self::productLinesFromPacks($packs))
            ->map(fn (array $line): array => [
                'product_name' => $line['product_name'],
                'unit_name' => $line['unit_name'],
                'quantity' => (string) $line['quantity'],
                'total_weight' => $line['total_weight'],
                'weight_price' => $line['weight_price'],
            ])
            ->values()
            ->all();
    }

    /**
     * @return array{
     *     id: int,
     *     sale_number: string,
     *     type: string,
     *     customer_id: int,
     *     customer_name: string|null,
     *     customer_order_id: int|null,
     *     order_number: string|null,
     *     sold_at: string|null,
     *     notes: string|null,
     *     total_amount: string,
     *     discount: string,
     *     net_amount: string,
     *     paid_amount: string,
     *     due_amount: string,
     *     payment_status: string,
     *     has_payments: bool,
     *     pack_count: int,
     *     related_sale_id: int|null,
     *     exchange_group_id: string|null
     * }
     */
    public static function serializeSaleSummary(Sale $sale): array
    {
        $sale->loadMissing(['customer:id,full_name', 'order:id,order_number']);

        if ($sale->type === SaleType::EXCHANGE) {
            $sale->loadMissing('items');
            $packCount = (int) round((float) $sale->items->sum('quantity'));
        } else {
            $sale->loadCount([
                'packs',
                'packs as active_packs_count' => fn ($query) => $query
                    ->where('is_returned', false)
                    ->whereNull('original_sale_pack_id'),
            ]);
            $packCount = (int) ($sale->active_packs_count ?? $sale->packs_count);
        }

        return [
            'id' => $sale->id,
            'sale_number' => $sale->sale_number,
            'type' => $sale->type,
            'customer_id' => $sale->customer_id,
            'customer_name' => CashSaleCustomer::isCashCustomer((int) $sale->customer_id)
                ? CashSaleCustomer::displayName()
                : $sale->customer?->full_name,
            'is_cash_walk_in' => CashSaleCustomer::isCashCustomer((int) $sale->customer_id),
            'customer_order_id' => $sale->customer_order_id,
            'order_number' => $sale->order?->order_number,
            'sold_at' => $sale->sold_at?->toDateTimeString(),
            'notes' => $sale->notes,
            'total_amount' => (string) $sale->total_amount,
            'discount' => (string) $sale->discount,
            'net_amount' => (string) $sale->net_amount,
            'paid_amount' => (string) $sale->paid_amount,
            'due_amount' => NumberFormat::money(SalePaymentLedger::dueAmountForSale($sale)),
            'payment_status' => SalePaymentStatus::resolve(
                (float) $sale->net_amount,
                (float) $sale->paid_amount,
            ),
            'has_payments' => SalePaymentLedger::hasPaymentsForSale($sale),
            'pack_count' => $packCount,
            'related_sale_id' => $sale->related_sale_id,
            'exchange_group_id' => $sale->exchange_group_id,
        ];
    }

    /**
     * @return array{
     *     id: int,
     *     factory_product_id: int,
     *     product_name: string|null,
     *     unit_name: string|null,
     *     customer_order_item_id: int|null,
     *     quantity: string,
     *     total_weight: string,
     *     pack_weights: list<string>,
     *     weight_price: string
     * }
     */
    public static function serializeItem(SaleItem $item): array
    {
        $item->loadMissing(['product.unit:id,name', 'packs']);

        $packWeights = $item->packs
            ->filter(fn (SalePack $pack): bool => ! $pack->is_returned && $pack->original_sale_pack_id === null)
            ->sortBy('id')
            ->map(fn (SalePack $pack): string => (string) $pack->pack_weight)
            ->values()
            ->all();

        return [
            'id' => $item->id,
            'factory_product_id' => $item->factory_product_id,
            'product_name' => $item->product?->name,
            'unit_name' => $item->product?->unit?->name,
            'customer_order_item_id' => $item->customer_order_item_id,
            'quantity_mode' => SaleLineQuantityMode::normalize($item->quantity_mode),
            'quantity' => (string) $item->quantity,
            'total_weight' => (string) $item->total_weight,
            'pack_weights' => $packWeights,
            'weight_price' => (string) $item->weight_price,
            'return_cotton' => (string) $item->return_cotton,
            'cotton_price' => (string) $item->cotton_price,
        ];
    }

    /**
     * @return array{
     *     id: int,
     *     factory_product_id: int,
     *     product_name: string|null,
     *     unit_name: string|null,
     *     customer_order_item_id: int|null,
     *     pack_weight: string,
     *     is_returned: bool,
     *     original_sale_pack_id: int|null
     * }
     */
    public static function serializePack(SalePack $pack): array
    {
        $pack->loadMissing('product.unit:id,name');

        return [
            'id' => $pack->id,
            'factory_product_id' => $pack->factory_product_id,
            'product_name' => $pack->product?->name,
            'unit_name' => $pack->product?->unit?->name,
            'customer_order_item_id' => $pack->customer_order_item_id,
            'pack_weight' => (string) $pack->pack_weight,
            'is_returned' => (bool) $pack->is_returned,
            'original_sale_pack_id' => $pack->original_sale_pack_id,
            'return_condition' => $pack->return_condition,
        ];
    }

    /**
     * @param  Collection<int, SalePack>  $packs
     * @return list<array{
     *     product_name: string|null,
     *     unit_name: string|null,
     *     quantity: int,
     *     total_weight: string,
     *     conditions: array<string, array{quantity: int, weight: string}>,
     *     packs: list<array{
     *         id: int,
     *         pack_weight: string,
     *         return_condition: string,
     *         original_sale_pack_id: int|null
     *     }>
     * }>
     */
    public static function returnLinesFromPacks(Collection $packs): array
    {
        if ($packs->isEmpty()) {
            return [];
        }

        $packs->loadMissing(['product.unit:id,name']);

        return $packs
            ->groupBy(fn (SalePack $pack): string => (string) $pack->factory_product_id)
            ->map(function (Collection $group): array {
                /** @var SalePack $first */
                $first = $group->first();
                $sorted = $group->sortBy('id')->values();

                /** @var array<string, array{quantity: int, weight: float}> $conditions */
                $conditions = [
                    SaleReturnCondition::GOOD => ['quantity' => 0, 'weight' => 0.0],
                    SaleReturnCondition::DEFECTIVE => ['quantity' => 0, 'weight' => 0.0],
                    SaleReturnCondition::EXPIRED => ['quantity' => 0, 'weight' => 0.0],
                ];

                $packRows = [];

                foreach ($sorted as $pack) {
                    $condition = SaleReturnCondition::normalize($pack->return_condition);
                    $weight = (float) $pack->pack_weight;

                    $conditions[$condition]['quantity']++;
                    $conditions[$condition]['weight'] += $weight;

                    $packRows[] = [
                        'id' => $pack->id,
                        'pack_weight' => (string) $pack->pack_weight,
                        'return_condition' => $condition,
                        'original_sale_pack_id' => $pack->original_sale_pack_id,
                    ];
                }

                $totalWeight = round($sorted->sum(fn (SalePack $pack): float => (float) $pack->pack_weight), 3);

                return [
                    'product_name' => $first->product?->name,
                    'unit_name' => $first->product?->unit?->name,
                    'quantity' => $sorted->count(),
                    'total_weight' => SaleDocument::formatQuantity($totalWeight),
                    'conditions' => collect($conditions)
                        ->map(fn (array $entry): array => [
                            'quantity' => $entry['quantity'],
                            'weight' => SaleDocument::formatQuantity(round($entry['weight'], 3)),
                        ])
                        ->all(),
                    'packs' => $packRows,
                ];
            })
            ->values()
            ->all();
    }

    /**
     * @return list<array{
     *     direction: string,
     *     product_name: string|null,
     *     unit_name: string|null,
     *     quantity: string,
     *     total_weight: string,
     *     weight_price: string,
     *     line_total: string
     * }>
     */
    public static function serializeExchangeTransaction(?Sale $returnSale, Sale $exchangeSale): array
    {
        $lines = [];

        if ($returnSale !== null) {
            $returnSale->loadMissing(['packs.product.unit:id,name']);

            $originalPackIds = $returnSale->packs
                ->pluck('original_sale_pack_id')
                ->filter()
                ->map(fn ($id): int => (int) $id)
                ->values()
                ->all();

            $originalPacks = $originalPackIds === []
                ? collect()
                : SalePack::query()
                    ->with('saleItem:id,weight_price')
                    ->whereIn('id', $originalPackIds)
                    ->get()
                    ->keyBy('id');

            $returnPacksById = $returnSale->packs->keyBy('id');

            foreach (self::returnLinesFromPacks($returnSale->packs) as $group) {
                $firstPackId = (int) ($group['packs'][0]['id'] ?? 0);
                $returnPack = $returnPacksById->get($firstPackId);
                $originalPack = $returnPack?->original_sale_pack_id !== null
                    ? $originalPacks->get((int) $returnPack->original_sale_pack_id)
                    : null;
                $weightPrice = round((float) ($originalPack?->saleItem?->weight_price ?? 0), 2);
                $totalWeight = round(
                    collect($group['packs'] ?? [])
                        ->sum(fn (array $pack): float => (float) ($returnPacksById->get($pack['id'])?->pack_weight ?? 0)),
                    3,
                );

                $lines[] = [
                    'direction' => 'returned',
                    'product_name' => $group['product_name'] ?? null,
                    'unit_name' => $group['unit_name'] ?? null,
                    'quantity' => (string) ($group['quantity'] ?? 0),
                    'total_weight' => SaleDocument::formatQuantity($totalWeight),
                    'weight_price' => NumberFormat::money($weightPrice),
                    'line_total' => NumberFormat::money(round($totalWeight * $weightPrice, 2)),
                ];
            }
        }

        $exchangeSale->loadMissing(['items.product.unit:id,name']);

        foreach ($exchangeSale->items as $item) {
            $serialized = self::serializeItem($item);
            $lineTotal = self::calculateLineTotal([
                'quantity_mode' => $item->quantity_mode,
                'quantity' => $item->quantity,
                'total_weight' => $item->total_weight,
                'weight_price' => $item->weight_price,
                'return_cotton' => $item->return_cotton,
                'cotton_price' => $item->cotton_price,
            ]);

            $lines[] = [
                'direction' => 'outgoing',
                'product_name' => $serialized['product_name'],
                'unit_name' => $serialized['unit_name'],
                'quantity' => $serialized['quantity'],
                'total_weight' => $serialized['total_weight'],
                'weight_price' => $serialized['weight_price'],
                'return_cotton' => $serialized['return_cotton'],
                'cotton_price' => $serialized['cotton_price'],
                'cotton_cost' => NumberFormat::money(self::calculateCottonCost([
                    'return_cotton' => $item->return_cotton,
                    'cotton_price' => $item->cotton_price,
                ])),
                'line_total' => NumberFormat::money($lineTotal),
            ];
        }

        return $lines;
    }

    /**
     * @param  list<array{
     *     direction: string,
     *     product_name: string|null,
     *     quantity: string
     * }>  $transactionLines
     */
    public static function exchangeTransactionSummary(array $transactionLines): ?string
    {
        if ($transactionLines === []) {
            return null;
        }

        $formatSide = function (string $direction) use ($transactionLines): string {
            return collect($transactionLines)
                ->filter(fn (array $line): bool => ($line['direction'] ?? '') === $direction)
                ->map(function (array $line): string {
                    $name = $line['product_name'] ?? '—';
                    $quantity = (int) round((float) ($line['quantity'] ?? 0));

                    return $quantity > 0 ? "{$name} ({$quantity})" : $name;
                })
                ->unique()
                ->values()
                ->implode(', ');
        };

        $returned = $formatSide('returned');
        $outgoing = $formatSide('outgoing');

        if ($returned === '' && $outgoing === '') {
            return null;
        }

        if ($returned === '') {
            return $outgoing;
        }

        if ($outgoing === '') {
            return $returned;
        }

        return "{$returned} → {$outgoing}";
    }
}
