<?php

namespace App\Services;

use App\Models\Catalog\Product;
use App\Models\Inventory\StockBatch;
use App\Models\Scopes\BranchScope;
use Illuminate\Support\Collection;

/**
 * The branch-priced product catalog the Sales Order form's picker runs on:
 * every active product, its stock at that branch, and the sellable lots it can
 * be quoted from.
 *
 * Lives here rather than inside the form field because the payload is fetched
 * on demand — the field renders an empty shell and the browser asks for the
 * catalog once per branch (see SalesOrderProductGrid and
 * InteractsWithSalesOrderCatalog). It used to be built during every render of
 * the form, which meant four catalog-wide queries and a full re-serialization
 * of the whole catalog on interactions that had nothing to do with it: opening
 * the customer dropdown, adding a payment row, typing in the note.
 */
class SalesOrderCatalog
{
    /**
     * Payloads already built during THIS request, keyed by branch + VAT state.
     *
     * The catalog is a pure function of (branch, VAT) within one request, and
     * the field and a lazy fetch can both ask for it in the same one.
     *
     * @var array<string, array<int, array<string, mixed>>>
     */
    private array $memo = [];

    /**
     * The catalog for a branch, or an empty list when there is no branch to
     * price against — the client then shows "pick a branch" rather than a grid
     * of unpriced cards.
     *
     * @return array<int, array<string, mixed>>
     */
    public function forBranch(?int $branchId): array
    {
        if ($branchId === null) {
            return [];
        }

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

        return $this->memo[$branchId.'|'.($vatEnabled ? 'vat' : 'novat')] ??= $this->build($branchId, $vatEnabled);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function build(int $branchId, bool $vatEnabled): array
    {
        // Only the columns the payload below actually reads. `products.*` on a
        // wide catalog table is a lot of bytes to haul across the wire and
        // hydrate into models several hundred times over, for fields nothing
        // here looks at. image_url is needed raw: the image_display_url
        // accessor resolves it from the stored attribute.
        $products = Product::where('is_active', true)
            ->select([
                'id', 'name', 'sku', 'barcode', 'image_url', 'vat_rate',
                'is_batch_tracked', 'is_expiry_tracked',
                'main_category_id', 'sub_category_id', 'base_unit_id',
            ])
            ->with('mainCategory:id,name')
            ->with('subCategory:id,name')
            ->with('baseUnit:id,code,name,is_fractional')
            ->withStockFor($branchId)
            ->orderBy('name')
            ->get();

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

        $ids = $products->pluck('id')->map(fn ($id) => (int) $id)->all();

        // One query for every lot in the branch, grouped in memory — the picker
        // never goes N+1.
        $lotsByProduct = StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->whereIn('product_id', $ids)
            ->where('branch_id', $branchId)
            ->active()
            ->whereNotNull('sale_price')
            ->get([
                'id',
                'product_id',
                'internal_batch_number',
                'sale_price',
                'batch_cost',
                'expiry_date',
                'available_quantity',
                'created_at',
            ])
            ->groupBy('product_id');

        return $products
            ->map(function (Product $p) use ($vatEnabled, $lotsByProduct): array {
                $productLots = $lotsByProduct->get((int) $p->id) ?? collect();

                $isBatchTracked = (bool) $p->is_batch_tracked;

                // The card's price range and lot count, over the lots that are
                // actually sellable right now. This used to be a second,
                // grouped query (BatchService::priceRangesFor) against the very
                // same rows, with the very same filters, that $lotsByProduct
                // has already fetched — one catalog-wide GROUP BY per render
                // for numbers three min/max calls over data in memory give.
                $sellable = $productLots->filter(
                    fn (StockBatch $lot): bool => (float) $lot->available_quantity > 0
                );

                $priceMin = $sellable->min('sale_price') !== null
                    ? (float) $sellable->min('sale_price')
                    : null;

                $priceMax = $sellable->max('sale_price') !== null
                    ? (float) $sellable->max('sale_price')
                    : null;

                $lotCount = $sellable->count();

                /*
                 * Sales Orders may quote products that currently have no stock
                 * — a sales order is a promise about the future, not a shelf
                 * check (see class docblock). When nothing is available right
                 * now but the product has been priced before, expose ONE
                 * virtual quotation lot instead of blocking the line entirely:
                 *
                 * - id = null, batch = null, available = 0, is_quote_only = true
                 * - price = the most recently received price on file
                 *
                 * The browser must never submit a real StockBatch id for this
                 * case — SalesOrderService::quoteLot() resolves a null
                 * batch_id itself rather than trusting one from the client.
                 */
                $orderedSellableLots = $this->orderedLots(
                    $sellable,
                    $isBatchTracked,
                    (bool) $p->is_expiry_tracked,
                );

                $lotsPayload = $orderedSellableLots
                    ->map(fn (StockBatch $lot): array => [
                        'id' => $lot->id,
                        'batch' => $lot->internal_batch_number,
                        'price' => (float) $lot->sale_price,
                        'cost' => $lot->batch_cost !== null
                            ? (float) $lot->batch_cost
                            : null,
                        'expiry' => $lot->expiry_date?->toDateString(),
                        'available' => (float) $lot->available_quantity,
                        'is_quote_only' => false,
                    ])
                    ->values()
                    ->all();

                if ($lotsPayload === [] && $productLots->isNotEmpty()) {
                    // The most recent receipt, not the sell order — there is no
                    // stock to sell FEFO/FIFO from, so this is "what did we last
                    // charge", not "which lot ships first". The same rule
                    // BatchService::unifiedBatchFor() uses for a non-tracked
                    // product's price of record.
                    $quoteLot = $productLots
                        ->sortByDesc(fn (StockBatch $lot) => $lot->created_at->getTimestamp())
                        ->first();

                    $lotsPayload = [[
                        'id' => null,
                        'batch' => null,
                        'price' => (float) $quoteLot->sale_price,
                        'cost' => $quoteLot->batch_cost !== null
                            ? (float) $quoteLot->batch_cost
                            : null,
                        'expiry' => null,
                        'available' => 0.0,
                        'is_quote_only' => true,
                    ]];
                }

                return [
                    'id' => $p->id,
                    'name' => $p->name,
                    'sku' => $p->sku,
                    // The manufacturer's own barcode, when one is on file — a
                    // scan is matched against this first, then falls back to
                    // sku. See handleScan() in the terminal/grid views.
                    'barcode' => $p->barcode,
                    // Batch tracking OFF: batches are an internal receiving
                    // detail, not a selling concern — the "price" is a single
                    // figure (the front lot above — the latest GRN/batch price,
                    // whether or not it's the quote-only fallback), never a
                    // min–max range across every receipt the product has had.
                    'price_min' => $isBatchTracked ? $priceMin : ($lotsPayload[0]['price'] ?? $priceMin),
                    'price_max' => $isBatchTracked ? $priceMax : ($lotsPayload[0]['price'] ?? $priceMax),
                    'lot_count' => $lotCount,
                    'is_batch_tracked' => $isBatchTracked,
                    // Sell order first (FEFO for expiry-tracked products, FIFO
                    // otherwise — matching the default SalesOrderService::quoteLot
                    // quotes when batch_id is absent) when batch tracking is ON;
                    // most-recent-receipt first when it is OFF, so `lots[0]` is
                    // always the single lot the unified pool prices and sells
                    // from — see orderedLots(). Falls back to the quote-only lot
                    // above when nothing is currently available.
                    'lots' => $lotsPayload,
                    // See Pos::productsPayload() — while VAT is off, the browser
                    // never even sees a rate.
                    'vat_rate' => $vatEnabled ? (float) $p->vat_rate : 0.0,
                    // See Pos::productsPayload() — the column is a path, not a URL.
                    'image_url' => $p->image_display_url,
                    'main_category' => $p->mainCategory?->name,
                    'sub_category' => $p->subCategory?->name,
                    'quantity' => (float) ($p->stock ?? 0),
                    'base_unit' => $p->baseUnit
                        ? [
                            'code' => $p->baseUnit->code,
                            'name' => $p->baseUnit->name,
                            'is_fractional' => $p->baseUnit->is_fractional,
                        ]
                        : null,
                ];
            })
            ->values()
            ->all();
    }

    /**
     * A product's lots in its sell order.
     *
     * Batch tracking ON: FEFO when the product is expiry-tracked (soonest
     * expiry first, legacy no-date lots last), FIFO (oldest receipt first)
     * otherwise — so the grid's default lot matches the lot
     * SalesOrderService::quoteLot would quote with no explicit batch_id.
     *
     * Batch tracking OFF: newest receipt first. BatchService::upsertUnifiedFromGrn
     * keeps a single running lot per (product, branch) and overwrites its price
     * on every GRN, so in the ordinary case there is exactly one row here; the
     * newest-first order is what makes `lots[0]` the "latest GRN price" lot
     * SalesOrderService::quoteLot's unified-pool branch (BatchService::unifiedBatchFor)
     * quotes from even for legacy data with more than one stray row.
     *
     * @param  Collection<int, StockBatch>  $lots
     * @return Collection<int, StockBatch>
     */
    private function orderedLots(Collection $lots, bool $batchTracked, bool $expiryTracked): Collection
    {
        if (! $batchTracked) {
            return $lots->sortByDesc(fn (StockBatch $lot) => $lot->created_at->getTimestamp())->values();
        }

        if ($expiryTracked) {
            return $lots->sortBy([
                fn (StockBatch $lot) => $lot->expiry_date === null ? 1 : 0,
                fn (StockBatch $lot) => $lot->expiry_date?->getTimestamp() ?? PHP_INT_MAX,
                fn (StockBatch $lot) => $lot->created_at->getTimestamp(),
            ])->values();
        }

        return $lots->sortBy(fn (StockBatch $lot) => $lot->created_at->getTimestamp())->values();
    }
}
