<?php

declare(strict_types=1);

namespace App\Business\Inventory;

use App\Business\Inventory\Enums\InventoryBusinessType;
use App\Business\Inventory\Enums\InventorySerialNumberStatus;
use App\Models\DberpInventoryBatch;
use App\Models\DberpInventorySerialNumber;
use App\Models\DberpInventorySerialNumberLog;
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\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class InventorySerialNumberService
{
    public function query(): Builder
    {
        return DberpInventorySerialNumber::query()->with(['product', 'warehouse', 'batch']);
    }

    public function availableQuery(DberpProduct $product, ?DberpWarehouse $warehouse = null, ?int $batchId = null): Builder
    {
        return $this->query()
            ->where('product_id', $product->getKey())
            ->when($warehouse !== null, fn (Builder $query): Builder => $query->where('warehouse_id', $warehouse->getKey()))
            ->when($batchId !== null, fn (Builder $query): Builder => $query->where('batch_id', $batchId))
            ->where('status', InventorySerialNumberStatus::InStock->value)
            ->where('is_enabled', true)
            ->orderBy('sort_order')
            ->orderBy('id');
    }

    public function reserve(DberpInventorySerialNumber $serialNumber, string $ownerType, int $ownerId): DberpInventorySerialNumber
    {
        return DB::transaction(function () use ($serialNumber, $ownerType, $ownerId): DberpInventorySerialNumber {
            $locked = DberpInventorySerialNumber::query()->whereKey($serialNumber->getKey())->lockForUpdate()->firstOrFail();

            if ($locked->status !== InventorySerialNumberStatus::InStock->value || ! $locked->is_enabled) {
                throw ValidationException::withMessages([
                    'serial_no' => __('dberp.errors.inventory_serial_number_not_reservable'),
                ]);
            }

            $locked->forceFill([
                'status' => InventorySerialNumberStatus::Locked->value,
                'current_owner_type' => $ownerType,
                'current_owner_id' => $ownerId,
            ])->save();

            return $locked->refresh();
        });
    }

    public function release(DberpInventorySerialNumber $serialNumber): DberpInventorySerialNumber
    {
        $serialNumber->forceFill([
            'status' => InventorySerialNumberStatus::InStock->value,
            'current_owner_type' => null,
            'current_owner_id' => null,
        ])->save();

        return $serialNumber->refresh();
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     * @return array<int, string>
     */
    public function registerInbound(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        ?DberpInventoryBatch $batch,
        array|string|null $serialNumbers,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
    ): array {
        if (! $product->manages_serial_number) {
            return [];
        }

        $resolvedSerialNumbers = $this->normalizeSerialNumbers($serialNumbers);

        if ($resolvedSerialNumbers === []) {
            throw ValidationException::withMessages([
                'serial_numbers' => __('dberp.errors.inventory_serial_number_required'),
            ]);
        }

        foreach ($resolvedSerialNumbers as $serialNo) {
            $existing = DberpInventorySerialNumber::query()
                ->where('serial_no', $serialNo)
                ->lockForUpdate()
                ->first();

            if ($existing !== null) {
                throw ValidationException::withMessages([
                    'serial_numbers' => __('dberp.errors.inventory_serial_number_exists'),
                ]);
            }

            $serialNumber = DberpInventorySerialNumber::query()->create([
                'product_id' => $product->getKey(),
                'warehouse_id' => $warehouse->getKey(),
                'batch_id' => $batch?->getKey(),
                'serial_no' => $serialNo,
                'status' => InventorySerialNumberStatus::InStock->value,
                'current_owner_type' => null,
                'current_owner_id' => null,
                'inbound_at' => Carbon::instance($businessTime instanceof \DateTime ? $businessTime : Carbon::parse($businessTime->format('Y-m-d H:i:s'))),
                'outbound_at' => null,
                'is_enabled' => true,
                'sort_order' => 0,
                'remark' => $remark,
            ]);

            $this->recordLog(
                serialNumber: $serialNumber,
                previousStatus: null,
                currentStatus: InventorySerialNumberStatus::InStock,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }

        return $resolvedSerialNumbers;
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     * @param  array<int, int>  $batchIds
     * @return array<int, string>
     */
    public function issueOutbound(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        array|string|null $serialNumbers,
        array $batchIds,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
    ): array {
        if (! $product->manages_serial_number) {
            return [];
        }

        $resolvedSerialNumbers = $this->normalizeSerialNumbers($serialNumbers);

        if ($resolvedSerialNumbers === []) {
            throw ValidationException::withMessages([
                'serial_numbers' => __('dberp.errors.inventory_serial_number_required'),
            ]);
        }

        foreach ($resolvedSerialNumbers as $serialNo) {
            $serialNumber = DberpInventorySerialNumber::query()
                ->where('serial_no', $serialNo)
                ->lockForUpdate()
                ->first();

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

            if (! $serialNumber->is_enabled || $serialNumber->status !== InventorySerialNumberStatus::InStock->value) {
                throw ValidationException::withMessages([
                    'serial_numbers' => __('dberp.errors.inventory_serial_number_not_reservable'),
                ]);
            }

            if ($batchIds !== [] && $serialNumber->batch_id !== null && ! in_array((int) $serialNumber->batch_id, $batchIds, true)) {
                throw ValidationException::withMessages([
                    'serial_numbers' => __('dberp.errors.inventory_serial_number_batch_mismatch'),
                ]);
            }

            $previousStatus = InventorySerialNumberStatus::tryFrom((string) $serialNumber->status);

            $serialNumber->forceFill([
                'status' => InventorySerialNumberStatus::Outbound->value,
                'current_owner_type' => $sourceType,
                'current_owner_id' => $sourceId,
                'outbound_at' => Carbon::instance($businessTime instanceof \DateTime ? $businessTime : Carbon::parse($businessTime->format('Y-m-d H:i:s'))),
            ])->save();

            $this->recordLog(
                serialNumber: $serialNumber,
                previousStatus: $previousStatus,
                currentStatus: InventorySerialNumberStatus::Outbound,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }

        return $resolvedSerialNumbers;
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     */
    public function restoreInbound(
        DberpProduct $product,
        DberpWarehouse $warehouse,
        array|string|null $serialNumbers,
        ?int $batchId,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
    ): void {
        if (! $product->manages_serial_number) {
            return;
        }

        foreach ($this->loadSerialNumbers($serialNumbers, $product) as $serialNumber) {
            $previousStatus = InventorySerialNumberStatus::tryFrom((string) $serialNumber->status);

            $serialNumber->forceFill([
                'warehouse_id' => $warehouse->getKey(),
                'batch_id' => $batchId,
                'status' => InventorySerialNumberStatus::InStock->value,
                'current_owner_type' => null,
                'current_owner_id' => null,
                'outbound_at' => null,
            ])->save();

            $this->recordLog(
                serialNumber: $serialNumber,
                previousStatus: $previousStatus,
                currentStatus: InventorySerialNumberStatus::InStock,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     */
    public function voidInbound(
        DberpProduct $product,
        array|string|null $serialNumbers,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        ?string $remark,
        \DateTimeInterface $businessTime,
    ): void {
        if (! $product->manages_serial_number) {
            return;
        }

        foreach ($this->loadSerialNumbers($serialNumbers, $product) as $serialNumber) {
            $previousStatus = InventorySerialNumberStatus::tryFrom((string) $serialNumber->status);

            $serialNumber->forceFill([
                'status' => InventorySerialNumberStatus::Voided->value,
                'current_owner_type' => $sourceType,
                'current_owner_id' => $sourceId,
                'warehouse_id' => null,
                'batch_id' => null,
                'outbound_at' => Carbon::instance($businessTime instanceof \DateTime ? $businessTime : Carbon::parse($businessTime->format('Y-m-d H:i:s'))),
            ])->save();

            $this->recordLog(
                serialNumber: $serialNumber,
                previousStatus: $previousStatus,
                currentStatus: InventorySerialNumberStatus::Voided,
                businessType: $businessType,
                businessNumber: $businessNumber,
                sourceType: $sourceType,
                sourceId: $sourceId,
                businessTime: $businessTime,
                remark: $remark,
            );
        }
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     * @return array<int, string>
     */
    public function normalizeSerialNumbers(array|string|null $serialNumbers): array
    {
        $items = is_array($serialNumbers)
            ? $serialNumbers
            : (preg_split('/[\r\n,，;；]+/u', (string) $serialNumbers) ?: []);

        return collect($items)
            ->map(fn (mixed $serialNo): string => Str::upper(trim((string) $serialNo)))
            ->filter(fn (string $serialNo): bool => $serialNo !== '')
            ->unique()
            ->values()
            ->all();
    }

    /**
     * @param  array<int, string>|string|null  $serialNumbers
     * @return Collection<int, DberpInventorySerialNumber>
     */
    private function loadSerialNumbers(array|string|null $serialNumbers, DberpProduct $product): Collection
    {
        $resolvedSerialNumbers = $this->normalizeSerialNumbers($serialNumbers);

        if ($resolvedSerialNumbers === []) {
            return collect();
        }

        $serials = DberpInventorySerialNumber::query()
            ->where('product_id', $product->getKey())
            ->whereIn('serial_no', $resolvedSerialNumbers)
            ->lockForUpdate()
            ->get()
            ->keyBy('serial_no');

        foreach ($resolvedSerialNumbers as $serialNo) {
            if (! $serials->has($serialNo)) {
                throw ValidationException::withMessages([
                    'serial_numbers' => __('dberp.errors.inventory_serial_number_not_found'),
                ]);
            }
        }

        return collect($resolvedSerialNumbers)
            ->map(fn (string $serialNo): DberpInventorySerialNumber => $serials->get($serialNo))
            ->values();
    }

    private function recordLog(
        DberpInventorySerialNumber $serialNumber,
        ?InventorySerialNumberStatus $previousStatus,
        InventorySerialNumberStatus $currentStatus,
        InventoryBusinessType $businessType,
        string $businessNumber,
        string $sourceType,
        int $sourceId,
        \DateTimeInterface $businessTime,
        ?string $remark,
    ): void {
        DberpInventorySerialNumberLog::query()->create([
            'serial_number_id' => $serialNumber->getKey(),
            'product_id' => $serialNumber->product_id,
            'warehouse_id' => $serialNumber->warehouse_id,
            'batch_id' => $serialNumber->batch_id,
            'previous_status' => $previousStatus?->value,
            'current_status' => $currentStatus->value,
            'business_type' => $businessType->value,
            'business_number' => $businessNumber,
            'source_type' => $sourceType,
            'source_id' => $sourceId,
            'business_time' => $businessTime,
            'context_snapshot' => [
                'serial_no' => $serialNumber->serial_no,
                'current_owner_type' => $serialNumber->current_owner_type,
                'current_owner_id' => $serialNumber->current_owner_id,
            ],
            'remark' => $remark,
        ]);
    }
}