<?php

namespace App\Services;

use App\Models\Catalog\Product;
use App\Models\Inventory\BarcodeLabel;
use App\Models\Inventory\ProductSerial;
use App\Models\Inventory\StockBatch;
use App\Models\Scopes\BranchScope;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Owns the two ends of a printed barcode label: making one, and reading one.
 *
 * Everything the terminal would otherwise have to ask the cashier — which lot,
 * which serial, how many — is decided once at {@see create()} and baked into
 * the code. {@see resolve()} then turns a scan back into a finished cart line,
 * which is why neither end may guess: a label whose lot has since been sold
 * out, deactivated or lost its price is refused with a reason, never quietly
 * repriced or re-pointed at another lot.
 */
class BarcodeLabelService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly UnitConversionService $units,
    ) {}

    /**
     * @param  array{
     *     product_id: int, branch_id: int|null, stock_batch_id: int|null,
     *     product_serial_id: int|null, quantity: float|null, unit_id: int|null,
     *     label_name: string|null, note: string|null,
     * }  $data
     *
     * @throws RuntimeException when the label would not be scannable as one exact thing
     */
    public function create(array $data): BarcodeLabel
    {
        $product = Product::findOrFail($data['product_id']);
        $branchId = isset($data['branch_id']) ? (int) $data['branch_id'] : null;
        $batchId = isset($data['stock_batch_id']) ? (int) $data['stock_batch_id'] : null;
        $serialId = isset($data['product_serial_id']) ? (int) $data['product_serial_id'] : null;

        $this->assertTrackingSatisfied($product, $batchId, $serialId);

        // A serial already belongs to the consignment it was received on, so
        // the lot is a fact about it, not a second choice to make.
        if ($serialId !== null) {
            $batchId = ProductSerial::withoutGlobalScope(BranchScope::class)
                ->whereKey($serialId)
                ->value('stock_batch_id') ?? $batchId;
        }

        $quantity = (float) ($data['quantity'] ?? 1);
        if ($quantity <= 0) {
            throw new RuntimeException('A label must carry a quantity greater than zero.');
        }

        // One serial is one physical item, so its label can only ever mean one
        // of it — a "3" on a serial label would put three of the same serial on
        // the cart, which no checkout can settle.
        if ($serialId !== null && abs($quantity - 1.0) > 0.0001) {
            throw new RuntimeException('A serial-numbered label always stands for exactly one item.');
        }

        $unitId = isset($data['unit_id']) ? (int) $data['unit_id'] : (int) $product->base_unit_id;

        return BarcodeLabel::create([
            'code' => $this->numbers->next('barcode_label'),
            'product_id' => $product->id,
            'branch_id' => $branchId,
            'stock_batch_id' => $batchId,
            'product_serial_id' => $serialId,
            'quantity' => $quantity,
            'unit_id' => $unitId ?: null,
            'base_quantity' => $unitId
                ? $this->units->toBaseUnit($product, $unitId, $quantity)
                : $quantity,
            'label_name' => $data['label_name'] ?? null,
            'note' => $data['note'] ?? null,
            'is_active' => true,
        ]);
    }

    /**
     * Turn a scanned code into everything a terminal needs to add the line
     * without asking a single question.
     *
     * Deliberately re-derives the lot, its price and its stock live rather than
     * trusting anything printed: a label is a pointer to stock, and stock moves
     * after the label is stuck on. The returned `unit_price` is still only what
     * the terminal displays — InvoiceService reprices from the same lot at
     * checkout, so a label can never smuggle a price in.
     *
     * @return array<string, mixed> `ok:false` plus a cashier-readable `message`
     *                              when the label cannot be sold as printed.
     */
    public function resolve(string $code, int $branchId): array
    {
        $label = BarcodeLabel::query()
            ->withoutGlobalScope(BranchScope::class)
            ->with(['product.baseUnit', 'batch', 'serial', 'unit'])
            ->where('code', trim($code))
            ->first();

        if (! $label || ! $label->is_active) {
            return ['ok' => false, 'message' => 'That label is not on file. It may have been deleted or printed on another system.'];
        }

        // A label prints against the stock of one branch: honouring one at
        // another till would sell a lot that is not standing there.
        if ($label->branch_id !== null && (int) $label->branch_id !== $branchId) {
            return ['ok' => false, 'message' => 'This label was printed for another branch.'];
        }

        $product = $label->product;
        if (! $product || ! $product->is_active) {
            return ['ok' => false, 'message' => 'The product on this label is no longer active.'];
        }

        $lot = $this->sellableLotFor($label, $branchId);
        if (! $lot) {
            return ['ok' => false, 'message' => 'The lot on this label has no sellable stock with a price at this branch.'];
        }

        $serialIds = [];
        if ($label->product_serial_id !== null) {
            $serial = $label->serial;
            if (! $serial || ! $serial->isAvailable() || (int) $serial->branch_id !== $branchId) {
                return ['ok' => false, 'message' => 'The serial on this label is no longer available at this branch.'];
            }
            $serialIds[] = (int) $serial->id;
        }

        $quantity = round((float) $label->base_quantity, 4);

        return [
            'ok' => true,
            'label_id' => $label->id,
            'code' => $label->code,
            'product_id' => (int) $product->id,
            'product_name' => $label->displayName(),
            'quantity' => $quantity,
            'serial_ids' => $serialIds,
            'lot' => [
                'id' => $lot->id,
                'internal_batch_number' => $lot->internal_batch_number,
                'sale_price' => (float) $lot->sale_price,
                'expiry_date' => $lot->expiry_date?->toDateString(),
                'available_quantity' => (float) $lot->available_quantity,
            ],
        ];
    }

    /**
     * Records that labels went to a printer — the count is the only way to tell
     * "never printed" from "reprinted after a jam" when a shelf label goes
     * missing.
     *
     * @param  Collection<int, BarcodeLabel>  $labels
     */
    public function markPrinted(Collection $labels): void
    {
        if ($labels->isEmpty()) {
            return;
        }

        BarcodeLabel::query()
            ->withoutGlobalScope(BranchScope::class)
            ->whereIn('id', $labels->pluck('id'))
            ->update([
                'printed_count' => DB::raw('printed_count + 1'),
                'last_printed_at' => now(),
            ]);
    }

    /**
     * The lot a scan of this label sells from.
     *
     * A batch-tracked label names its own lot and must use that one or nothing:
     * re-pointing it at whatever is FEFO-front today would sell a different
     * consignment, at a different price, than the one the label is stuck to.
     * A non-batch product has a single unified pool instead, so its label
     * resolves against that pool live.
     */
    private function sellableLotFor(BarcodeLabel $label, int $branchId): ?StockBatch
    {
        if ($label->stock_batch_id !== null) {
            $lot = StockBatch::query()
                ->withoutGlobalScope(BranchScope::class)
                ->where('id', $label->stock_batch_id)
                ->where('branch_id', $branchId)
                ->active()
                ->where('available_quantity', '>', 0)
                ->whereNotNull('sale_price')
                ->first();

            return $lot;
        }

        return app(BatchService::class)->unifiedBatchFor((int) $label->product_id, $branchId)
            ?? StockBatch::query()
                ->withoutGlobalScope(BranchScope::class)
                ->where('product_id', $label->product_id)
                ->where('branch_id', $branchId)
                ->active()
                ->where('available_quantity', '>', 0)
                ->whereNotNull('sale_price')
                ->first();
    }

    /**
     * A label only removes questions from the till if it answers every one the
     * product's tracking would ask. A batch-tracked product without a lot on
     * the label still opens the lot picker on scan; a serial-tracked one
     * without a serial still opens the serial picker. Both defeat the point,
     * so neither is allowed to be printed in the first place.
     */
    private function assertTrackingSatisfied(Product $product, ?int $batchId, ?int $serialId): void
    {
        if ($product->is_batch_tracked && $batchId === null && $serialId === null) {
            throw new RuntimeException('This product is batch-tracked — pick the lot the label is for.');
        }

        if ($product->is_serial_tracked && $serialId === null) {
            throw new RuntimeException('This product is serial-tracked — pick the serial number the label is for.');
        }

        if ($serialId !== null) {
            $serial = ProductSerial::withoutGlobalScope(BranchScope::class)->find($serialId);
            if (! $serial || (int) $serial->product_id !== (int) $product->id) {
                throw new RuntimeException('That serial number does not belong to this product.');
            }
        }

        if ($batchId !== null) {
            $lot = StockBatch::withoutGlobalScope(BranchScope::class)->find($batchId);
            if (! $lot || (int) $lot->product_id !== (int) $product->id) {
                throw new RuntimeException('That lot does not belong to this product.');
            }
        }
    }
}
