<?php

namespace App\Services;

use App\Exceptions\StockReferenceException;
use App\Models\Inventory\Grn;
use App\Models\Inventory\GrnItem;
use App\Models\Lookups\GrnStatus;
use App\Models\Lookups\MovementType;
use App\Models\Purchasing\PurchaseOrder;
use App\Models\Purchasing\Supplier;
use App\Support\SerialInput;
use Illuminate\Support\Facades\DB;

/**
 * Stock-in via GRN. Each line writes a `purchase` movement through the
 * StockMovementService (single source of truth).
 */
class GrnService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly StockMovementService $stock,
        private readonly UnitConversionService $units,
        private readonly BatchService $batches,
        private readonly SerialService $serials,
        private readonly SupplierService $suppliers,
        private readonly PurchaseOrderService $purchaseOrders,
    ) {}

    /**
     * @param  array{branch_id:int,supplier_document_id?:int,supplier_id?:int,purchase_order_id?:int,reference?:string,note?:string}  $header
     * @param  array<int,array{product_id:int,quantity:float,unit_cost:float|null,sale_price:float|null}>  $lines
     */
    public function create(array $header, array $lines): Grn
    {
        return DB::transaction(function () use ($header, $lines) {
            $reference = $header['reference'] ?? null;
            if (blank($reference)) {
                $reference = $this->numbers->next('grn_reference');
            }

            $grn = Grn::create([
                'grn_number' => $this->numbers->next('grn'),
                'branch_id' => $header['branch_id'],
                'supplier_document_id' => $header['supplier_document_id'] ?? null,
                'supplier_id' => $header['supplier_id'] ?? null,
                'purchase_order_id' => $header['purchase_order_id'] ?? null,
                'reference' => $reference,
                'note' => $header['note'] ?? null,
                'grn_status_id' => GrnStatus::DRAFT,
            ]);

            foreach ($lines as $line) {
                // A line carries no batch number of its own. The lot's
                // internal BATCH-{seq} identifier (DocumentNumberService) is
                // generated once, at confirm, and is the sole identifier a
                // received consignment ever gets.
                GrnItem::create([
                    'grn_id' => $grn->id,
                    'product_id' => $line['product_id'],
                    'unit_id' => $line['unit_id'] ?? null,
                    'expiry_date' => $line['expiry_date'] ?? null,
                    'manufacture_date' => $line['manufacture_date'] ?? null,
                    'batch_cost' => $line['batch_cost'] ?? null,
                    'serial_numbers' => $line['serial_numbers'] ?? null,
                    'quantity' => $line['quantity'],
                    'unit_cost' => $line['unit_cost'] ?? null,
                    'sale_price' => $line['sale_price'] ?? null,
                ]);
            }

            $grn->load('branch:id,name');

            AuditLog::record('grn.created', $grn, [
                'grn_number' => $grn->grn_number,
                'branch' => $grn->branch?->name,
                'reference' => $reference,
                'items_count' => count($lines),
                'note' => $header['note'] ?? null,
                'status' => 'draft',
            ]);

            return $grn;
        });
    }

    /**
     * Confirm a draft GRN: records all stock movements and creates the lots
     * that now own the received stock's pricing.
     *
     * Every confirmed line creates one lot — serials on the line or not —
     * and the lot carries the price we intend to sell at (`sale_price`)
     * and the cost we paid (`unit_cost` / `batch_cost`). A line without a
     * `sale_price` is rejected: the lot, not the product, is the source of
     * truth for what a consignment sells at, and an unpriced lot is
     * unsellable at the POS until a later receipt prices it.
     */
    public function confirm(Grn $grn, int $confirmedByUserId): Grn
    {
        if (! $grn->isDraft()) {
            throw new \InvalidArgumentException('Only draft GRNs can be confirmed.');
        }

        return DB::transaction(function () use ($grn, $confirmedByUserId) {
            $grn->load([
                'items.product:id,name,sku,base_unit_id,is_batch_tracked,is_serial_tracked,is_expiry_tracked',
                'branch:id,name',
            ]);

            $itemSummaries = [];
            $totalCost = 0.0;
            $receivedByProductId = [];

            foreach ($grn->items as $item) {
                $productName = $item->product?->name ?? "Product#{$item->product_id}";
                $product = $item->product;

                $baseQty = $product && $item->unit_id
                    ? $this->units->toBaseUnit($product, (int) $item->unit_id, (float) $item->quantity)
                    : (float) $item->quantity;

                // The lot, not the product, is the price source of truth: a
                // lot with no sale price on file is unsellable at the POS
                // until a later receipt prices it. Refuse to create one — the
                // operator must price the line before the GRN can be
                // confirmed.
                if ($item->sale_price === null || (float) $item->sale_price <= 0) {
                    throw StockReferenceException::lot(
                        $item->product_id,
                        "Product {$productName}: no sale price on file — set a selling price on this GRN line before confirming.",
                    );
                }

                $isBatchTracked = (bool) ($product?->is_batch_tracked ?? false);

                // Batch-tracked products keep per-receipt lots with individual
                // manufacture/expiry dates and pricing; non-batch products are a
                // single unified pool — upsert one lot and overwrite its price.
                if ($isBatchTracked) {
                    $batch = $this->batches->createFromGrn([
                        'branch_id' => $grn->branch_id,
                        'product_id' => $item->product_id,
                        'manufacture_date' => $item->manufacture_date?->toDateString(),
                        'expiry_date' => $item->expiry_date?->toDateString(),
                        'received_quantity' => $baseQty,
                        'batch_cost' => isset($item->batch_cost) ? (float) $item->batch_cost : (isset($item->unit_cost) ? (float) $item->unit_cost : null),
                        'sale_price' => isset($item->sale_price) ? (float) $item->sale_price : null,
                        'grn_item_id' => $item->id,
                        'grn_id' => $grn->id,
                        'notes' => null,
                    ]);
                } else {
                    $batch = $this->batches->upsertUnifiedFromGrn([
                        'branch_id' => $grn->branch_id,
                        'product_id' => $item->product_id,
                        'received_quantity' => $baseQty,
                        'batch_cost' => isset($item->batch_cost) ? (float) $item->batch_cost : (isset($item->unit_cost) ? (float) $item->unit_cost : null),
                        'sale_price' => isset($item->sale_price) ? (float) $item->sale_price : null,
                        'grn_item_id' => $item->id,
                        'grn_id' => $grn->id,
                    ]);
                }

                $stockBatchId = $batch->id;
                $item->update(['stock_batch_id' => $stockBatchId]);

                // A line that actually carries serial numbers must carry one
                // per unit — each unit is a discrete serial. Blank means this
                // consignment simply isn't serialized (data-driven, no mode) —
                // EXCEPT for products flagged serial-tracked, where every
                // unit must be named: the flag declares the intent to capture
                // serials, so a receipt without them is refused.
                $serialNumbers = SerialInput::numbers($item->serial_numbers);
                $createdSerialIds = null;
                if ($serialNumbers !== []) {
                    $serialCount = count($serialNumbers);
                    if (abs($serialCount - $baseQty) > 0.0001) {
                        throw StockReferenceException::serial(
                            $item->product_id,
                            "Product {$productName}: expected {$baseQty} serial number(s) but received {$serialCount}",
                        );
                    }

                    $createdSerials = $this->serials->createFromGrn(
                        productId: $item->product_id,
                        stockBatchId: $stockBatchId,
                        branchId: $grn->branch_id,
                        serialNumbers: $serialNumbers,
                    );
                    $createdSerialIds = array_map(fn ($s) => $s->id, $createdSerials);
                } elseif ($product?->is_serial_tracked) {
                    throw StockReferenceException::serial(
                        $item->product_id,
                        "Product {$productName}: expected {$baseQty} serial number(s) but received 0",
                    );
                }

                // The same strict rule for expiry: a batch-tracked, expiry-tracked
                // product declares that FEFO picking needs a date on every receipt,
                // so a line without one is refused. Non-batch or non-expiry products
                // have no expiry field at all and sell from the unified pool / FIFO.
                if ($product?->is_batch_tracked && $product?->is_expiry_tracked && ! $item->expiry_date) {
                    throw StockReferenceException::expiry(
                        $item->product_id,
                        "Product {$productName}: expected an expiry date but received none — set the expiry date on this GRN line before confirming.",
                    );
                }

                $movement = $this->stock->record(
                    branchId: $grn->branch_id,
                    productId: $item->product_id,
                    movementTypeCode: MovementType::PURCHASE,
                    quantityChange: $baseQty,
                    unitCost: isset($item->unit_cost) ? (float) $item->unit_cost : null,
                    referenceType: $grn,
                    referenceNumber: $grn->grn_number,
                    productName: $productName,
                    branchName: $grn->branch?->name,
                    stockBatchId: $stockBatchId,
                );

                if ($createdSerialIds) {
                    $this->serials->linkToMovement($movement, $createdSerialIds);
                }

                $receivedByProductId[$item->product_id] = ($receivedByProductId[$item->product_id] ?? 0) + $baseQty;

                if ($item->unit_cost !== null) {
                    $totalCost += (float) $item->unit_cost * (float) $item->quantity;
                }

                $summary = [
                    'product' => $productName,
                    'qty' => (float) $item->quantity,
                    'batch' => $batch->internal_batch_number,
                    'sale_price' => isset($item->sale_price) ? (float) $item->sale_price : null,
                ];

                $itemSummaries[] = $summary;
            }

            if ($grn->supplier_id && $totalCost > 0) {
                $this->suppliers->debit(Supplier::findOrFail($grn->supplier_id), $totalCost, $grn, "GRN {$grn->grn_number} received");
            }

            if ($grn->purchase_order_id) {
                $this->purchaseOrders->applyGrnReceipt(PurchaseOrder::findOrFail($grn->purchase_order_id), $receivedByProductId);
            }

            $grn->update([
                'grn_status_id' => GrnStatus::CONFIRMED,
                'confirmed_by' => $confirmedByUserId,
                'confirmed_at' => now(),
            ]);

            AuditLog::record('grn.confirmed', $grn, [
                'grn_number' => $grn->grn_number,
                'branch' => $grn->branch?->name,
                'items_count' => $grn->items->count(),
                'items' => $itemSummaries,
            ]);

            return $grn;
        });
    }

    /**
     * Delete a GRN (admin-only).
     * Reverses stock if the GRN was confirmed; always sets status to Deleted.
     * Does not physically remove the record — the row remains visible in the table.
     */
    public function delete(Grn $grn, int $deletedByUserId): void
    {
        if ($grn->isDeleted()) {
            throw new \InvalidArgumentException('GRN is already deleted.');
        }

        DB::transaction(function () use ($grn, $deletedByUserId) {
            $stockReversed = $grn->isConfirmed();

            if ($stockReversed) {
                $grn->load([
                    'items.product:id,name,base_unit_id',
                    'items.stockBatch',
                    'branch:id,name',
                ]);

                $totalCost = 0.0;

                foreach ($grn->items as $item) {
                    $baseQty = $item->product && $item->unit_id
                        ? $this->units->toBaseUnit($item->product, (int) $item->unit_id, (float) $item->quantity)
                        : (float) $item->quantity;

                    // Reverse batch available_quantity when batch-tracked.
                    if ($item->stockBatch) {
                        $this->batches->consume($item->stockBatch, $baseQty);
                    }

                    $this->stock->record(
                        branchId: $grn->branch_id,
                        productId: $item->product_id,
                        movementTypeCode: MovementType::GRN_REVERSAL,
                        quantityChange: -$baseQty,
                        unitCost: isset($item->unit_cost) ? (float) $item->unit_cost : null,
                        referenceType: $grn,
                        referenceNumber: $grn->grn_number,
                        note: "Admin deletion reversal of GRN {$grn->grn_number}",
                        productName: $item->product?->name ?? "Product#{$item->product_id}",
                        branchName: $grn->branch?->name,
                        stockBatchId: $item->stock_batch_id,
                    );

                    if (isset($item->unit_cost) && $item->unit_cost !== null) {
                        $totalCost += (float) $item->unit_cost * (float) $item->quantity;
                    }
                }

                if ($grn->supplier_id && $totalCost > 0) {
                    $this->suppliers->credit(
                        Supplier::findOrFail($grn->supplier_id),
                        $totalCost,
                        $grn,
                        "Reversal of GRN {$grn->grn_number} deletion",
                    );
                }
            } else {
                $grn->load('branch:id,name');
            }

            AuditLog::record('grn.deleted', $grn, [
                'grn_number' => $grn->grn_number,
                'branch' => $grn->branch?->name,
                'previous_status' => $grn->grnStatus?->name ?? 'unknown',
                'items_count' => $grn->items->count(),
                'stock_reversed' => $stockReversed,
            ], $deletedByUserId);

            // Set status to Deleted and stamp the deletion audit columns.
            // The record is NOT physically removed — it stays visible in the table.
            $grn->grn_status_id = GrnStatus::DELETED;
            $grn->deleted_by = $deletedByUserId;
            $grn->deleted_at = now();
            $grn->saveQuietly();
        });
    }
}
