<?php

namespace App\Services;

use App\Exceptions\StockReferenceException;
use App\Models\Catalog\Product;
use App\Models\Inventory\Damage;
use App\Models\Inventory\GrnItem;
use App\Models\Inventory\ProductSerial;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\DamageReason;
use App\Models\Lookups\GrnStatus;
use App\Models\Lookups\MovementType;
use App\Models\Lookups\ReturnReason;
use App\Models\Returns\ReturnItem;
use App\Support\SerialInput;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

class DamageService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly StockMovementService $stock,
        private readonly UnitConversionService $units,
        private readonly BatchService $batches,
        private readonly SerialService $serials,
    ) {}

    /**
     * Manual damage entry. Decreases stock + writes a damage record.
     *
     * A lot is always required — the damage must name the consignment being
     * written off. When `serial_ids` are supplied, one `Damage` row is created
     * per serial since each is a discrete unit (`quantity` is ignored in that
     * case); the last one created is returned.
     *
     * @param  array{branch_id:int,product_id:int,damage_reason_code?:string,quantity:float,unit_cost?:float,unit_id?:int,note?:string,stock_batch_id?:int,serial_ids?:array<int>}  $data
     */
    public function recordManual(array $data): Damage
    {
        return DB::transaction(function () use ($data) {
            $product = Product::find($data['product_id']);

            $serialIds = SerialInput::ids($data['serial_ids'] ?? null);
            if ($serialIds !== []) {
                $damages = array_map(function (int $serialId) use ($data) {
                    $serial = ProductSerial::findOrFail($serialId);
                    $stockBatchId = $serial->stock_batch_id;

                    return $this->recordOne($data, 1.0, $stockBatchId, $serialId);
                }, $serialIds);

                return end($damages);
            }

            $stockBatchId = $data['stock_batch_id'] ?? null;
            if (! $stockBatchId) {
                throw StockReferenceException::lot(
                    $data['product_id'],
                    ($product?->name ?? "Product #{$data['product_id']}").' has no lot selected — pick the consignment being written off.',
                );
            }

            return $this->recordOne($data, (float) $data['quantity'], $stockBatchId, null);
        });
    }

    /**
     * @param  array<string,mixed>  $data
     */
    private function recordOne(array $data, float $quantity, ?int $stockBatchId, ?int $productSerialId): Damage
    {
        $reason = isset($data['damage_reason_code'])
            ? DamageReason::findByCode($data['damage_reason_code'])
            : null;

        $unitCost = $data['unit_cost'] ?? $this->latestPurchaseCost($data['product_id']) ?? 0.0;
        $total = $quantity * $unitCost;

        $damage = Damage::create([
            'damage_number' => $this->numbers->next('damage'),
            'branch_id' => $data['branch_id'],
            'product_id' => $data['product_id'],
            'unit_id' => $data['unit_id'] ?? null,
            'stock_batch_id' => $stockBatchId,
            'product_serial_id' => $productSerialId,
            'damage_reason_id' => $reason?->id,
            'quantity_damaged' => $quantity,
            'unit_cost' => $unitCost,
            'total_damage_cost' => $total,
            'source_type' => 'manual',
            'note' => $data['note'] ?? null,
            'recorded_by' => Auth::id(),
        ]);

        $product = Product::find($data['product_id']);
        $baseQty = ($product && ! empty($data['unit_id']))
            ? $this->units->toBaseUnit($product, (int) $data['unit_id'], $quantity)
            : $quantity;

        $this->stock->record(
            branchId: $data['branch_id'],
            productId: $data['product_id'],
            movementTypeCode: MovementType::DAMAGE,
            quantityChange: -$baseQty,
            unitCost: $unitCost,
            referenceType: $damage,
            referenceNumber: $damage->damage_number,
            note: $data['note'] ?? null,
            stockBatchId: $stockBatchId,
        );

        if ($stockBatchId) {
            $this->batches->markDamaged(StockBatch::findOrFail($stockBatchId), $baseQty);
        }

        if ($productSerialId) {
            $this->serials->markDamaged(ProductSerial::findOrFail($productSerialId), $data['note'] ?? '');
        }

        AuditLog::record('damage.recorded', $damage, [
            'reference' => $damage->damage_number,
            'cost' => (string) $total,
        ]);

        return $damage;
    }

    /**
     * Damage recorded from a damage-flagged customer return. The damaged goods
     * are written off with a DAMAGE stock movement (negative), reducing available
     * stock (SRS 3.11). The ReturnService records the matching RETURN_IN first, so
     * the net effect is that the item is never restocked into sellable inventory.
     */
    public function recordFromReturn(ReturnItem $line, int $branchId): Damage
    {
        return DB::transaction(function () use ($line, $branchId) {
            $reasonCode = match ($line->reason?->code) {
                ReturnReason::DAMAGE_TRANSIT => DamageReason::TRANSIT,
                ReturnReason::DAMAGE_WAREHOUSE => DamageReason::WAREHOUSE,
                ReturnReason::DAMAGE_CUSTOMER => DamageReason::CUSTOMER,
                default => DamageReason::OTHER,
            };
            $reason = DamageReason::findByCode($reasonCode);

            $unitCost = (float) $line->unit_price;
            $total = (float) $line->quantity * $unitCost;

            $damage = Damage::create([
                'damage_number' => $this->numbers->next('damage'),
                'branch_id' => $branchId,
                'product_id' => $line->product_id,
                'damage_reason_id' => $reason?->id,
                'stock_batch_id' => $line->stock_batch_id,
                'product_serial_id' => $line->product_serial_id,
                'quantity_damaged' => $line->quantity,
                'unit_cost' => $unitCost,
                'total_damage_cost' => $total,
                'source_type' => 'return_item',
                'source_id' => $line->id,
                'recorded_by' => Auth::id(),
            ]);

            if ($branchId > 0) {
                $this->stock->record(
                    branchId: $branchId,
                    productId: (int) $line->product_id,
                    movementTypeCode: MovementType::DAMAGE,
                    quantityChange: -1 * (float) $line->quantity,
                    unitCost: $unitCost,
                    referenceType: $damage,
                    referenceNumber: $damage->damage_number,
                    stockBatchId: $line->stock_batch_id,
                );

                // The unit already left `available_quantity` when it was sold, so
                // only the damaged bucket is incremented here (see class docblock).
                if ($line->stock_batch_id) {
                    $this->batches->markDamagedReturn($line->stockBatch, (float) $line->quantity);
                }
                if ($line->product_serial_id) {
                    $this->serials->receiveReturn($line->serial, $line->return_id, $branchId, isDamaged: true);
                }
            }

            AuditLog::record('damage.from_return', $damage, [
                'return_item_id' => $line->id,
                'cost' => (string) $total,
            ]);

            return $damage;
        });
    }

    private function latestPurchaseCost(int $productId): ?float
    {
        // Grn has no SoftDeletes scope of its own (deletion is tracked via
        // grn_status_id, not deleted_at), so a deleted GRN's lines must be
        // excluded explicitly here to avoid resurfacing a voided cost.
        $cost = GrnItem::where('product_id', $productId)
            ->whereNotNull('unit_cost')
            ->whereHas('grn', fn ($q) => $q->where('grn_status_id', '!=', GrnStatus::DELETED))
            ->orderByDesc('id')
            ->value('unit_cost');

        return $cost !== null ? (float) $cost : null;
    }
}
