<?php

namespace App\Services;

use App\Models\Catalog\Product;
use App\Models\Inventory\Damage;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\MovementType;
use App\Models\Lookups\ReturnReason;
use App\Models\Lookups\Status;
use App\Models\Returns\CreditNote;
use App\Models\Returns\RefundMethod;
use App\Models\Returns\ReturnDoc;
use App\Models\Returns\ReturnItem;
use App\Models\Sales\Invoice;
use App\Models\Sales\InvoiceItem;
use App\Models\Sales\PosSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Owns the 3-step customer return:
 *   1. customer
 *   2. product + invoice
 *   3. line details (qty / discount / add_back_to_stock / reason)
 *
 * Damage-flagged reasons force add_back_to_stock=false and dispatch to
 * DamageService::recordFromReturn() automatically.
 */
class ReturnService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly StockMovementService $stock,
        private readonly DamageService $damages,
        private readonly CreditNoteService $creditNotes,
        private readonly CustomerCreditService $customerCredit,
        private readonly UnitConversionService $units,
        private readonly BatchService $batches,
        private readonly SerialService $serials,
        private readonly CashMovementService $cashMovements,
        private readonly PosSettings $posSettings,
    ) {}

    /**
     * @param  array{customer_id:int, branch_id?:int, refund_method:string, note?:string}  $header
     * @param  array<int, array{
     *   invoice_id:int, product_id:int, invoice_item_id?:int,
     *   quantity:float, unit_price:float,
     *   return_reason_code?:string,
     *   discount_type?:string, discount_value?:float,
     *   is_add_back_to_stock?:bool, reason_note?:string
     * }>  $lines
     */
    public function create(array $header, array $lines): ReturnDoc
    {
        $this->assertDiscountAllowed($lines);

        return DB::transaction(function () use ($header, $lines) {
            $refundMethod = $header['refund_method'];

            // Snapshot already-refunded amounts per invoice BEFORE creating new
            // return items so this return's own lines don't distort the cap.
            $uniqueInvoiceIds = array_unique(array_filter(array_column($lines, 'invoice_id')));
            $alreadyRefundedByInvoice = [];
            foreach ($uniqueInvoiceIds as $invoiceId) {
                $alreadyRefundedByInvoice[$invoiceId] = (float) ReturnItem::fromActiveReturn()->where('invoice_id', $invoiceId)->sum('grand_total');
            }

            // Snapshot already-returned quantities the same way: a return can
            // never give back more than was sold (and not yet returned) on its
            // source line, whatever the form or a crafted payload claims.
            $returnedByInvoiceItem = [];
            $returnedByInvoiceProduct = [];
            foreach ($lines as $line) {
                if (! empty($line['invoice_item_id'])) {
                    $returnedByInvoiceItem[(int) $line['invoice_item_id']] = (float) ReturnItem::fromActiveReturn()
                        ->where('invoice_item_id', (int) $line['invoice_item_id'])
                        ->sum('quantity');
                } else {
                    $returnedByInvoiceProduct[((int) $line['invoice_id']).'-'.((int) $line['product_id'])] = (float) ReturnItem::fromActiveReturn()
                        ->where('invoice_id', (int) $line['invoice_id'])
                        ->where('product_id', (int) $line['product_id'])
                        ->whereNull('invoice_item_id')
                        ->sum('quantity');
                }
            }

            $return = ReturnDoc::create([
                'return_number' => $this->numbers->next('return'),
                'customer_id' => $header['customer_id'],
                'branch_id' => $header['branch_id'] ?? null,
                'status_id' => Status::idOf(Status::CTX_RETURN, Status::RETURN_PENDING),
                'total' => 0,
                'refund_method_id' => RefundMethod::idOf($refundMethod),
                'refund_amount' => 0,
                'note' => $header['note'] ?? null,
            ]);

            $runningTotal = 0;
            $returnValueByInvoice = [];

            foreach ($lines as $line) {
                $reason = isset($line['return_reason_code'])
                    ? ReturnReason::findByCode($line['return_reason_code'])
                    : null;

                $triggersDamage = $reason?->triggers_damage ?? false;
                $addBack = $triggersDamage ? false : (bool) ($line['is_add_back_to_stock'] ?? true);

                $lineTotal = (float) $line['quantity'] * (float) $line['unit_price'];
                $discountAmt = $this->computeDiscount($lineTotal, (float) $line['quantity'], $line['discount_type'] ?? null, (float) ($line['discount_value'] ?? 0));
                $grandTotal = $lineTotal - $discountAmt;
                $runningTotal += $grandTotal;

                $invoiceId = (int) $line['invoice_id'];
                $productId = (int) $line['product_id'];
                $returnValueByInvoice[$invoiceId] = ($returnValueByInvoice[$invoiceId] ?? 0) + $grandTotal;

                // Carry the exact batch/serial the customer was sold over from the
                // original invoice line — no re-selection needed by the operator.
                $sourceInvoiceItem = ! empty($line['invoice_item_id'])
                    ? InvoiceItem::find($line['invoice_item_id'])
                    : null;

                // A return line must point at a line that actually exists on the
                // stated invoice, and can never give back more than was sold on
                // it (minus what earlier returns already took back).
                if ($sourceInvoiceItem) {
                    if ((int) $sourceInvoiceItem->invoice_id !== $invoiceId
                        || (int) $sourceInvoiceItem->product_id !== $productId
                    ) {
                        throw new RuntimeException('The selected line does not belong to the stated invoice.');
                    }
                    $soldQty = (float) $sourceInvoiceItem->quantity;
                    $returnedQty = $returnedByInvoiceItem[(int) $sourceInvoiceItem->id] ?? 0.0;
                } else {
                    $soldQty = (float) InvoiceItem::query()
                        ->where('invoice_id', $invoiceId)
                        ->where('product_id', $productId)
                        ->sum('quantity');
                    $returnedQty = $returnedByInvoiceProduct[$invoiceId.'-'.$productId] ?? 0.0;
                }

                $returnableQty = max(0, round($soldQty - $returnedQty, 2));
                if ((float) $line['quantity'] > $returnableQty + 0.0001) {
                    throw new RuntimeException(
                        'Cannot return '.rtrim(rtrim(number_format((float) $line['quantity'], 2), '0'), '.')
                        .' unit(s) — only '.rtrim(rtrim(number_format($returnableQty, 2), '0'), '.')
                        .' remain returnable on this line.'
                    );
                }

                $stockBatchId = $sourceInvoiceItem?->stock_batch_id;
                $productSerialId = $sourceInvoiceItem?->product_serial_id;

                $item = ReturnItem::create([
                    'return_id' => $return->id,
                    'invoice_id' => $invoiceId,
                    'invoice_item_id' => $line['invoice_item_id'] ?? null,
                    'product_id' => $line['product_id'],
                    'unit_id' => $line['unit_id'] ?? null,
                    'stock_batch_id' => $stockBatchId,
                    'product_serial_id' => $productSerialId,
                    'return_reason_id' => $reason?->id,
                    'quantity' => $line['quantity'],
                    'unit_price' => $line['unit_price'],
                    'line_total' => $lineTotal,
                    'discount_type' => $line['discount_type'] ?? null,
                    'discount_value' => $line['discount_value'] ?? 0,
                    'discount_amount' => $discountAmt,
                    'grand_total' => $grandTotal,
                    'is_add_back_to_stock' => $addBack,
                    'reason_note' => $line['reason_note'] ?? null,
                ]);

                $branchId = $header['branch_id'] ?? $return->branch_id ?? Invoice::find($invoiceId)?->branch_id;
                $product = Product::find($line['product_id']);
                $baseQty = ($product && ! empty($line['unit_id']))
                    ? $this->units->toBaseUnit($product, (int) $line['unit_id'], (float) $line['quantity'])
                    : (float) $line['quantity'];

                if ($triggersDamage) {
                    // Damaged goods physically come back to the branch, then are
                    // immediately written off as damaged stock. Net effect: the item
                    // is NOT restocked into sellable stock (3.9), but the damage record
                    // explicitly reduces available stock (3.11) and is visible on the
                    // bin card as a RETURN_IN followed by a DAMAGE movement.
                    if ($branchId) {
                        $this->stock->record(
                            branchId: (int) $branchId,
                            productId: (int) $line['product_id'],
                            movementTypeCode: MovementType::RETURN_IN,
                            quantityChange: $baseQty,
                            unitCost: (float) $line['unit_price'],
                            referenceType: $item,
                            referenceNumber: $return->return_number,
                            stockBatchId: $stockBatchId,
                        );
                    }
                    $this->damages->recordFromReturn($item, (int) $branchId);
                } elseif ($addBack && $branchId) {
                    $this->stock->record(
                        branchId: (int) $branchId,
                        productId: (int) $line['product_id'],
                        movementTypeCode: MovementType::RETURN_IN,
                        quantityChange: $baseQty,
                        unitCost: (float) $line['unit_price'],
                        referenceType: $item,
                        referenceNumber: $return->return_number,
                        stockBatchId: $stockBatchId,
                    );

                    if ($stockBatchId) {
                        $this->batches->restock(StockBatch::findOrFail($stockBatchId), $baseQty);
                    }
                    if ($productSerialId) {
                        $this->serials->receiveReturn($item->serial, $return->id, (int) $branchId, isDamaged: false);
                    }
                } else {
                    AuditLog::record('return_item.silent_writeoff', $item, [
                        'quantity' => (string) $line['quantity'],
                        'product_id' => $line['product_id'],
                    ]);
                }
            }

            $return->update(['total' => $runningTotal]);

            // Compute refundable amount per invoice: cap at (paid − already_refunded).
            // If an invoice is unpaid or less was paid than the return value, only
            // refund what was actually collected.
            $refundableAmount = 0.0;
            foreach ($returnValueByInvoice as $invoiceId => $returnValue) {
                $invoice = Invoice::find($invoiceId);
                if (! $invoice) {
                    continue;
                }
                $paidAmount = (float) $invoice->paid_amount;
                $alreadyRefunded = $alreadyRefundedByInvoice[$invoiceId] ?? 0.0;
                $available = max(0.0, $paidAmount - $alreadyRefunded);
                $refundableAmount += min($returnValue, $available);
            }

            $auditData = [
                'reference' => $return->return_number,
                'refund_method' => $refundMethod,
                'total' => (string) $runningTotal,
                'refund_amount' => (string) round($refundableAmount, 2),
            ];

            if ($refundMethod === 'credit_note' && $refundableAmount > 0.001) {
                if ($this->posSettings->walkinOnly()) {
                    throw new RuntimeException(
                        'Credit notes are disabled while this tenant is in walk-in-only mode. Refund as cash instead.'
                    );
                }

                $cn = $this->creditNotes->issueForReturn($return, round($refundableAmount, 2));
                $auditData['cn'] = $cn->cn_number;
            }

            // Cash refunds leave the till automatically — attributed to whichever
            // session is currently open for the return's branch.
            //
            // Refusing the refund when no shift is open is deliberate: cash
            // physically leaving a drawer that no session owns can never be
            // reconciled, so it would surface later as an unexplainable shortage
            // on whoever's shift happened to be open next. The cashier is told
            // to open the till (or issue a credit note) rather than being let
            // through into a state nobody can balance.
            if ($refundMethod === RefundMethod::CASH && $refundableAmount > 0.001) {
                $branchId = $header['branch_id'] ?? $return->branch_id;
                $openSession = $branchId
                    ? PosSession::where('branch_id', $branchId)
                        ->whereHas('status', fn ($q) => $q->where('code', Status::SESSION_OPEN))
                        ->latest()
                        ->first()
                    : null;

                if (! $openSession) {
                    throw new RuntimeException(
                        'A cash refund needs an open till session so the money leaving the drawer can be reconciled. '
                        .'Open a till for this branch, or refund as a credit note instead.'
                    );
                }

                $this->cashMovements->recordRefundCash($return, $openSession, round($refundableAmount, 2));
            }

            $return->update([
                'refund_amount' => round($refundableAmount, 2),
                'status_id' => Status::idOf(Status::CTX_RETURN, Status::RETURN_CREDITED),
            ]);

            AuditLog::record('return.created', $return, $auditData);

            return $return;
        });
    }

    /**
     * The discount field is hidden in the form without the permission, but a
     * crafted payload could still submit one — rejected outright, the same
     * "no prior record to fall back on" rule CreateLegacyInvoice enforces
     * (a return has no edit flow, only create, so there is nothing to
     * preserve the way EditInvoice/EditSalesOrder do).
     *
     * @param  array<int, array{discount_value?:float}>  $lines
     */
    private function assertDiscountAllowed(array $lines): void
    {
        if (Auth::user()?->hasPermissionTo('isms_returns.discount')) {
            return;
        }

        $hasDiscount = collect($lines)->contains(fn (array $line): bool => (float) ($line['discount_value'] ?? 0) > 0);

        if ($hasDiscount) {
            throw new RuntimeException('You do not have permission to apply a discount on a return.');
        }
    }

    /**
     * Reverse and soft-delete a return document.
     *
     * Reverses stock movements and voids the associated credit note (if issued
     * and unclaimed). Blocks if the credit note has already been claimed.
     *
     * @throws RuntimeException if a fail-safe guard is triggered
     * @throws \InvalidArgumentException if the return is already deleted
     */
    public function delete(ReturnDoc $return): void
    {
        if ($return->trashed()) {
            throw new \InvalidArgumentException('Return is already deleted.');
        }

        DB::transaction(function () use ($return) {
            $return->load(['items.product:id,name', 'items.reason:id,code', 'items.stockBatch', 'items.serial', 'customer', 'branch:id,name']);

            // ── Fail-safe: block if the credit note is already claimed ────────
            $creditNote = CreditNote::query()
                ->where('return_id', $return->id)
                ->whereNot('status_id', Status::idOf(Status::CTX_CREDIT_NOTE, Status::VOID))
                ->first();

            if ($creditNote && $creditNote->status?->code === Status::CLAIMED) {
                throw new RuntimeException(
                    'Cannot delete return: the associated credit note has already been claimed. Reverse the claim first.'
                );
            }

            // ── Reverse stock per line ────────────────────────────────────────
            $branchId = (int) $return->branch_id;

            foreach ($return->items as $item) {
                if (! $item->product_id || ! $branchId) {
                    continue;
                }

                $linkedDamage = Damage::query()
                    ->where('source_type', 'return_item')
                    ->where('source_id', $item->id)
                    ->whereNull('deleted_at')
                    ->first();

                if ($linkedDamage) {
                    // Damaged items: reverse the RETURN_IN and the DAMAGE
                    $this->stock->record(
                        branchId: $branchId,
                        productId: (int) $item->product_id,
                        movementTypeCode: MovementType::RETURN_REVERSAL,
                        quantityChange: -(float) $item->quantity,
                        referenceType: $return,
                        referenceNumber: $return->return_number,
                        note: "Return deletion reversal of {$return->return_number}",
                        productName: $item->product?->name ?? "Product#{$item->product_id}",
                        branchName: $return->branch?->name,
                    );
                    $this->stock->record(
                        branchId: $branchId,
                        productId: (int) $item->product_id,
                        movementTypeCode: MovementType::DAMAGE_REVERSAL,
                        quantityChange: (float) $linkedDamage->quantity_damaged,
                        referenceType: $return,
                        referenceNumber: $return->return_number,
                        note: "Damage reversal for deleted return {$return->return_number}",
                        productName: $item->product?->name ?? "Product#{$item->product_id}",
                        branchName: $return->branch?->name,
                    );
                    $linkedDamage->delete();

                    if ($item->stockBatch) {
                        $this->batches->reverseDamagedReturn($item->stockBatch, (float) $item->quantity);
                    }
                    if ($item->serial) {
                        $this->serials->assignToInvoice($item->serial, (int) $item->invoice_id, (int) (Invoice::find($item->invoice_id)?->customer_id ?? 0));
                    }
                } elseif ($item->is_add_back_to_stock) {
                    // Stock-back items: reverse the RETURN_IN
                    $this->stock->record(
                        branchId: $branchId,
                        productId: (int) $item->product_id,
                        movementTypeCode: MovementType::RETURN_REVERSAL,
                        quantityChange: -(float) $item->quantity,
                        referenceType: $return,
                        referenceNumber: $return->return_number,
                        note: "Return deletion reversal of {$return->return_number}",
                        productName: $item->product?->name ?? "Product#{$item->product_id}",
                        branchName: $return->branch?->name,
                    );

                    if ($item->stockBatch) {
                        $this->batches->consume($item->stockBatch, (float) $item->quantity);
                    }
                    if ($item->serial) {
                        $this->serials->assignToInvoice($item->serial, (int) $item->invoice_id, (int) (Invoice::find($item->invoice_id)?->customer_id ?? 0));
                    }
                }
                // Silent write-off lines had no stock movement — nothing to reverse.
            }

            // ── Void the credit note and reverse the customer credit ───────────
            if ($creditNote) {
                $this->creditNotes->void($creditNote, "Return {$return->return_number} deleted.");

                $cnAmount = (float) $creditNote->amount;
                if ($cnAmount > 0.001 && $return->customer) {
                    $this->customerCredit->debit(
                        customer: $return->customer,
                        amount: $cnAmount,
                        reference: $return,
                        note: "Credit reversal for deleted return {$return->return_number}",
                    );
                }
            }

            AuditLog::record('return.deleted', $return, [
                'return_number' => $return->return_number,
                'customer' => $return->customer?->business_name,
                'total' => (string) $return->total,
                'cn_voided' => $creditNote?->cn_number,
            ]);

            $return->delete();
        });
    }

    private function computeDiscount(float $lineTotal, float $quantity, ?string $type, float $value): float
    {
        if (! $type || $value <= 0) {
            return 0;
        }

        // A fixed discount is a per-unit amount, so a partial-quantity return
        // only refunds the discount on the units actually returned.
        return match ($type) {
            'percentage' => round($lineTotal * ($value / 100), 2),
            'fixed' => min($lineTotal, $value * $quantity),
            default => 0,
        };
    }
}
