<?php

declare(strict_types=1);

namespace App\Business\Inventory;

use App\Business\Inventory\Enums\InventoryBusinessType;
use App\Business\Inventory\Enums\InventoryChangeDirection;
use App\Business\Inventory\Exceptions\InsufficientStockException;
use App\Business\Product\ProductBusinessSnapshot;
use App\Models\DberpInventoryBalance;
use App\Models\DberpInventoryTransaction;
use App\Models\DberpProduct;
use App\Models\DberpProductSku;
use App\Models\DberpUnit;
use App\Models\DberpWarehouse;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class InventoryLedgerService
{
    public function __construct(
        private readonly InventoryRuleService $ruleService,
    ) {}

    public function inbound(
        DberpProductSku $productSku,
        DberpWarehouse $warehouse,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
        string $idempotencyKey,
        ?InventoryLedgerLineTrace $lineTrace = null,
    ): InventoryLedgerPostResult {
        $this->assertIdempotencyKey($idempotencyKey);

        return DB::transaction(function () use ($productSku, $warehouse, $quantity, $businessType, $businessNumber, $sourceType, $sourceId, $remark, $businessTime, $idempotencyKey, $lineTrace): InventoryLedgerPostResult {
            $balance = $this->lockBalance($productSku, $warehouse);
            $product = $productSku->relationLoaded('product') ? $productSku->product : $productSku->product()->firstOrFail();

            return $this->post(
                balance: $balance,
                productSku: $productSku,
                product: $product,
                warehouse: $warehouse,
                direction: InventoryChangeDirection::In,
                quantity: $quantity,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                remark: $remark,
                businessTime: $businessTime,
                idempotencyKey: $idempotencyKey,
                lineTrace: $lineTrace,
            );
        });
    }

    public function outbound(
        DberpProductSku $productSku,
        DberpWarehouse $warehouse,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
        string $idempotencyKey,
        ?InventoryLedgerLineTrace $lineTrace = null,
    ): InventoryLedgerPostResult {
        $this->assertIdempotencyKey($idempotencyKey);

        return DB::transaction(function () use ($productSku, $warehouse, $quantity, $businessType, $businessNumber, $sourceType, $sourceId, $remark, $businessTime, $idempotencyKey, $lineTrace): InventoryLedgerPostResult {
            $balance = $this->lockBalance($productSku, $warehouse);
            $product = $productSku->relationLoaded('product') ? $productSku->product : $productSku->product()->firstOrFail();

            return $this->post(
                balance: $balance,
                productSku: $productSku,
                product: $product,
                warehouse: $warehouse,
                direction: InventoryChangeDirection::Out,
                quantity: $quantity,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                remark: $remark,
                businessTime: $businessTime,
                idempotencyKey: $idempotencyKey,
                lineTrace: $lineTrace,
            );
        });
    }

    /**
     * 出库前预检：按仓库与规格组合读取账面（不加锁、不创建余额行），用于汇总多条明细的库存不足提示。
     * 最终扣减仍以 {@see outbound()} 加锁过账为准。
     *
     * @param  list<array{sku: DberpProductSku, warehouse: DberpWarehouse, quantity: string}>  $lines
     */
    public function assertNoOutboundShortagesPreflight(
        array $lines,
        InventoryBusinessType $businessType,
        string $businessNumber,
    ): void {
        $failures = [];

        foreach ($lines as $line) {
            $exception = $this->assessOutboundShortageWithoutLock(
                productSku: $line['sku'],
                warehouse: $line['warehouse'],
                quantity: $line['quantity'],
                businessType: $businessType,
                businessNumber: $businessNumber,
            );

            if ($exception !== null) {
                $failures[] = $exception;
            }
        }

        if ($failures === []) {
            return;
        }

        if (count($failures) === 1) {
            throw $failures[0];
        }

        $labels = [];

        foreach ($failures as $failure) {
            $labels[] = $failure->displayProductLabel;
        }

        $labels = array_values(array_unique($labels));

        throw ValidationException::withMessages([
            'confirm' => (string) __('dberp.errors.outbound_insufficient_stock_multi', [
                'products' => implode('、', $labels),
            ]),
        ]);
    }

    public function assessOutboundShortageWithoutLock(
        DberpProductSku $productSku,
        DberpWarehouse $warehouse,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
    ): ?InsufficientStockException {
        if ($this->ruleService->allowNegativeStock()) {
            return null;
        }

        $change = $this->toScaledInteger($quantity);

        if ($change <= 0) {
            return null;
        }

        $balance = DberpInventoryBalance::query()
            ->where('product_sku_id', $productSku->getKey())
            ->where('warehouse_id', $warehouse->getKey())
            ->first();

        $before = $balance !== null ? $this->toScaledInteger((string) $balance->realtime_quantity) : 0;
        $occupied = $balance !== null ? $this->toScaledInteger((string) $balance->occupied_quantity) : 0;
        $after = $before - $change;

        if ($after >= 0) {
            return null;
        }

        $product = $productSku->relationLoaded('product') ? $productSku->product : $productSku->product()->firstOrFail();
        $available = $this->fromScaledInteger($before - $occupied);

        return InsufficientStockException::fromModels(
            product: $product,
            warehouse: $warehouse,
            required: $this->fromScaledInteger($change),
            available: $available,
            businessType: $businessType,
            businessNumber: $businessNumber,
            productSku: $productSku,
        );
    }

    private function assertIdempotencyKey(string $idempotencyKey): void
    {
        if ($idempotencyKey === '') {
            throw ValidationException::withMessages([
                'idempotency_key' => __('dberp.errors.inventory_idempotency_key_required'),
            ]);
        }
    }

    private function lockBalance(DberpProductSku $productSku, DberpWarehouse $warehouse): DberpInventoryBalance
    {
        $balance = DberpInventoryBalance::query()
            ->where('product_sku_id', $productSku->getKey())
            ->where('warehouse_id', $warehouse->getKey())
            ->lockForUpdate()
            ->first();

        if ($balance !== null) {
            return $balance;
        }

        $balance = new DberpInventoryBalance;
        $balance->fill([
            'product_id' => $productSku->product_id,
            'product_sku_id' => $productSku->getKey(),
            'warehouse_id' => $warehouse->getKey(),
            'realtime_quantity' => '0.0000',
            'available_quantity' => '0.0000',
            'occupied_quantity' => '0.0000',
            'safety_stock' => '0.0000',
            'last_moved_at' => null,
        ]);
        $balance->save();

        return DberpInventoryBalance::query()
            ->whereKey($balance->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function post(
        DberpInventoryBalance $balance,
        DberpProductSku $productSku,
        DberpProduct $product,
        DberpWarehouse $warehouse,
        InventoryChangeDirection $direction,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
        string $idempotencyKey,
        ?InventoryLedgerLineTrace $lineTrace = null,
    ): InventoryLedgerPostResult {
        $existing = DberpInventoryTransaction::query()
            ->where('idempotency_key', $idempotencyKey)
            ->lockForUpdate()
            ->first();

        if ($existing !== null) {
            $this->assertIdempotentPayloadMatches($existing, $productSku, $warehouse, $direction, $quantity, $businessType, $businessNumber, $sourceType, $sourceId);

            return new InventoryLedgerPostResult($existing, true);
        }

        $before = $this->toScaledInteger((string) $balance->realtime_quantity);
        $occupied = $this->toScaledInteger((string) $balance->occupied_quantity);
        $change = $this->toScaledInteger($quantity);

        if ($change <= 0) {
            throw ValidationException::withMessages([
                'confirm' => __('dberp.errors.inventory_quantity_must_be_positive'),
            ]);
        }

        $after = $direction === InventoryChangeDirection::In ? $before + $change : $before - $change;

        if ($direction === InventoryChangeDirection::Out && ! $this->ruleService->allowNegativeStock() && $after < 0) {
            $available = $this->fromScaledInteger($before - $occupied);

            throw InsufficientStockException::fromModels(
                product: $product,
                warehouse: $warehouse,
                required: $this->fromScaledInteger($change),
                available: $available,
                businessType: $businessType,
                businessNumber: $businessNumber,
                productSku: $productSku,
            );
        }

        $balance->forceFill([
            'realtime_quantity' => $this->fromScaledInteger($after),
            'available_quantity' => $this->fromScaledInteger($after - $occupied),
            'last_moved_at' => Carbon::instance((new \DateTimeImmutable)->setTimestamp($businessTime->getTimestamp())),
        ])->save();

        $snap = ProductBusinessSnapshot::fromSku($productSku, $product);

        $accountingUnitId = (int) ($product->unit_id ?? 0);
        if ($lineTrace !== null && $lineTrace->postingBaseUnitId > 0) {
            $accountingUnitId = $lineTrace->postingBaseUnitId;
        }

        if ($accountingUnitId <= 0) {
            $accountingUnitId = (int) ($snap['unit_id'] ?? 0);
        }

        $accountingUnitName = DberpUnit::query()->whereKey($accountingUnitId)->value('name');
        if ($accountingUnitName === null || $accountingUnitName === '') {
            $accountingUnitName = (string) ($snap['unit_name'] ?? '');
        }

        $traceAttributes = $lineTrace?->toTransactionAttributes() ?? [];

        $created = DberpInventoryTransaction::query()->create(array_merge([
            'product_id' => $snap['product_id'],
            'product_sku_id' => $snap['product_sku_id'],
            'product_code' => $snap['product_code'],
            'product_name' => $snap['product_name'],
            'sku_code' => $snap['sku_code'],
            'spec_text' => $snap['spec_text'],
            'unit_id' => $accountingUnitId,
            'unit_name' => $accountingUnitName,
            'warehouse_id' => $warehouse->getKey(),
            'direction' => $direction->value,
            'change_quantity' => $this->fromScaledInteger($change),
            'before_quantity' => $this->fromScaledInteger($before),
            'after_quantity' => $this->fromScaledInteger($after),
            'business_type' => $businessType->value,
            'business_number' => $businessNumber,
            'source_type' => $sourceType,
            'source_id' => $sourceId,
            'operator_user_id' => Auth::id(),
            'business_time' => $businessTime,
            'remark' => $remark,
            'idempotency_key' => $idempotencyKey,
        ], $traceAttributes));

        return new InventoryLedgerPostResult($created, false);
    }

    private function assertIdempotentPayloadMatches(
        DberpInventoryTransaction $existing,
        DberpProductSku $productSku,
        DberpWarehouse $warehouse,
        InventoryChangeDirection $direction,
        string $quantity,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
    ): void {
        $mismatch = (int) $existing->product_sku_id !== (int) $productSku->getKey()
            || (int) $existing->warehouse_id !== (int) $warehouse->getKey()
            || (string) $existing->direction !== $direction->value
            || (string) $existing->business_type !== $businessType->value
            || (string) $existing->business_number !== $businessNumber
            || (string) $existing->source_type !== $sourceType
            || (int) $existing->source_id !== $sourceId;

        if (! $mismatch && $this->toScaledInteger((string) $existing->change_quantity) !== $this->toScaledInteger($quantity)) {
            $mismatch = true;
        }

        if ($mismatch) {
            throw ValidationException::withMessages([
                'idempotency_key' => __('dberp.errors.inventory_idempotency_conflict'),
            ]);
        }
    }

    private function toScaledInteger(string $quantity): int
    {
        return (int) round(((float) $quantity) * 10000);
    }

    private function fromScaledInteger(int $quantity): string
    {
        return number_format($quantity / 10000, 4, '.', '');
    }
}
