<?php

namespace App\Services;

use App\Models\BranchStock;
use App\Models\Catalog\Product;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\Status;
use App\Models\Scopes\BranchScope;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;

/**
 * All batch lifecycle operations go through this service.
 * Controllers and other services must never write to stock_batches directly.
 */
class BatchService
{
    private const RECONCILE_EPSILON = 0.0005;

    public function __construct(private readonly DocumentNumberService $numbers) {}

    /**
     * Create a new stock batch from a confirmed GRN line.
     *
     * Every confirmed GRN line creates a batch — the batch owns the selling
     * price, so a batch without one cannot be sold.
     *
     * @param  array{
     *     branch_id: int,
     *     product_id: int,
     *     manufacture_date: string|null,
     *     expiry_date: string|null,
     *     received_quantity: float,
     *     batch_cost: float|null,
     *     sale_price: float|null,
     *     grn_item_id: int|null,
     *     grn_id: int|null,
     *     notes: string|null,
     * }  $data
     */
    public function createFromGrn(array $data): StockBatch
    {
        $activeStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_ACTIVE);

        return StockBatch::create([
            'branch_id' => $data['branch_id'],
            'product_id' => $data['product_id'],
            'internal_batch_number' => $this->numbers->next('batch'),
            'manufacture_date' => $data['manufacture_date'] ?? null,
            'expiry_date' => $data['expiry_date'] ?? null,
            'received_quantity' => $data['received_quantity'],
            'available_quantity' => $data['received_quantity'],
            'reserved_quantity' => 0,
            'damaged_quantity' => 0,
            'batch_cost' => $data['batch_cost'] ?? null,
            'sale_price' => $data['sale_price'] ?? null,
            'grn_item_id' => $data['grn_item_id'] ?? null,
            'grn_id' => $data['grn_id'] ?? null,
            'status_id' => $activeStatusId,
            'notes' => $data['notes'] ?? null,
            'created_by' => Auth::id(),
        ]);
    }

    /**
     * FEFO/FIFO allocation: pick batches for an outgoing movement, in the
     * product's sell order (expiry-tracked → FEFO, otherwise FIFO).
     * Returns a list of [batch_id, quantity] pairs that together satisfy $qty.
     *
     * @return array<int, array{batch_id: int, batch: StockBatch, quantity: float}>
     */
    public function allocate(
        int $productId,
        int $branchId,
        float $quantity,
        ?bool $expiryTracked = null,
    ): array {
        $batches = StockBatch::query()
            ->where('product_id', $productId)
            ->where('branch_id', $branchId)
            ->active()
            ->sellOrder($expiryTracked ?? $this->productTracksExpiry($productId))
            ->get();

        $allocations = [];
        $remaining = $quantity;

        foreach ($batches as $batch) {
            if ($remaining <= 0) {
                break;
            }

            $take = min((float) $batch->available_quantity, $remaining);

            if ($take > 0) {
                $allocations[] = [
                    'batch_id' => $batch->id,
                    'batch' => $batch,
                    'quantity' => $take,
                ];
                $remaining -= $take;
            }
        }

        return $allocations;
    }

    /**
     * The sellable lots of a product at a branch, sell-order first: active
     * lots with stock and a price on file, with their receipt eagerly loaded
     * so a caller (POS lot picker, bundle pricing) never fires an N+1.
     *
     * Ordering follows the product's expiry switch — expiry-tracked products
     * FEFO (soonest expiry first), everything else FIFO (oldest receipt
     * first) — so the "next lot to sell" is always the first row. Pass
     * `$expiryTracked` when the caller already holds the product to skip the
     * lookup.
     *
     * @return Collection<int, StockBatch>
     */
    public function sellableFor(int $productId, int $branchId, ?bool $expiryTracked = null)
    {
        // The explicit branch filter below is the real constraint; BranchScope
        // would additionally restrict to the current context branch and break
        // legitimate cross-branch service calls (order conversion on another
        // branch, bundle expansion, POS pricing). Callers already validate the
        // branch they pass.
        return StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('product_id', $productId)
            ->where('branch_id', $branchId)
            ->active()
            ->where('available_quantity', '>', 0)
            ->whereNotNull('sale_price')
            ->with('grn.supplier:id,name')
            ->sellOrder($expiryTracked ?? $this->productTracksExpiry($productId))
            ->get();
    }

    /**
     * The id of a product's next lot to sell at a branch — the FEFO-front lot
     * of an expiry-tracked product, the FIFO-front (oldest) lot otherwise.
     * Null when nothing sellable exists. This is the one lot a barcode-driven
     * sale may draw from without the override permission, and the default the
     * lot picker pre-selects.
     */
    public function frontLotId(int $productId, int $branchId, ?bool $expiryTracked = null): ?int
    {
        // Explicit branch is the real constraint — see sellableFor() above.
        // Without the bypass, a caller whose ambient CurrentContext branch
        // differs from $branchId gets BranchScope AND'd against it and
        // silently comes back empty, which reads here as "nothing to sell"
        // even though the branch actually has a front lot.
        return StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('product_id', $productId)
            ->where('branch_id', $branchId)
            ->active()
            ->where('available_quantity', '>', 0)
            ->whereNotNull('sale_price')
            ->sellOrder($expiryTracked ?? $this->productTracksExpiry($productId))
            ->value('id');
    }

    /**
     * Sellable-price range per product at a branch, in one grouped query —
     * the POS grid's "Rs. 250" / "Rs. 250 – 280" cards never fire one query
     * per product.
     *
     * A null branch aggregates across the caller's accessible branches
     * (BranchScope stays active, mirroring Product::withStockFor(null)) —
     * used by reports in their All-branches mode. A given branch is an
     * explicit, caller-validated parameter (mirroring
     * Product::scopeWithStockFor()) and bypasses BranchScope rather than risk
     * AND-ing two conflicting branch filters when it differs from the acting
     * user's ambient CurrentContext branch.
     *
     * @param  array<int, int>  $productIds
     * @return array<int, array{price_min: float, price_max: float, lot_count: int}>
     */
    public function priceRangesFor(array $productIds, ?int $branchId): array
    {
        if ($productIds === []) {
            return [];
        }

        return StockBatch::query()
            ->when($branchId !== null, fn ($q) => $q->withoutGlobalScope(BranchScope::class))
            ->whereIn('product_id', $productIds)
            ->when($branchId !== null, fn ($q) => $q->where('branch_id', $branchId))
            ->active()
            ->where('available_quantity', '>', 0)
            ->whereNotNull('sale_price')
            ->groupBy('product_id')
            ->selectRaw('product_id, MIN(sale_price) as price_min, MAX(sale_price) as price_max, COUNT(*) as lot_count')
            ->get()
            ->keyBy('product_id')
            ->map(fn ($row) => [
                'price_min' => (float) $row->price_min,
                'price_max' => (float) $row->price_max,
                'lot_count' => (int) $row->lot_count,
            ])
            ->all();
    }

    /**
     * Reserve stock from a batch (e.g., for a pending order).
     */
    public function reserve(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('available_quantity', $quantity);
        $batch->increment('reserved_quantity', $quantity);
    }

    /**
     * Release reserved quantity back to available (e.g., order cancelled).
     */
    public function release(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('reserved_quantity', $quantity);
        $batch->increment('available_quantity', $quantity);
    }

    /**
     * Consume available quantity (stock has left the batch permanently).
     */
    public function consume(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('available_quantity', $quantity);
    }

    /**
     * Inverse of consume(): stock that left available at sale time is coming
     * back (non-damaged customer return, or an invoice-delete reversal).
     */
    public function restock(StockBatch $batch, float $quantity): void
    {
        $batch->increment('available_quantity', $quantity);
    }

    /**
     * A damage-flagged return: the unit already left `available_quantity` when
     * it was sold, so only `damaged_quantity` is incremented here — there is no
     * available balance left to decrement again.
     */
    public function markDamagedReturn(StockBatch $batch, float $quantity): void
    {
        $batch->increment('damaged_quantity', $quantity);
    }

    /** Inverse of markDamagedReturn(): the return that flagged this damage was deleted. */
    public function reverseDamagedReturn(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('damaged_quantity', $quantity);
    }

    /**
     * The lot at `$branchId` that carries the same lineage as `$batch` — the
     * row transferPortion() would pour into (or has already created) when
     * moving stock there.
     *
     * Lineage is the receipt the goods arrived on (grn_item_id: one GRN line,
     * one consignment) plus the attributes a transfer copies verbatim —
     * expiry, price and status. It used to be keyed on the supplier's batch
     * number, which was optional and unvalidated: every lot that arrived
     * without one matched every other numberless lot of the same product.
     * grn_item_id is the identifier the receipt already guarantees.
     *
     * Status is part of the key so a deactivated (or expired, or recalled) lot
     * at the destination never silently absorbs a live transfer — the move
     * lands in a fresh lot instead.
     */
    public function findCounterpart(StockBatch $batch, int $branchId): ?StockBatch
    {
        return StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('branch_id', $branchId)
            ->where('product_id', $batch->product_id)
            ->where('grn_item_id', $batch->grn_item_id)
            ->when(
                $batch->expiry_date,
                fn ($q) => $q->whereDate('expiry_date', $batch->expiry_date),
                fn ($q) => $q->whereNull('expiry_date'),
            )
            ->where('sale_price', $batch->sale_price)
            ->where('status_id', $batch->status_id)
            ->first();
    }

    /**
     * Move a quantity of this batch to another branch. Finds or creates the
     * matching batch row at the destination (same product and batch
     * lineage — see findCounterpart()) and moves the quantity into it.
     */
    public function transferPortion(StockBatch $batch, float $quantity, int $toBranchId): StockBatch
    {
        if ($quantity > (float) $batch->available_quantity + 0.0001) {
            throw new \InvalidArgumentException("Cannot transfer {$quantity}: only {$batch->available_quantity} available in batch {$batch->internal_batch_number}.");
        }

        $batch->decrement('available_quantity', $quantity);

        $destination = $this->findCounterpart($batch, $toBranchId);

        if ($destination) {
            $destination->increment('available_quantity', $quantity);

            return $destination->fresh();
        }

        return StockBatch::create([
            'branch_id' => $toBranchId,
            'product_id' => $batch->product_id,
            'internal_batch_number' => $this->numbers->next('batch'),
            'manufacture_date' => $batch->manufacture_date,
            'expiry_date' => $batch->expiry_date,
            'received_quantity' => $quantity,
            'available_quantity' => $quantity,
            'reserved_quantity' => 0,
            'damaged_quantity' => 0,
            'batch_cost' => $batch->batch_cost,
            'sale_price' => $batch->sale_price,
            // Provenance travels with the goods: the destination lot is the
            // same consignment, so findCounterpart() can pair the two again
            // (a second transfer of the same lot, or a deletion reversal).
            'grn_item_id' => $batch->grn_item_id,
            'grn_id' => $batch->grn_id,
            'status_id' => $batch->status_id,
            'notes' => "Transferred from batch {$batch->internal_batch_number}.",
            'created_by' => Auth::id(),
        ]);
    }

    /**
     * Set a batch's `available_quantity` to an absolute new value (stock count
     * correction). Returns the signed diff applied, for the caller to mirror
     * against `branch_stock` via StockMovementService.
     */
    public function adjust(StockBatch $batch, float $newAvailableQuantity): float
    {
        $diff = $newAvailableQuantity - (float) $batch->available_quantity;
        $batch->update(['available_quantity' => $newAvailableQuantity, 'updated_by' => Auth::id()]);

        return $diff;
    }

    /**
     * Move quantity from available to damaged.
     */
    public function markDamaged(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('available_quantity', $quantity);
        $batch->increment('damaged_quantity', $quantity);
    }

    /**
     * Inverse of markDamaged(): a manual damage entry was deleted.
     */
    public function reverseDamage(StockBatch $batch, float $quantity): void
    {
        $batch->decrement('damaged_quantity', $quantity);
        $batch->increment('available_quantity', $quantity);
    }

    /**
     * Mark a batch as expired (sets status to 'expired').
     */
    public function markExpired(StockBatch $batch): void
    {
        $expiredStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_EXPIRED);
        $batch->update([
            'status_id' => $expiredStatusId,
            'updated_by' => Auth::id(),
        ]);
    }

    /**
     * Recall a batch (sets status to 'recalled', appends note).
     */
    public function recall(StockBatch $batch, string $reason): void
    {
        $recalledStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_RECALLED);
        $batch->update([
            'status_id' => $recalledStatusId,
            'notes' => trim(($batch->notes ?? '')."\n[RECALL] {$reason}"),
            'updated_by' => Auth::id(),
        ]);
    }

    /**
     * Park a lot: it keeps its stock and stays on the books, but drops out of
     * every list that offers lots — POS and sales-order pickers, transfers,
     * adjustments, write-offs, price ranges and FEFO allocation — because all
     * of those go through scopeActive() or scopeNotDeactivated().
     *
     * Quantities are deliberately untouched, exactly as recall() leaves them:
     * the units are still physically on the shelf and still counted in
     * branch_stock. Deactivation is a sellability flag, not a stock movement.
     */
    public function deactivate(StockBatch $batch, ?string $reason = null): void
    {
        $inactiveStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_INACTIVE);

        $batch->update([
            'status_id' => $inactiveStatusId,
            'notes' => $reason
                ? trim(($batch->notes ?? '')."\n[DEACTIVATED] {$reason}")
                : $batch->notes,
            'updated_by' => Auth::id(),
        ]);
    }

    /**
     * Put a parked lot back into circulation.
     *
     * A lot whose expiry has passed while it sat deactivated comes back as
     * 'expired', not 'active' — reactivation must not resurrect stock the
     * nightly flagExpiredBatches() sweep would immediately kill again (and it
     * only ever looks at active lots, so it would never revisit this one).
     */
    public function reactivate(StockBatch $batch): void
    {
        $expired = $batch->expiry_date !== null
            && $batch->expiry_date->lt(now()->startOfDay());

        $statusId = Status::idOf(
            Status::CTX_STOCK_BATCH,
            $expired ? Status::BATCH_EXPIRED : Status::BATCH_ACTIVE,
        );

        $batch->update([
            'status_id' => $statusId,
            'updated_by' => Auth::id(),
        ]);
    }

    /**
     * Flag all batches with expiry_date < today as 'expired'.
     * Called by the FlagExpiredBatches console command.
     *
     * @return int number of batches flagged
     */
    public function flagExpiredBatches(): int
    {
        $expiredStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_EXPIRED);
        $activeStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_ACTIVE);

        if (! $expiredStatusId || ! $activeStatusId) {
            return 0;
        }

        return StockBatch::query()
            ->where('status_id', $activeStatusId)
            ->whereNotNull('expiry_date')
            ->where('expiry_date', '<', now()->toDateString())
            ->update([
                'status_id' => $expiredStatusId,
                'updated_by' => null,
            ]);
    }

    /**
     * Reconcile the lot ledger against the branch_stock summary, per
     * (branch, product): the lots are the physical truth, branch_stock is the
     * derived counter that can drift (legacy movements, skipped pickers,
     * manual SQL). Called by the ReconcileBatches console command inside a
     * per-tenant sweep.
     *
     * With `$fix` enabled:
     *  - lot cover > 0: branch_stock.quantity is set to the lot sum.
     *  - on-hand > 0 but no sellable lot: an opening lot is created carrying
     *    NO price — per the lot-pricing contract a consignment without a
     *    price on file is unsellable, so reconciled orphans stay unsellable
     *    until a receipt prices them.
     *
     * @return array{drift: array<int,array{branch_id:int,product_id:int,product:string,on_hand:float,lot_cover:float,drift:float}>, fixed:int}
     */
    public function reconcileQuantities(bool $fix = false): array
    {
        $onHand = BranchStock::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('quantity', '!=', 0)
            ->get(['branch_id', 'product_id', 'quantity']);

        // Deactivated lots count toward cover even though they are unsellable:
        // the units are still on the shelf and still in branch_stock, so
        // leaving them out would read as missing stock and `--fix` would mint
        // a duplicate opening lot for quantity that already has a lot.
        $lotCover = StockBatch::query()
            ->where('available_quantity', '!=', 0)
            ->whereHas('status', fn ($q) => $q->whereIn('code', [Status::BATCH_ACTIVE, Status::BATCH_INACTIVE]))
            ->selectRaw('branch_id, product_id, SUM(available_quantity) AS cover')
            ->groupBy('branch_id', 'product_id')
            ->get();

        $products = Product::query()
            ->whereIn('id', collect($onHand)->pluck('product_id')->merge(collect($lotCover)->pluck('product_id'))->unique()->all())
            ->get(['id', 'name'])
            ->keyBy('id');

        $coverByKey = [];
        foreach ($lotCover as $row) {
            $coverByKey["{$row->branch_id}:{$row->product_id}"] = (float) $row->cover;
        }

        $onHandByKey = [];
        foreach ($onHand as $row) {
            $onHandByKey["{$row->branch_id}:{$row->product_id}"] = (float) $row->quantity;
        }

        $keys = collect(array_merge(array_keys($coverByKey), array_keys($onHandByKey)))->unique()->values()->all();

        $drift = [];
        foreach ($keys as $key) {
            $cover = $coverByKey[$key] ?? 0.0;
            $hand = $onHandByKey[$key] ?? 0.0;
            if (abs($hand - $cover) <= self::RECONCILE_EPSILON) {
                continue;
            }

            [$branchId, $productId] = array_map('intval', explode(':', $key));
            $drift[] = [
                'branch_id' => $branchId,
                'product_id' => $productId,
                'product' => $products[$productId]->name ?? "Product#{$productId}",
                'on_hand' => $hand,
                'lot_cover' => $cover,
                'drift' => $cover - $hand,
            ];
        }

        if (! $fix) {
            return ['drift' => $drift, 'fixed' => 0];
        }

        $fixed = 0;
        foreach ($drift as $row) {
            if ($row['lot_cover'] > 0) {
                BranchStock::query()
                    ->withoutGlobalScope(BranchScope::class)
                    ->updateOrCreate(
                        ['branch_id' => $row['branch_id'], 'product_id' => $row['product_id']],
                        ['quantity' => $row['lot_cover']],
                    );
                $fixed++;
            } elseif ($row['on_hand'] > 0) {
                $this->createOpeningLot($row['branch_id'], $row['product_id'], $row['on_hand']);
                $fixed++;
            }
        }

        return ['drift' => $drift, 'fixed' => $fixed];
    }

    /**
     * Whether a product is batch-tracked — used to bypass lot-level operations
     * for unified-pool products.
     */
    public function productIsBatchTracked(int $productId): bool
    {
        return (bool) Product::whereKey($productId)->value('is_batch_tracked');
    }

    /**
     * Whether a product's lots sell FEFO (expiry-tracked) or FIFO. One tiny
     * lookup — callers that already hold the Product pass the flag instead.
     */
    private function productTracksExpiry(int $productId): bool
    {
        return (bool) Product::whereKey($productId)->value('is_expiry_tracked');
    }

    /**
     * Unified lot for a non-batch product at a branch — the single lot that
     * aggregates all receipts for the product at that branch. Batched products
     * must not use this; they keep per-receipt lots.
     */
    public function unifiedBatchFor(int $productId, int $branchId): ?StockBatch
    {
        return StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('product_id', $productId)
            ->where('branch_id', $branchId)
            ->active()
            ->orderByDesc('created_at')
            ->first();
    }

    /**
     * Upsert the unified lot for a non-batch GRN receipt: first receipt creates
     * one lot, subsequent receipts aggregate quantity and overwrite the price to
     * the latest GRN purchase price (unified-pool contract).
     *
     * @param  array{
     *     branch_id: int,
     *     product_id: int,
     *     received_quantity: float,
     *     batch_cost: float|null,
     *     sale_price: float|null,
     *     grn_item_id: int|null,
     *     grn_id: int|null,
     * }  $data
     */
    public function upsertUnifiedFromGrn(array $data): StockBatch
    {
        $existing = StockBatch::query()
            ->withoutGlobalScope(BranchScope::class)
            ->where('product_id', $data['product_id'])
            ->where('branch_id', $data['branch_id'])
            ->active()
            ->first();

        if ($existing) {
            $existing->increment('received_quantity', $data['received_quantity']);
            $existing->increment('available_quantity', $data['received_quantity']);
            $existing->update([
                'batch_cost' => $data['batch_cost'] ?? $existing->batch_cost,
                'sale_price' => $data['sale_price'] ?? $existing->sale_price,
                // Keep provenance to latest receipt for audit, but don't overwrite
                // earlier grn linkage that would hide history.
                'updated_by' => \Illuminate\Support\Facades\Auth::id(),
            ]);

            return $existing->fresh();
        }

        $activeStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_ACTIVE);

        return StockBatch::create([
            'branch_id' => $data['branch_id'],
            'product_id' => $data['product_id'],
            'internal_batch_number' => $this->numbers->next('batch'),
            'manufacture_date' => null,
            'expiry_date' => null,
            'received_quantity' => $data['received_quantity'],
            'available_quantity' => $data['received_quantity'],
            'reserved_quantity' => 0,
            'damaged_quantity' => 0,
            'batch_cost' => $data['batch_cost'] ?? null,
            'sale_price' => $data['sale_price'] ?? null,
            'grn_item_id' => $data['grn_item_id'] ?? null,
            'grn_id' => $data['grn_id'] ?? null,
            'status_id' => $activeStatusId,
            'notes' => 'Unified pool — non-batch product',
            'created_by' => \Illuminate\Support\Facades\Auth::id(),
        ]);
    }

    /**
     * Consume from the unified lot of a non-batch product — mirrors consume()
     * but finds the lot by (branch, product) instead of requiring a specific
     * StockBatch instance.
     */
    public function consumeUnified(int $productId, int $branchId, float $quantity): ?StockBatch
    {
        $batch = $this->unifiedBatchFor($productId, $branchId);
        if ($batch) {
            $batch->decrement('available_quantity', $quantity);
        }

        return $batch;
    }

    /**
     * Restock the unified lot (non-batch return or adjustment reversal).
     */
    public function restockUnified(int $productId, int $branchId, float $quantity): ?StockBatch
    {
        $batch = $this->unifiedBatchFor($productId, $branchId);
        if ($batch) {
            $batch->increment('available_quantity', $quantity);
        }

        return $batch;
    }

    /**
     * An opening lot for stock that sits on the books with no lot at all —
     * the counterpart of the backfill migration, for drift found after the
     * fact. Deliberately unpriced: selling it requires a later receipt to
     * name a price, exactly like any other consignment without one.
     */
    private function createOpeningLot(int $branchId, int $productId, float $quantity): void
    {
        $activeStatusId = Status::idOf(Status::CTX_STOCK_BATCH, Status::BATCH_ACTIVE);
        $number = $this->numbers->next('batch');
        while (StockBatch::query()->where('internal_batch_number', $number)->exists()) {
            $number = $this->numbers->next('batch');
        }

        StockBatch::create([
            'branch_id' => $branchId,
            'product_id' => $productId,
            'internal_batch_number' => $number,
            'received_quantity' => $quantity,
            'available_quantity' => $quantity,
            'reserved_quantity' => 0,
            'damaged_quantity' => 0,
            'sale_price' => null,
            'batch_cost' => null,
            'status_id' => $activeStatusId,
            'notes' => 'Opening balance — reconciled by batches:reconcile (no lot on file)',
        ]);
    }
}
