<?php

namespace App\Modules\Inventory\Reservation\Services;

use App\Modules\Inventory\Reservation\DTOs\ReservationApplyResult;
use App\Modules\Inventory\Reservation\DTOs\ReserveInventoryInput;
use App\Modules\Inventory\Reservation\Repositories\InventoryReservationIdempotencyRepository;
use App\Modules\Inventory\Reservation\Repositories\InventoryReservationMovementRepository;
use App\Modules\Inventory\Reservation\Support\ReservationAdvisoryLockKey;
use App\Modules\Inventory\Reservation\Support\ReservationEntryType;
use App\Modules\Inventory\Stock\Exceptions\InsufficientStockException;
use App\Modules\Inventory\Stock\Repositories\InventoryStockProjectionRepository;
use App\Modules\Inventory\Stock\Services\InventoryStockAdvisoryLock;
use App\Modules\Inventory\Stock\Services\StockCatalogGate;
use App\Modules\Inventory\Stock\Services\StockProjectionBalanceApplier;
use App\Modules\Inventory\Stock\Support\DecimalQuantity;
use App\Modules\Inventory\Stock\Support\InventoryStockMovementType;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Lock ordering (deadlock avoidance): MySQL GET_LOCK(idempotency) → FOR UPDATE projection → append ledger.
 */
final class ReserveInventoryService
{
    public function __construct(
        private readonly InventoryReservationMovementRepository $movements,
        private readonly InventoryReservationIdempotencyRepository $idempotency,
        private readonly InventoryStockProjectionRepository $projections,
        private readonly StockProjectionBalanceApplier $balanceApplier,
        private readonly StockCatalogGate $catalogGate,
        private readonly InventoryStockAdvisoryLock $advisoryLock,
        private readonly InventoryReservationObservability $obs,
    ) {}

    public function reserve(ReserveInventoryInput $in): ReservationApplyResult
    {
        $lockKey = ReservationAdvisoryLockKey::build($in->tenantId, $in->idempotencyKey, $in->idempotencySource);

        return $this->advisoryLock->runWithLock($lockKey, function () use ($in) {
            $attempts = (int) config('inventory.transaction_attempts', 5);

            return DB::transaction(function () use ($in) {
                return $this->reserveInTransaction($in);
            }, $attempts);
        });
    }

    private function reserveInTransaction(ReserveInventoryInput $in): ReservationApplyResult
    {
        $idem = $this->maybeIdempotent($in);
        if ($idem !== null) {
            return $idem;
        }

        $qty = trim($in->quantity);
        if (! DecimalQuantity::gtZero($qty)) {
            throw new InsufficientStockException('reserve_quantity_positive');
        }

        $this->catalogGate->assertMovable(
            $in->tenantId,
            $in->warehouseId,
            $in->itemId,
            null,
            InventoryStockMovementType::StockIn,
        );

        $projection = $this->projections->lockForWarehouseItem(
            $in->tenantId,
            $in->warehouseId,
            $in->itemId,
        );

        if (! DecimalQuantity::gte((string) $projection->available_quantity, $qty)) {
            $this->obs->allocationFailure('insufficient_available_to_reserve', [
                'tenant_id' => $in->tenantId,
                'warehouse_id' => $in->warehouseId,
                'item_id' => $in->itemId,
            ]);
            throw new InsufficientStockException('insufficient_available_to_reserve');
        }

        $reservationId = $in->reservationPublicId ?? (string) Str::uuid();
        $movement = $this->movements->insert([
            'tenant_id' => $in->tenantId,
            'warehouse_id' => $in->warehouseId,
            'item_id' => $in->itemId,
            'reservation_public_id' => $reservationId,
            'entry_type' => ReservationEntryType::Reserve,
            'quantity' => $qty,
            'expires_at' => $in->expiresAt,
            'correlation_id' => $in->correlationId ?? (string) Str::uuid(),
            'idempotency_key' => $in->idempotencyKey,
            'idempotency_source' => $in->idempotencySource,
            'source_type' => $in->sourceType,
            'source_id' => $in->sourceId,
            'actor_user_id' => $in->actorUserId,
            'metadata' => $in->metadata,
        ]);

        $this->balanceApplier->applyReservedDelta($projection, $qty);
        $this->projections->save($projection);

        $result = new ReservationApplyResult(
            movementId: (int) $movement->id,
            reservationPublicId: $reservationId,
            onHandAfter: (string) $projection->on_hand_quantity,
            reservedAfter: (string) $projection->reserved_quantity,
            availableAfter: (string) $projection->available_quantity,
        );

        $this->storeIdempotency($in, $result);

        return $result;
    }

    private function maybeIdempotent(ReserveInventoryInput $in): ?ReservationApplyResult
    {
        if ($in->idempotencyKey === null || $in->idempotencyKey === '') {
            return null;
        }
        $row = $this->idempotency->findLocked(
            $in->tenantId,
            $in->idempotencyKey,
            $in->idempotencySource,
        );
        if ($row === null) {
            return null;
        }
        $this->obs->duplicateReplay((string) $in->idempotencyKey, ['tenant_id' => $in->tenantId]);

        return ReservationApplyResult::fromIdempotencyPayload($row->result_payload);
    }

    private function storeIdempotency(ReserveInventoryInput $in, ReservationApplyResult $result): void
    {
        if ($in->idempotencyKey === null || $in->idempotencyKey === '') {
            return;
        }
        $this->idempotency->store(
            $in->tenantId,
            $in->idempotencyKey,
            $in->idempotencySource,
            $result->movementId,
            $result->toIdempotencyPayload(),
        );
    }
}
