<?php

declare(strict_types=1);

/**
 * 本文件属于 DBErp 进销存系统。
 *
 * 版权所有 © 2026 北京珑大钜商科技有限公司。保留所有权利。
 *
 * 未经官方授权，不得删除、篡改或隐藏本文件中的版权声明、产品标识与来源信息。
 * 商业授权、官方更新与技术支持服务，请以珑大官方支持中心记录为准。
 *
 * @link https://support.loongdom.cn
 * @see  https://www.dberp.com.cn
 */

namespace App\Business\Inventory;

use App\Business\Inventory\Enums\InventoryBatchStatus;
use App\Business\Inventory\Enums\InventoryBusinessType;
use App\Business\Inventory\Enums\InventoryExpiryStatus;
use App\Models\DberpInventoryBatch;
use App\Models\DberpInventoryBatchMovement;
use App\Models\DberpProduct;
use App\Models\DberpWarehouse;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

/**
 * 批次数量字段与表 dberp_inventory_batches 一致：realtime / available / locked；不在此处维护历史「累计收货」列。
 */
final class InventoryBatchService
{
    public function __construct(
        private readonly InventoryExpiryService $expiryService,
    ) {}

    public function query(): Builder
    {
        return DberpInventoryBatch::query()->with(['product', 'warehouse']);
    }

    /**
     * @return Collection<int, DberpInventoryBatch>
     */
    public function allocationCandidates(DberpProduct $product, DberpWarehouse $warehouse): Collection
    {
        return $this->query()
            ->where('product_id', $product->getKey())
            ->where('warehouse_id', $warehouse->getKey())
            ->where('status', InventoryBatchStatus::Active->value)
            ->where('is_enabled', true)
            ->where('available_quantity', '>', 0)
            ->orderByRaw('CASE WHEN expiry_date IS NULL THEN 1 ELSE 0 END')
            ->orderBy('expiry_date')
            ->orderBy('production_date')
            ->orderBy('sort_order')
            ->orderBy('id')
            ->get();
    }

    public function expiryStatus(DberpInventoryBatch $batch): InventoryExpiryStatus
    {
        return $this->expiryService->resolveForBatch($batch);
    }

    public function isSaleBlocked(DberpInventoryBatch $batch): bool
    {
        return $this->expiryService->isBlockedForSale($batch);
    }

    public function receive(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
        ?string $batchNo = null,
        mixed $productionDate = null,
        mixed $expiryDate = null,
        int $shelfLifeDays = 0,
    ): ?DberpInventoryBatch {
        if (! $product->manages_batch) {
            return null;
        }

        $resolvedBatchNo = $this->normalizeBatchNo($batchNo) ?? $this->generateSystemBatchNo($businessNumber, $product, $warehouse);

        $batch = DberpInventoryBatch::query()
            ->where('product_id', $product->getKey())
            ->where('warehouse_id', $warehouse->getKey())
            ->where('batch_no', $resolvedBatchNo)
            ->lockForUpdate()
            ->first();

        if ($batch === null) {
            $batch = new DberpInventoryBatch;
            $batch->forceFill([
                'product_id' => $product->getKey(),
                'warehouse_id' => $warehouse->getKey(),
                'batch_no' => $resolvedBatchNo,
                'source_system' => 'dberp',
                'external_reference_id' => $businessNumber,
                'production_date' => $this->normalizeDate($productionDate),
                'expiry_date' => $this->normalizeDate($expiryDate),
                'shelf_life_days' => max(0, $shelfLifeDays),
                'available_quantity' => '0.0000',
                'locked_quantity' => '0.0000',
                'realtime_quantity' => '0.0000',
                'status' => InventoryBatchStatus::Active->value,
                'is_enabled' => true,
                'sort_order' => 0,
                'remark' => $remark,
            ]);
        } else {
            if ($batch->production_date === null && $productionDate !== null) {
                $batch->production_date = $this->normalizeDate($productionDate);
            }

            if ($batch->expiry_date === null && $expiryDate !== null) {
                $batch->expiry_date = $this->normalizeDate($expiryDate);
            }

            if ((int) $batch->shelf_life_days === 0 && $shelfLifeDays > 0) {
                $batch->shelf_life_days = $shelfLifeDays;
            }
        }

        $beforeQuantity = (float) $batch->realtime_quantity;
        $availableQuantity = (float) $batch->available_quantity;
        $deltaQuantity = (float) $quantity;
        $afterQuantity = $beforeQuantity + $deltaQuantity;

        $batch->forceFill([
            'available_quantity' => number_format($availableQuantity + $deltaQuantity, 4, '.', ''),
            'realtime_quantity' => number_format($afterQuantity, 4, '.', ''),
            'status' => $afterQuantity > 0 ? InventoryBatchStatus::Active->value : InventoryBatchStatus::Depleted->value,
            'remark' => $remark ?: $batch->remark,
        ])->save();

        $this->recordMovement(
            batch: $batch,
            direction: 'in',
            changeQuantity: $quantity,
            beforeQuantity: $beforeQuantity,
            afterQuantity: $afterQuantity,
            businessType: $businessType,
            businessNumber: $businessNumber,
            sourceType: $sourceType,
            sourceId: $sourceId,
            businessTime: $businessTime,
            remark: $remark,
        );

        return $batch->refresh();
    }

    /**
     * @param  array<int, array<string, mixed>>  $batchAllocations
     * @return array<int, array<string, mixed>>
     */
    public function issue(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
        ?int $sourceBatchId = null,
        ?string $batchNo = null,
        array $batchAllocations = [],
        string $issuePolicy = 'fifo',
        bool $enforceSaleExpiry = false,
    ): array {
        if (! $product->manages_batch) {
            return [];
        }

        $resolvedAllocations = $this->resolveIssueAllocations(
            product: $product,
            warehouse: $warehouse,
            quantity: $quantity,
            sourceBatchId: $sourceBatchId,
            batchNo: $batchNo,
            batchAllocations: $batchAllocations,
            issuePolicy: $issuePolicy,
            enforceSaleExpiry: $enforceSaleExpiry,
        );

        foreach ($resolvedAllocations as $allocation) {
            $batch = DberpInventoryBatch::query()->whereKey((int) $allocation['batch_id'])->lockForUpdate()->firstOrFail();
            $beforeQuantity = (float) $batch->realtime_quantity;
            $availableQuantity = (float) $batch->available_quantity;
            $deltaQuantity = (float) $allocation['quantity'];
            $afterQuantity = $beforeQuantity - $deltaQuantity;

            if ($availableQuantity < $deltaQuantity || $afterQuantity < -0.0001) {
                throw ValidationException::withMessages([
                    'batch_no' => __('dberp.errors.inventory_batch_quantity_not_enough'),
                ]);
            }

            $batch->forceFill([
                'available_quantity' => number_format(max(0, $availableQuantity - $deltaQuantity), 4, '.', ''),
                'realtime_quantity' => number_format(max(0, $afterQuantity), 4, '.', ''),
                'status' => $afterQuantity > 0 ? InventoryBatchStatus::Active->value : InventoryBatchStatus::Depleted->value,
            ])->save();

            $this->recordMovement(
                batch: $batch,
                direction: 'out',
                changeQuantity: number_format($deltaQuantity, 4, '.', ''),
                beforeQuantity: $beforeQuantity,
                afterQuantity: max(0, $afterQuantity),
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }

        return $resolvedAllocations;
    }

    /**
     * @param  array<int, array<string, mixed>>  $allocations
     */
    public function restoreFromAllocations(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        array $allocations,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
    ): void {
        if (! $product->manages_batch || $allocations === []) {
            return;
        }

        foreach ($allocations as $allocation) {
            $batch = isset($allocation['batch_id'])
                ? DberpInventoryBatch::query()->whereKey((int) $allocation['batch_id'])->lockForUpdate()->first()
                : null;

            if ($batch === null) {
                $resolvedBatchNo = $this->normalizeBatchNo(isset($allocation['batch_no']) ? (string) $allocation['batch_no'] : null);

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

                $batch = DberpInventoryBatch::query()
                    ->where('product_id', $product->getKey())
                    ->where('warehouse_id', $warehouse->getKey())
                    ->where('batch_no', $resolvedBatchNo)
                    ->lockForUpdate()
                    ->first();

                if ($batch === null) {
                    $batch = DberpInventoryBatch::query()->create([
                        'product_id' => $product->getKey(),
                        'warehouse_id' => $warehouse->getKey(),
                        'batch_no' => $resolvedBatchNo,
                        'production_date' => $this->normalizeDate($allocation['production_date'] ?? null),
                        'expiry_date' => $this->normalizeDate($allocation['expiry_date'] ?? null),
                        'shelf_life_days' => (int) ($allocation['shelf_life_days'] ?? 0),
                        'source_system' => 'dberp',
                        'external_reference_id' => $businessNumber,
                        'available_quantity' => '0.0000',
                        'locked_quantity' => '0.0000',
                        'realtime_quantity' => '0.0000',
                        'status' => InventoryBatchStatus::Active->value,
                        'is_enabled' => true,
                        'sort_order' => 0,
                    ]);
                }
            }

            $beforeQuantity = (float) $batch->realtime_quantity;
            $availableQuantity = (float) $batch->available_quantity;
            $deltaQuantity = (float) ($allocation['quantity'] ?? 0);
            $afterQuantity = $beforeQuantity + $deltaQuantity;

            $batch->forceFill([
                'available_quantity' => number_format($availableQuantity + $deltaQuantity, 4, '.', ''),
                'realtime_quantity' => number_format($afterQuantity, 4, '.', ''),
                'status' => InventoryBatchStatus::Active->value,
            ])->save();

            $this->recordMovement(
                batch: $batch,
                direction: 'rollback_in',
                changeQuantity: number_format($deltaQuantity, 4, '.', ''),
                beforeQuantity: $beforeQuantity,
                afterQuantity: $afterQuantity,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }
    }

    private function normalizeBatchNo(?string $batchNo): ?string
    {
        $resolved = trim((string) $batchNo);

        return $resolved === '' ? null : Str::upper($resolved);
    }

    private function generateSystemBatchNo(string $businessNumber, DberpProduct $product, DberpWarehouse $warehouse): string
    {
        return Str::upper(sprintf('SYS-%s-%s-%s', $businessNumber, $product->getKey(), $warehouse->getKey()));
    }

    private function normalizeDate(mixed $value): ?string
    {
        if ($value === null || $value === '') {
            return null;
        }

        return Carbon::parse((string) $value)->format('Y-m-d');
    }

    /**
     * @param  array<int, array<string, mixed>>  $batchAllocations
     * @return array<int, array<string, mixed>>
     */
    private function resolveIssueAllocations(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        string $quantity,
        ?int $sourceBatchId,
        ?string $batchNo,
        array $batchAllocations,
        string $issuePolicy,
        bool $enforceSaleExpiry,
    ): array {
        if ($batchAllocations !== []) {
            return collect($batchAllocations)
                ->map(function (array $allocation) use ($product, $warehouse, $enforceSaleExpiry): array {
                    $batch = DberpInventoryBatch::query()->whereKey((int) ($allocation['batch_id'] ?? 0))->lockForUpdate()->first();

                    if ($batch === null || $batch->product_id !== $product->getKey() || $batch->warehouse_id !== $warehouse->getKey()) {
                        throw ValidationException::withMessages([
                            'batch_allocations' => __('dberp.errors.inventory_batch_not_found'),
                        ]);
                    }

                    $this->guardBatchForIssue($batch, $enforceSaleExpiry);

                    return [
                        'batch_id' => $batch->getKey(),
                        'batch_no' => $batch->batch_no,
                        'quantity' => number_format((float) ($allocation['quantity'] ?? 0), 4, '.', ''),
                        'production_date' => $this->snapshotDate($batch->getAttribute('production_date')),
                        'expiry_date' => $this->snapshotDate($batch->getAttribute('expiry_date')),
                        'shelf_life_days' => (int) $batch->shelf_life_days,
                    ];
                })
                ->values()
                ->all();
        }

        $remaining = (float) $quantity;
        $resolvedAllocations = [];

        $candidates = $sourceBatchId !== null || $batchNo !== null
            ? collect([$this->resolveSpecificCandidate($product, $warehouse, $sourceBatchId, $batchNo, $enforceSaleExpiry)])
            : $this->sortedCandidates($product, $warehouse, $issuePolicy, $enforceSaleExpiry);

        foreach ($candidates as $batch) {
            $availableQuantity = (float) $batch->available_quantity;

            if ($availableQuantity <= 0 || $remaining <= 0) {
                continue;
            }

            $allocatedQuantity = min($availableQuantity, $remaining);

            $resolvedAllocations[] = [
                'batch_id' => $batch->getKey(),
                'batch_no' => $batch->batch_no,
                'quantity' => number_format($allocatedQuantity, 4, '.', ''),
                'production_date' => $this->snapshotDate($batch->getAttribute('production_date')),
                'expiry_date' => $this->snapshotDate($batch->getAttribute('expiry_date')),
                'shelf_life_days' => (int) $batch->shelf_life_days,
            ];

            $remaining -= $allocatedQuantity;
        }

        if ($remaining > 0.0001) {
            throw ValidationException::withMessages([
                'batch_no' => __('dberp.errors.inventory_batch_quantity_not_enough'),
            ]);
        }

        return $resolvedAllocations;
    }

    private function resolveSpecificCandidate(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        ?int $sourceBatchId,
        ?string $batchNo,
        bool $enforceSaleExpiry,
    ): DberpInventoryBatch {
        $query = DberpInventoryBatch::query()
            ->where('product_id', $product->getKey())
            ->where('warehouse_id', $warehouse->getKey())
            ->where('is_enabled', true)
            ->lockForUpdate();

        if ($sourceBatchId !== null) {
            $query->whereKey($sourceBatchId);
        } else {
            $query->where('batch_no', $this->normalizeBatchNo($batchNo));
        }

        $batch = $query->first();

        if ($batch === null) {
            throw ValidationException::withMessages([
                'batch_no' => __('dberp.errors.inventory_batch_not_found'),
            ]);
        }

        $this->guardBatchForIssue($batch, $enforceSaleExpiry);

        return $batch;
    }

    /**
     * @return Collection<int, DberpInventoryBatch>
     */
    private function sortedCandidates(DberpProduct $product, DberpWarehouse $warehouse, string $issuePolicy, bool $enforceSaleExpiry): Collection
    {
        $candidates = $this->allocationCandidates($product, $warehouse)
            ->filter(function (DberpInventoryBatch $batch) use ($enforceSaleExpiry): bool {
                try {
                    $this->guardBatchForIssue($batch, $enforceSaleExpiry);

                    return true;
                } catch (ValidationException) {
                    return false;
                }
            })
            ->values();

        if ($issuePolicy === 'lifo') {
            return $candidates->sortByDesc(fn (DberpInventoryBatch $batch): string => sprintf('%s-%s-%s', (string) ($this->snapshotDate($batch->getAttribute('production_date')) ?? '9999-12-31'), (string) ($this->snapshotDate($batch->getAttribute('expiry_date')) ?? '9999-12-31'), str_pad((string) $batch->getKey(), 10, '0', STR_PAD_LEFT)))->values();
        }

        return $candidates;
    }

    private function snapshotDate(mixed $value): ?string
    {
        if ($value === null || $value === '') {
            return null;
        }

        return Carbon::parse((string) $value)->format('Y-m-d');
    }

    private function guardBatchForIssue(DberpInventoryBatch $batch, bool $enforceSaleExpiry): void
    {
        if (! $batch->is_enabled || $batch->status === InventoryBatchStatus::Disabled->value || $batch->status === InventoryBatchStatus::Voided->value) {
            throw ValidationException::withMessages([
                'batch_no' => __('dberp.errors.inventory_batch_not_usable'),
            ]);
        }

        if ($enforceSaleExpiry && $this->isSaleBlocked($batch)) {
            throw ValidationException::withMessages([
                'batch_no' => __('dberp.errors.inventory_batch_sale_blocked'),
            ]);
        }
    }

    private function recordMovement(
        DberpInventoryBatch $batch,
        string $direction,
        string $changeQuantity,
        float $beforeQuantity,
        float $afterQuantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        \DateTimeInterface $businessTime,
        ?string $remark,
    ): void {
        DberpInventoryBatchMovement::query()->create([
            'batch_id' => $batch->getKey(),
            'product_id' => $batch->product_id,
            'warehouse_id' => $batch->warehouse_id,
            'direction' => $direction,
            'change_quantity' => $changeQuantity,
            'before_quantity' => number_format($beforeQuantity, 4, '.', ''),
            'after_quantity' => number_format($afterQuantity, 4, '.', ''),
            'business_type' => $businessType->value,
            'business_number' => $businessNumber,
            'source_type' => $sourceType,
            'source_id' => $sourceId,
            'business_time' => $businessTime,
            'remark' => $remark,
        ]);
    }
}
