<?php

declare(strict_types=1);

namespace App\Business\Inventory;

use App\Business\Inventory\Enums\InventoryBusinessType;
use App\Business\Inventory\Enums\TransferDocumentStatus;
use App\Business\Inventory\Exceptions\InsufficientStockException;
use App\Business\Product\InventoryPostingProductSkuResolver;
use App\Models\DberpInventoryBalance;
use App\Models\DberpTransferOrder;
use App\Models\DberpTransferOrderItem;
use App\Models\DberpWarehouse;
use App\Services\DberpAuditService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class TransferOrderService
{
    public function __construct(
        private readonly InventoryLedgerService $ledgerService,
        private readonly InventoryRefinementService $refinementService,
        private readonly InventoryRuleService $ruleService,
        private readonly DberpAuditService $auditService,
    ) {}

    public function apply(DberpTransferOrder $order): void
    {
        try {
            DB::transaction(function () use ($order): void {
                $locked = DberpTransferOrder::query()->whereKey($order->getKey())->lockForUpdate()->firstOrFail();
                $locked->loadMissing('fromWarehouse', 'toWarehouse');

                if ($locked->status !== TransferDocumentStatus::Draft->value) {
                    throw ValidationException::withMessages([
                        'status' => __('dberp.errors.inventory_doc_not_draft'),
                    ]);
                }

                $this->validateDifferentWarehouses($locked->fromWarehouse, $locked->toWarehouse);

                $items = $locked->items()->with('product')->orderBy('sort_order')->orderBy('id')->get();

                if ($items->isEmpty()) {
                    throw ValidationException::withMessages([
                        'items' => __('dberp.errors.inventory_doc_no_items'),
                    ]);
                }

                $preflightLines = [];

                foreach ($items as $item) {
                    if ((int) $item->product_id <= 0 || $item->product === null) {
                        throw ValidationException::withMessages([
                            'items' => __('dberp.errors.inventory_outbound_line_incomplete'),
                        ]);
                    }

                    if (bccomp((string) $item->base_quantity, '0', 4) !== 1) {
                        throw ValidationException::withMessages([
                            'items' => __('dberp.errors.inventory_outbound_quantity_invalid'),
                        ]);
                    }

                    if ($item->product->manages_serial_number) {
                        $this->refinementService->assertDocumentLineSerialNumbersMatchQuantity(
                            $item->product,
                            (string) $item->base_quantity,
                            $item->serial_numbers,
                        );
                    }

                    $sku = InventoryPostingProductSkuResolver::resolveSkuForPosting(
                        $item->product,
                        $item->product_sku_id !== null ? (int) $item->product_sku_id : null,
                    );

                    $preflightLines[] = [
                        'sku' => $sku,
                        'warehouse' => $locked->fromWarehouse,
                        'quantity' => (string) $item->base_quantity,
                    ];
                }

                $this->ledgerService->assertNoOutboundShortagesPreflight(
                    $preflightLines,
                    InventoryBusinessType::TransferOut,
                    (string) $locked->code,
                );

                foreach ($items as $item) {
                    $sku = InventoryPostingProductSkuResolver::resolveSkuForPosting(
                        $item->product,
                        $item->product_sku_id !== null ? (int) $item->product_sku_id : null,
                    );

                    $ledgerQty = (string) $item->base_quantity;
                    $lineTrace = InventoryLedgerLineTrace::fromDocumentLine($item, 'quantity');
                    $product = $sku->product;

                    $postOut = $this->ledgerService->outbound(
                        productSku: $sku,
                        warehouse: $locked->fromWarehouse,
                        quantity: $ledgerQty,
                        businessType: InventoryBusinessType::TransferOut,
                        businessNumber: (string) $locked->code,
                        sourceType: $locked->getMorphClass(),
                        sourceId: (int) $locked->getKey(),
                        remark: $item->remark ?? $locked->remark,
                        businessTime: $locked->business_time ?? now(),
                        idempotencyKey: sprintf('transfer_order:%d:confirm:%d:out', (int) $locked->getKey(), (int) $item->getKey()),
                        lineTrace: $lineTrace,
                    );

                    $postIn = $this->ledgerService->inbound(
                        productSku: $sku,
                        warehouse: $locked->toWarehouse,
                        quantity: $ledgerQty,
                        businessType: InventoryBusinessType::TransferIn,
                        businessNumber: (string) $locked->code,
                        sourceType: $locked->getMorphClass(),
                        sourceId: (int) $locked->getKey(),
                        remark: $item->remark ?? $locked->remark,
                        businessTime: $locked->business_time ?? now(),
                        idempotencyKey: sprintf('transfer_order:%d:confirm:%d:in', (int) $locked->getKey(), (int) $item->getKey()),
                        lineTrace: $lineTrace,
                    );

                    if (! $postOut->wasIdempotentReplay && ! $postIn->wasIdempotentReplay && ($product->manages_batch || $product->manages_serial_number)) {
                        $baseDetail = $this->transferItemRefinementDetail($item);
                        $resultOut = $this->refinementService->applyOutbound(
                            product: $product,
                            warehouse: $locked->fromWarehouse,
                            quantity: $ledgerQty,
                            businessType: InventoryBusinessType::TransferOut,
                            businessNumber: (string) $locked->code,
                            sourceType: $locked->getMorphClass(),
                            sourceId: (int) $locked->getKey(),
                            remark: $item->remark ?? $locked->remark,
                            businessTime: $locked->business_time ?? now(),
                            detail: $baseDetail,
                        );

                        $inboundDetail = $this->buildTransferInboundDetailAfterOutbound($baseDetail, $resultOut);
                        $resultIn = $this->refinementService->applyTransferInbound(
                            product: $product,
                            warehouse: $locked->toWarehouse,
                            quantity: $ledgerQty,
                            businessType: InventoryBusinessType::TransferIn,
                            businessNumber: (string) $locked->code,
                            sourceType: $locked->getMorphClass(),
                            sourceId: (int) $locked->getKey(),
                            remark: $item->remark ?? $locked->remark,
                            businessTime: $locked->business_time ?? now(),
                            detail: $inboundDetail,
                        );

                        $firstAlloc = $resultOut['batch_allocations'][0] ?? [];
                        $item->forceFill([
                            'source_batch_id' => $resultOut['batch_id'] ?? $item->source_batch_id,
                            'batch_no' => $resultOut['batch_no'] ?? $item->batch_no,
                            'batch_allocations' => $resultOut['batch_allocations'],
                            'serial_numbers' => $resultOut['serial_numbers'],
                            'destination_batch_allocations' => $resultIn['batch_allocations'],
                            'production_date' => $this->parseOptionalDate($firstAlloc['production_date'] ?? null) ?? $item->production_date,
                            'expiry_date' => $this->parseOptionalDate($firstAlloc['expiry_date'] ?? null) ?? $item->expiry_date,
                            'shelf_life_days' => (int) ($firstAlloc['shelf_life_days'] ?? ($item->shelf_life_days ?? 0)),
                        ])->save();
                    }
                }

                $before = ['status' => $locked->status, 'effective_at' => $locked->effective_at];

                $this->auditService->logSemanticSave(
                    'dberp.transfer_order.applied',
                    $locked,
                    function () use ($locked): void {
                        $locked->forceFill([
                            'status' => TransferDocumentStatus::Effective->value,
                            'effective_at' => now(),
                        ])->save();
                    },
                    $before,
                    ['status' => $locked->status, 'effective_at' => $locked->effective_at],
                );
            });
        } catch (InsufficientStockException $exception) {
            $this->auditInsufficientStock($exception);

            throw $exception->validationException();
        }
    }

    public function complete(DberpTransferOrder $order): void
    {
        DB::transaction(function () use ($order): void {
            $locked = DberpTransferOrder::query()->whereKey($order->getKey())->lockForUpdate()->firstOrFail();

            if ($locked->status !== TransferDocumentStatus::Effective->value) {
                throw ValidationException::withMessages([
                    'status' => __('dberp.errors.transfer_not_effective'),
                ]);
            }

            $before = ['status' => $locked->status, 'completed_at' => $locked->completed_at];

            $this->auditService->logSemanticSave(
                'dberp.transfer_order.completed',
                $locked,
                function () use ($locked): void {
                    $locked->forceFill([
                        'status' => TransferDocumentStatus::Completed->value,
                        'completed_at' => now(),
                    ])->save();
                },
                $before,
                ['status' => $locked->status, 'completed_at' => $locked->completed_at],
            );
        });
    }

    public function void(DberpTransferOrder $order): void
    {
        try {
            DB::transaction(function () use ($order): void {
                $locked = DberpTransferOrder::query()->whereKey($order->getKey())->lockForUpdate()->firstOrFail();
                $locked->loadMissing('fromWarehouse', 'toWarehouse');

                if (! in_array($locked->status, [TransferDocumentStatus::Effective->value, TransferDocumentStatus::Completed->value], true)) {
                    throw ValidationException::withMessages([
                        'status' => __('dberp.errors.inventory_doc_not_effective'),
                    ]);
                }

                if (! $this->ruleService->allowsEffectiveInventoryVoid()) {
                    throw ValidationException::withMessages([
                        'status' => __('dberp.errors.inventory_void_policy_blocked'),
                    ]);
                }

                if (! $this->ruleService->allowVoidRollback()) {
                    throw ValidationException::withMessages([
                        'status' => __('dberp.errors.transfer_void_requires_inventory_rollback'),
                    ]);
                }

                $this->validateDifferentWarehouses($locked->fromWarehouse, $locked->toWarehouse);

                $items = $locked->items()->with('product')->orderBy('sort_order')->orderBy('id')->get();

                $preflightVoidLines = [];

                foreach ($items as $item) {
                    $sku = InventoryPostingProductSkuResolver::resolveSkuForPosting(
                        $item->product,
                        $item->product_sku_id !== null ? (int) $item->product_sku_id : null,
                    );

                    $ledgerQty = (string) $item->base_quantity;

                    if (bccomp($ledgerQty, '0', 4) !== 1) {
                        continue;
                    }

                    $preflightVoidLines[] = [
                        'sku' => $sku,
                        'warehouse' => $locked->toWarehouse,
                        'quantity' => $ledgerQty,
                    ];
                }

                if ($preflightVoidLines !== []) {
                    $this->ledgerService->assertNoOutboundShortagesPreflight(
                        $preflightVoidLines,
                        InventoryBusinessType::TransferInVoid,
                        (string) $locked->code,
                    );
                }

                foreach ($items as $item) {
                    $sku = InventoryPostingProductSkuResolver::resolveSkuForPosting(
                        $item->product,
                        $item->product_sku_id !== null ? (int) $item->product_sku_id : null,
                    );

                    $ledgerQty = (string) $item->base_quantity;
                    $lineTrace = InventoryLedgerLineTrace::fromDocumentLine($item, 'quantity');
                    $product = $sku->product;

                    if (bccomp($ledgerQty, '0', 4) !== 1) {
                        continue;
                    }

                    $postTo = $this->ledgerService->outbound(
                        productSku: $sku,
                        warehouse: $locked->toWarehouse,
                        quantity: $ledgerQty,
                        businessType: InventoryBusinessType::TransferInVoid,
                        businessNumber: (string) $locked->code,
                        sourceType: $locked->getMorphClass(),
                        sourceId: (int) $locked->getKey(),
                        remark: $item->remark ?? $locked->remark,
                        businessTime: now(),
                        idempotencyKey: sprintf('transfer_order:%d:void:%d:out', (int) $locked->getKey(), (int) $item->getKey()),
                        lineTrace: $lineTrace,
                    );

                    $postFrom = $this->ledgerService->inbound(
                        productSku: $sku,
                        warehouse: $locked->fromWarehouse,
                        quantity: $ledgerQty,
                        businessType: InventoryBusinessType::TransferOutVoid,
                        businessNumber: (string) $locked->code,
                        sourceType: $locked->getMorphClass(),
                        sourceId: (int) $locked->getKey(),
                        remark: $item->remark ?? $locked->remark,
                        businessTime: now(),
                        idempotencyKey: sprintf('transfer_order:%d:void:%d:in', (int) $locked->getKey(), (int) $item->getKey()),
                        lineTrace: $lineTrace,
                    );

                    if (! $postTo->wasIdempotentReplay && ! $postFrom->wasIdempotentReplay && ($product->manages_batch || $product->manages_serial_number)) {
                        $destAllocations = is_array($item->destination_batch_allocations) ? $item->destination_batch_allocations : [];

                        $this->refinementService->applyOutbound(
                            product: $product,
                            warehouse: $locked->toWarehouse,
                            quantity: $ledgerQty,
                            businessType: InventoryBusinessType::TransferInVoid,
                            businessNumber: (string) $locked->code,
                            sourceType: $locked->getMorphClass(),
                            sourceId: (int) $locked->getKey(),
                            remark: $item->remark ?? $locked->remark,
                            businessTime: now(),
                            detail: [
                                'source_batch_id' => null,
                                'batch_no' => null,
                                'batch_allocations' => $destAllocations,
                                'serial_numbers' => is_array($item->serial_numbers) ? $item->serial_numbers : [],
                            ],
                        );

                        $this->refinementService->rollbackOutbound(
                            product: $product,
                            warehouse: $locked->fromWarehouse,
                            quantity: $ledgerQty,
                            businessType: InventoryBusinessType::TransferOutVoid,
                            businessNumber: (string) $locked->code,
                            sourceType: $locked->getMorphClass(),
                            sourceId: (int) $locked->getKey(),
                            remark: $item->remark ?? $locked->remark,
                            businessTime: now(),
                            detail: $this->transferItemRefinementDetail($item),
                        );
                    }
                }

                $before = ['status' => $locked->status, 'voided_at' => $locked->voided_at];

                $this->auditService->logSemanticSave(
                    'dberp.transfer_order.voided',
                    $locked,
                    function () use ($locked): void {
                        $locked->forceFill([
                            'status' => TransferDocumentStatus::Voided->value,
                            'voided_at' => now(),
                        ])->save();
                    },
                    $before,
                    ['status' => $locked->status, 'voided_at' => $locked->voided_at],
                );
            });
        } catch (InsufficientStockException $exception) {
            $this->auditInsufficientStock($exception);

            throw $exception->validationException();
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function transferItemRefinementDetail(DberpTransferOrderItem $item): array
    {
        $productionDate = $item->production_date;
        $expiryDate = $item->expiry_date;

        return [
            'source_batch_id' => $item->source_batch_id,
            'batch_no' => $item->batch_no,
            'production_date' => $productionDate instanceof \DateTimeInterface ? $productionDate->format('Y-m-d') : $productionDate,
            'expiry_date' => $expiryDate instanceof \DateTimeInterface ? $expiryDate->format('Y-m-d') : $expiryDate,
            'shelf_life_days' => (int) ($item->shelf_life_days ?? 0),
            'batch_allocations' => is_array($item->batch_allocations) ? $item->batch_allocations : [],
            'serial_numbers' => is_array($item->serial_numbers) ? $item->serial_numbers : [],
        ];
    }

    /**
     * @param  array<string, mixed>  $baseDetail
     * @param  array<string, mixed>  $resultOut
     * @return array<string, mixed>
     */
    private function buildTransferInboundDetailAfterOutbound(array $baseDetail, array $resultOut): array
    {
        $firstRow = $resultOut['batch_allocations'][0] ?? [];

        return array_merge($baseDetail, [
            'batch_no' => $resultOut['batch_no'] ?? $baseDetail['batch_no'],
            'production_date' => $firstRow['production_date'] ?? $baseDetail['production_date'],
            'expiry_date' => $firstRow['expiry_date'] ?? $baseDetail['expiry_date'],
            'shelf_life_days' => (int) ($firstRow['shelf_life_days'] ?? $baseDetail['shelf_life_days']),
            'serial_numbers' => $resultOut['serial_numbers'] ?? [],
        ]);
    }

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

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

    private function validateDifferentWarehouses(?DberpWarehouse $fromWarehouse, ?DberpWarehouse $toWarehouse): void
    {
        if ($fromWarehouse === null || $toWarehouse === null) {
            throw ValidationException::withMessages([
                'from_warehouse_id' => __('dberp.errors.transfer_warehouse_required'),
            ]);
        }

        if ($fromWarehouse->getKey() === $toWarehouse->getKey()) {
            throw ValidationException::withMessages([
                'to_warehouse_id' => __('dberp.errors.transfer_same_warehouse'),
            ]);
        }
    }

    private function auditInsufficientStock(InsufficientStockException $exception): void
    {
        $balanceQuery = DberpInventoryBalance::query()->where('warehouse_id', $exception->warehouseId);

        if ($exception->productSkuId !== null) {
            $balanceQuery->where('product_sku_id', $exception->productSkuId);
        } else {
            $balanceQuery->where('product_id', $exception->productId);
        }

        $balance = $balanceQuery->first();

        $this->auditService->log(
            'dberp.inventory.insufficient_stock_blocked',
            $balance,
            null,
            $exception->auditContext(),
        );
    }
}
