<?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\InventoryExceptionStatus;
use App\Business\Inventory\Enums\InventoryExceptionType;
use App\Business\Inventory\Enums\InventoryExpiryStatus;
use App\Business\Inventory\Enums\InventoryReplenishmentSuggestionStatus;
use App\Business\Inventory\Enums\StocktakeDifferenceStatus;
use App\Models\DberpInventoryBatch;
use App\Models\DberpInventoryException;
use App\Models\DberpInventoryReplenishmentSuggestion;
use App\Models\DberpInventorySerialNumber;
use App\Models\DberpStocktakeDifference;
use App\Services\DberpAuditService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;

final class InventoryExceptionService
{
    public function __construct(
        private readonly InventoryExpiryService $expiryService,
        private readonly DberpAuditService $auditService,
    ) {}

    public function query(): Builder
    {
        $this->synchronize();

        return DberpInventoryException::query()->with([
            'product.category',
            'warehouse',
            'batch',
            'serialNumber',
            'stocktakeDifference.stocktakeOrder',
            'replenishmentSuggestion',
            'handler',
        ]);
    }

    public function synchronize(): void
    {
        $snapshots = [];

        foreach ($this->buildExpiredBatchExceptions() as $snapshot) {
            $snapshots[$snapshot['exception_key']] = $snapshot;
        }

        foreach ($this->buildDisabledBatchExceptions() as $snapshot) {
            $snapshots[$snapshot['exception_key']] = $snapshot;
        }

        foreach ($this->buildSerialMismatchExceptions() as $snapshot) {
            $snapshots[$snapshot['exception_key']] = $snapshot;
        }

        foreach ($this->buildPendingStocktakeDifferenceExceptions() as $snapshot) {
            $snapshots[$snapshot['exception_key']] = $snapshot;
        }

        foreach ($this->buildPendingReplenishmentExceptions() as $snapshot) {
            $snapshots[$snapshot['exception_key']] = $snapshot;
        }

        $existing = DberpInventoryException::query()->get()->keyBy('exception_key');

        foreach ($snapshots as $exceptionKey => $snapshot) {
            $record = $existing->get($exceptionKey);

            if ($record === null) {
                DberpInventoryException::query()->create($snapshot);

                continue;
            }

            $status = $record->status === InventoryExceptionStatus::Ignored->value
                ? InventoryExceptionStatus::Ignored->value
                : InventoryExceptionStatus::Open->value;

            $record->forceFill(array_merge($snapshot, [
                'status' => $status,
                'resolved_at' => $status === InventoryExceptionStatus::Ignored->value ? $record->resolved_at : null,
                'handled_by' => $status === InventoryExceptionStatus::Ignored->value ? $record->handled_by : null,
                'handling_remark' => $status === InventoryExceptionStatus::Ignored->value ? $record->handling_remark : null,
            ]))->save();
        }

        $existing->each(function (DberpInventoryException $record) use ($snapshots): void {
            if (array_key_exists((string) $record->exception_key, $snapshots)) {
                return;
            }

            if ($record->status === InventoryExceptionStatus::Resolved->value) {
                return;
            }

            $record->forceFill([
                'status' => InventoryExceptionStatus::Resolved->value,
                'resolved_at' => $record->resolved_at ?? now(),
            ])->save();
        });
    }

    public function syncStocktakeDifference(DberpStocktakeDifference $difference): void
    {
        $this->upsertException($this->stocktakeDifferenceSnapshot($difference));
    }

    public function syncReplenishmentSuggestion(DberpInventoryReplenishmentSuggestion $suggestion): void
    {
        if (! in_array($suggestion->status, [
            InventoryReplenishmentSuggestionStatus::Pending->value,
            InventoryReplenishmentSuggestionStatus::Reviewed->value,
        ], true)) {
            $this->closeByReplenishmentSuggestion($suggestion, '补货建议已完成处理。');

            return;
        }

        $this->upsertException($this->replenishmentSnapshot($suggestion));
    }

    public function closeByStocktakeDifference(DberpStocktakeDifference $difference, ?string $remark = null): void
    {
        $this->updateExceptionStatus(
            $this->stocktakeDifferenceKey($difference),
            InventoryExceptionStatus::Resolved,
            $remark,
        );
    }

    public function ignoreByStocktakeDifference(DberpStocktakeDifference $difference, ?string $remark = null): void
    {
        $this->updateExceptionStatus(
            $this->stocktakeDifferenceKey($difference),
            InventoryExceptionStatus::Ignored,
            $remark,
        );
    }

    public function closeByReplenishmentSuggestion(DberpInventoryReplenishmentSuggestion $suggestion, ?string $remark = null): void
    {
        $this->updateExceptionStatus(
            $this->replenishmentKey($suggestion),
            InventoryExceptionStatus::Resolved,
            $remark,
        );
    }

    public function markResolved(DberpInventoryException $exception, ?string $remark = null): DberpInventoryException
    {
        $exception = $this->transitionStatus($exception, InventoryExceptionStatus::Resolved, $remark);

        $this->cascadeResolvedToSource($exception->fresh(), $remark);

        return $exception->refresh();
    }

    public function markIgnored(DberpInventoryException $exception, ?string $remark = null): DberpInventoryException
    {
        $exception = $this->transitionStatus($exception, InventoryExceptionStatus::Ignored, $remark);

        $this->cascadeIgnoredToSource($exception->fresh(), $remark);

        return $exception->refresh();
    }

    public function reopen(DberpInventoryException $exception, ?string $remark = null): DberpInventoryException
    {
        return $this->transitionStatus($exception, InventoryExceptionStatus::Open, $remark);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function buildExpiredBatchExceptions(): array
    {
        return DberpInventoryBatch::query()
            ->with(['product', 'warehouse'])
            ->where('available_quantity', '>', 0)
            ->whereNotNull('expiry_date')
            ->orderBy('expiry_date')
            ->get()
            ->filter(fn (DberpInventoryBatch $batch): bool => $this->expiryService->resolveForBatch($batch) === InventoryExpiryStatus::Expired)
            ->map(fn (DberpInventoryBatch $batch): array => [
                'exception_key' => 'batch-expired-'.$batch->getKey(),
                'exception_type' => InventoryExceptionType::ExpiredBatchStock->value,
                'severity' => 'danger',
                'title' => InventoryExceptionType::ExpiredBatchStock->label(),
                'description' => sprintf('批次 %s 已过期但仍有可用库存。', (string) $batch->batch_no),
                'product_id' => $batch->product_id,
                'warehouse_id' => $batch->warehouse_id,
                'batch_id' => $batch->getKey(),
                'serial_number_id' => null,
                'stocktake_difference_id' => null,
                'replenishment_suggestion_id' => null,
                'status' => InventoryExceptionStatus::Open->value,
                'occurred_at' => Carbon::parse((string) $batch->getAttribute('expiry_date'))->startOfDay(),
                'resolved_at' => null,
                'handled_by' => null,
                'handling_remark' => null,
                'context_snapshot' => [
                    'batch_no' => $batch->batch_no,
                    'available_quantity' => $batch->available_quantity,
                    'expiry_date' => $batch->getAttribute('expiry_date'),
                ],
                'sort_order' => 10,
            ])
            ->values()
            ->all();
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function buildDisabledBatchExceptions(): array
    {
        return DberpInventoryBatch::query()
            ->with(['product', 'warehouse'])
            ->where('realtime_quantity', '>', 0)
            ->where(function (Builder $query): void {
                $query->where('is_enabled', false)
                    ->orWhereIn('status', ['disabled', 'voided']);
            })
            ->orderBy('sort_order')
            ->orderBy('id')
            ->get()
            ->map(fn (DberpInventoryBatch $batch): array => [
                'exception_key' => 'batch-disabled-'.$batch->getKey(),
                'exception_type' => InventoryExceptionType::DisabledBatchStock->value,
                'severity' => 'danger',
                'title' => InventoryExceptionType::DisabledBatchStock->label(),
                'description' => sprintf('批次 %s 已停用但仍挂有库存。', (string) $batch->batch_no),
                'product_id' => $batch->product_id,
                'warehouse_id' => $batch->warehouse_id,
                'batch_id' => $batch->getKey(),
                'serial_number_id' => null,
                'stocktake_difference_id' => null,
                'replenishment_suggestion_id' => null,
                'status' => InventoryExceptionStatus::Open->value,
                'occurred_at' => $batch->updated_at ?? $batch->created_at ?? now(),
                'resolved_at' => null,
                'handled_by' => null,
                'handling_remark' => null,
                'context_snapshot' => [
                    'batch_no' => $batch->batch_no,
                    'realtime_quantity' => $batch->realtime_quantity,
                    'status' => $batch->status,
                    'is_enabled' => $batch->is_enabled,
                ],
                'sort_order' => 20,
            ])
            ->values()
            ->all();
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function buildSerialMismatchExceptions(): array
    {
        return DberpInventorySerialNumber::query()
            ->with(['product', 'warehouse', 'batch'])
            ->whereIn('status', ['in_stock', 'locked'])
            ->where(function (Builder $query): void {
                $query->whereDoesntHave('batch')
                    ->orWhereHas('batch', function (Builder $batchQuery): void {
                        $batchQuery->where('is_enabled', false)
                            ->orWhereIn('status', ['disabled', 'voided']);
                    });
            })
            ->orderBy('sort_order')
            ->orderBy('id')
            ->get()
            ->map(fn (DberpInventorySerialNumber $serialNumber): array => [
                'exception_key' => 'serial-mismatch-'.$serialNumber->getKey(),
                'exception_type' => InventoryExceptionType::SerialBatchMismatch->value,
                'severity' => 'danger',
                'title' => InventoryExceptionType::SerialBatchMismatch->label(),
                'description' => sprintf('序列号 %s 当前状态与所在批次不一致。', (string) $serialNumber->serial_no),
                'product_id' => $serialNumber->product_id,
                'warehouse_id' => $serialNumber->warehouse_id,
                'batch_id' => $serialNumber->batch_id,
                'serial_number_id' => $serialNumber->getKey(),
                'stocktake_difference_id' => null,
                'replenishment_suggestion_id' => null,
                'status' => InventoryExceptionStatus::Open->value,
                'occurred_at' => $serialNumber->updated_at ?? $serialNumber->created_at ?? now(),
                'resolved_at' => null,
                'handled_by' => null,
                'handling_remark' => null,
                'context_snapshot' => [
                    'serial_no' => $serialNumber->serial_no,
                    'serial_status' => $serialNumber->status,
                    'batch_status' => $serialNumber->batch?->status,
                    'batch_enabled' => $serialNumber->batch?->is_enabled,
                ],
                'sort_order' => 30,
            ])
            ->values()
            ->all();
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function buildPendingStocktakeDifferenceExceptions(): array
    {
        return DberpStocktakeDifference::query()
            ->whereIn('status', [
                StocktakeDifferenceStatus::Pending->value,
                StocktakeDifferenceStatus::Reviewed->value,
            ])
            ->orderBy('sort_order')
            ->orderBy('id')
            ->get()
            ->map(fn (DberpStocktakeDifference $difference): array => $this->stocktakeDifferenceSnapshot($difference))
            ->values()
            ->all();
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function buildPendingReplenishmentExceptions(): array
    {
        return DberpInventoryReplenishmentSuggestion::query()
            ->whereIn('status', [
                InventoryReplenishmentSuggestionStatus::Pending->value,
                InventoryReplenishmentSuggestionStatus::Reviewed->value,
            ])
            ->orderByDesc('generated_at')
            ->get()
            ->map(fn (DberpInventoryReplenishmentSuggestion $suggestion): array => $this->replenishmentSnapshot($suggestion))
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>
     */
    private function stocktakeDifferenceSnapshot(DberpStocktakeDifference $difference): array
    {
        return [
            'exception_key' => $this->stocktakeDifferenceKey($difference),
            'exception_type' => InventoryExceptionType::PendingStocktakeDifference->value,
            'severity' => (float) $difference->difference_quantity < 0 ? 'danger' : 'warning',
            'title' => InventoryExceptionType::PendingStocktakeDifference->label(),
            'description' => sprintf('盘点单 %s 存在待处理差异。', (string) $difference->stocktakeOrder?->code),
            'product_id' => $difference->product_id,
            'warehouse_id' => $difference->warehouse_id,
            'batch_id' => null,
            'serial_number_id' => null,
            'stocktake_difference_id' => $difference->getKey(),
            'replenishment_suggestion_id' => null,
            'status' => InventoryExceptionStatus::Open->value,
            'occurred_at' => $difference->updated_at ?? $difference->created_at ?? now(),
            'resolved_at' => null,
            'handled_by' => null,
            'handling_remark' => null,
            'context_snapshot' => [
                'book_quantity' => $difference->book_quantity,
                'actual_quantity' => $difference->actual_quantity,
                'difference_quantity' => $difference->difference_quantity,
                'result_type' => $difference->result_type,
            ],
            'sort_order' => 40,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function replenishmentSnapshot(DberpInventoryReplenishmentSuggestion $suggestion): array
    {
        return [
            'exception_key' => $this->replenishmentKey($suggestion),
            'exception_type' => InventoryExceptionType::PendingReplenishmentSuggestion->value,
            'severity' => 'warning',
            'title' => InventoryExceptionType::PendingReplenishmentSuggestion->label(),
            'description' => '库存规则已触发补货建议，仍待业务处理。',
            'product_id' => $suggestion->product_id,
            'warehouse_id' => $suggestion->warehouse_id,
            'batch_id' => null,
            'serial_number_id' => null,
            'stocktake_difference_id' => null,
            'replenishment_suggestion_id' => $suggestion->getKey(),
            'status' => InventoryExceptionStatus::Open->value,
            'occurred_at' => $suggestion->generated_at ?? $suggestion->created_at ?? now(),
            'resolved_at' => null,
            'handled_by' => null,
            'handling_remark' => null,
            'context_snapshot' => [
                'status' => $suggestion->status,
                'system_suggested_quantity' => $suggestion->system_suggested_quantity,
                'adjusted_quantity' => $suggestion->adjusted_quantity,
            ],
            'sort_order' => 50,
        ];
    }

    private function upsertException(array $snapshot): void
    {
        $record = DberpInventoryException::query()->firstWhere('exception_key', $snapshot['exception_key']);

        if ($record === null) {
            DberpInventoryException::query()->create($snapshot);

            return;
        }

        $status = $record->status === InventoryExceptionStatus::Ignored->value
            ? InventoryExceptionStatus::Ignored->value
            : InventoryExceptionStatus::Open->value;

        $record->forceFill(array_merge($snapshot, [
            'status' => $status,
            'resolved_at' => $status === InventoryExceptionStatus::Ignored->value ? $record->resolved_at : null,
            'handled_by' => $status === InventoryExceptionStatus::Ignored->value ? $record->handled_by : null,
            'handling_remark' => $status === InventoryExceptionStatus::Ignored->value ? $record->handling_remark : null,
        ]))->save();
    }

    private function updateExceptionStatus(string $exceptionKey, InventoryExceptionStatus $status, ?string $remark = null): void
    {
        $record = DberpInventoryException::query()->firstWhere('exception_key', $exceptionKey);

        if ($record === null) {
            return;
        }

        $record->forceFill([
            'status' => $status->value,
            'resolved_at' => $status === InventoryExceptionStatus::Open ? null : now(),
            'handled_by' => Auth::id(),
            'handling_remark' => $remark ?: $record->handling_remark,
        ])->save();
    }

    private function transitionStatus(DberpInventoryException $exception, InventoryExceptionStatus $status, ?string $remark = null): DberpInventoryException
    {
        $before = $exception->only(['status', 'resolved_at', 'handled_by', 'handling_remark']);

        $this->auditService->logSemanticSave(
            'dberp.inventory_exception.status_changed',
            $exception,
            function () use ($exception, $status, $remark): void {
                $exception->forceFill([
                    'status' => $status->value,
                    'resolved_at' => $status === InventoryExceptionStatus::Open ? null : now(),
                    'handled_by' => Auth::id(),
                    'handling_remark' => $remark ?: $exception->handling_remark,
                ])->save();
            },
            $before,
            $exception->only([
                'status',
                'resolved_at',
                'handled_by',
                'handling_remark',
            ]),
        );

        return $exception->refresh();
    }

    /**
     * 库存异常与盘点差异、补货建议等待办同源；仅在异常侧标记忽略/关闭时，需同步上游单据，避免待办仍悬挂。
     */
    private function cascadeIgnoredToSource(DberpInventoryException $exception, ?string $remark): void
    {
        $exception->loadMissing(['stocktakeDifference', 'replenishmentSuggestion']);

        if ($exception->stocktake_difference_id !== null && $exception->stocktakeDifference instanceof DberpStocktakeDifference) {
            $difference = $exception->stocktakeDifference;

            if (in_array($difference->status, [
                StocktakeDifferenceStatus::Pending->value,
                StocktakeDifferenceStatus::Reviewed->value,
            ], true)) {
                app(StocktakeDifferenceService::class)->markIgnored($difference, $remark);
            }

            return;
        }

        if ($exception->replenishment_suggestion_id !== null && $exception->replenishmentSuggestion instanceof DberpInventoryReplenishmentSuggestion) {
            $suggestion = $exception->replenishmentSuggestion;

            if (in_array($suggestion->status, [
                InventoryReplenishmentSuggestionStatus::Pending->value,
                InventoryReplenishmentSuggestionStatus::Reviewed->value,
            ], true)) {
                app(InventoryReplenishmentSuggestionService::class)->markIgnored($suggestion, $remark);
            }
        }
    }

    private function cascadeResolvedToSource(DberpInventoryException $exception, ?string $remark): void
    {
        $exception->loadMissing(['stocktakeDifference', 'replenishmentSuggestion']);

        if ($exception->stocktake_difference_id !== null && $exception->stocktakeDifference instanceof DberpStocktakeDifference) {
            $difference = $exception->stocktakeDifference;

            if (in_array($difference->status, [
                StocktakeDifferenceStatus::Pending->value,
                StocktakeDifferenceStatus::Reviewed->value,
            ], true)) {
                app(StocktakeDifferenceService::class)->markResolved($difference, $remark);
            }

            return;
        }

        if ($exception->replenishment_suggestion_id !== null && $exception->replenishmentSuggestion instanceof DberpInventoryReplenishmentSuggestion) {
            $suggestion = $exception->replenishmentSuggestion;

            if (in_array($suggestion->status, [
                InventoryReplenishmentSuggestionStatus::Pending->value,
                InventoryReplenishmentSuggestionStatus::Reviewed->value,
            ], true)) {
                app(InventoryReplenishmentSuggestionService::class)->markProcessed($suggestion, $remark);
            }
        }
    }

    private function stocktakeDifferenceKey(DberpStocktakeDifference $difference): string
    {
        return 'stocktake-difference-'.$difference->getKey();
    }

    private function replenishmentKey(DberpInventoryReplenishmentSuggestion $suggestion): string
    {
        return 'replenishment-'.$suggestion->getKey();
    }
}
