<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Events\SettlementApplied;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Models\Settlement;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Settling one participant's account: everything an operator has to be able to
 * do to a file that reality got ahead of.
 *
 * The books say a player owes two months. The truth, once someone asks at the
 * desk, is usually one of a handful of things: he paid in cash and nobody
 * entered it; he joined on the 20th and paid for the days that were left, which
 * closed that month; the academy agreed to drop a month; he bought a
 * registration card that was typed into a free-text line instead of sold as a
 * product; he was never billed for a month he trained. Each of those is a
 * different correction, and until now the only tools were a single-invoice
 * amount override and a back-fill wizard — so operators reached for whichever
 * one was closest and the ledger drifted further.
 *
 * Every action here is applied inside ONE transaction and recorded as ONE
 * settlement. Money movements go through PaymentService (so the ledger, the
 * invoice balance and the receipt all happen), stock goes through
 * InventoryService (so nothing touches quantity_on_hand directly), and a waiver
 * is written the way the rest of the system already reads one — as an
 * admin_override on the invoice, which is what makes the roster show
 * "خصم إداري" instead of an unexplained shortfall.
 *
 * Nothing in here calls auth(), request() or session(): the actor, the branch
 * and every amount arrive as parameters, because this service is called from a
 * wizard today and will be called from a command or an import tomorrow.
 */
class SettlementService
{
    /** Every action the wizard may ask for. Anything else is refused. */
    public const ACTIONS = [
        'record_payment',
        'settle_short',
        'waive_invoice',
        'bill_month',
        'sell_product',
        'link_line',
        'plan_installments',
        'move_payment',
        'credit_wallet',
    ];

    private const ARABIC_MONTHS = [
        1 => 'يناير', 2 => 'فبراير', 3 => 'مارس', 4 => 'أبريل',
        5 => 'مايو', 6 => 'يونيو', 7 => 'يوليو', 8 => 'أغسطس',
        9 => 'سبتمبر', 10 => 'أكتوبر', 11 => 'نوفمبر', 12 => 'ديسمبر',
    ];

    public function __construct(
        private InvoiceService $invoices,
        private PaymentService $payments,
        private InventoryService $inventory,
        private WalletService $wallets,
    ) {}

    /**
     * Apply a reviewed set of corrections to one participant's account.
     *
     * All or nothing: a settlement that fails halfway is a worse state than the
     * one it started from, because the operator has already told the parent the
     * account is clear.
     *
     * @param array<int, array<string, mixed>> $actions
     */
    public function apply(
        Participant $participant,
        array $actions,
        string $reason,
        User $actor,
        ?int $branchId = null
    ): Settlement {
        if ($actions === []) {
            throw new DomainException('لا توجد إجراءات لتنفيذها');
        }

        if (mb_strlen(trim($reason)) < 10) {
            throw new DomainException('سبب التسوية مطلوب (10 أحرف على الأقل)');
        }

        foreach ($actions as $action) {
            if (! in_array($action['type'] ?? '', self::ACTIONS, true)) {
                throw new DomainException('إجراء غير معروف: ' . ($action['type'] ?? '—'));
            }
        }

        $branchId ??= $participant->branch_id;

        return DB::transaction(function () use ($participant, $actions, $reason, $actor, $branchId) {
            $results = [];
            $collected = 0;
            $waived = 0;
            $billed = 0;

            foreach ($actions as $action) {
                $result = match ($action['type']) {
                    'record_payment' => $this->recordPayment($participant, $action, $actor, $branchId),
                    'settle_short' => $this->settleShort($participant, $action, $actor, $branchId),
                    'waive_invoice' => $this->waiveInvoice($participant, $action, $actor),
                    'bill_month' => $this->billMonth($participant, $action, $actor, $branchId),
                    'sell_product' => $this->sellProduct($participant, $action, $actor, $branchId),
                    'link_line' => $this->linkLine($participant, $action),
                    'plan_installments' => $this->planInstallments($participant, $action),
                    'move_payment' => $this->movePayment($participant, $action, $actor),
                    'credit_wallet' => $this->creditWallet($participant, $action, $actor),
                };

                $collected += $result['collected'] ?? 0;
                $waived += $result['waived'] ?? 0;
                $billed += $result['billed'] ?? 0;

                $results[] = ['type' => $action['type']] + $result;
            }

            $settlement = Settlement::create([
                'academy_id' => $participant->academy_id,
                'branch_id' => $branchId,
                'participant_id' => $participant->id,
                // Placeholder: the readable reference is the row's own id, which
                // does not exist until it is inserted. See below.
                'reference' => 'SET-' . \Illuminate\Support\Str::uuid(),
                'reason' => trim($reason),
                'actions' => $results,
                'collected_amount' => $collected,
                'waived_amount' => $waived,
                'billed_amount' => $billed,
                'applied_by' => $actor->id,
                'applied_at' => now(),
            ]);

            // Numbered from the row's own id rather than from a counted
            // max(id): counting needs a lock to be safe, and Postgres refuses
            // FOR UPDATE on an aggregate — so two settlements saved in the same
            // second would race for the same number and one whole settlement
            // would be rolled back after the operator had already confirmed it.
            $settlement->update([
                'reference' => 'SET-' . str_pad((string) $settlement->id, 6, '0', STR_PAD_LEFT),
            ]);

            Log::channel('audit')->info('settlement_applied', [
                'settlement_id' => $settlement->id,
                'reference' => $settlement->reference,
                'participant_id' => $participant->id,
                'actor_id' => $actor->id,
                'actor_name' => $actor->name,
                'branch_id' => $branchId,
                'collected' => $collected,
                'waived' => $waived,
                'billed' => $billed,
                'reason' => $settlement->reason,
                'actions' => $results,
            ]);

            SettlementApplied::dispatch($settlement, $actor);

            return $settlement;
        });
    }

    // ---- actions ----------------------------------------------------------

    /**
     * Money that was handed over and never entered. The date is the day it was
     * actually taken, not today — a receipt dated three weeks late is still a
     * receipt, but a payment filed on the wrong day makes every daily closing
     * after it wrong.
     */
    private function recordPayment(Participant $participant, array $a, User $actor, ?int $branchId): array
    {
        $invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
        $amount = (int) ($a['amount'] ?? 0);

        if ($amount <= 0) {
            throw new DomainException('مبلغ الدفعة يجب أن يكون أكبر من صفر');
        }

        $payment = $this->payments->recordPayment([
            'invoice_id' => $invoice->id,
            'branch_id' => $invoice->branch_id ?: $branchId,
            'amount' => $amount,
            'method' => $a['method'] ?? 'cash',
            'direction' => 'inbound',
            'currency' => $invoice->currency ?? 'EGP',
            'payment_date' => $this->date($a['date'] ?? null)->toDateString(),
            'notes' => $this->note($a, 'تسوية: دفعة سابقة لم تُسجَّل'),
        ], $actor);

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'payment_id' => $payment->id,
            'collected' => $amount,
            'label' => 'تحصيل ' . format_money($amount) . ' على ' . $invoice->number,
        ];
    }

    /**
     * The month closed for less than it was billed — he joined on the 20th and
     * paid for the days that were left, or the academy agreed a number. What
     * was collected is collected; the rest is written off with a reason, and
     * the month stops being owed.
     */
    private function settleShort(Participant $participant, array $a, User $actor, ?int $branchId): array
    {
        $invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
        $amount = (int) ($a['amount'] ?? 0);

        if ($amount < 0) {
            throw new DomainException('المبلغ لا يمكن أن يكون سالباً');
        }

        $paymentId = null;
        if ($amount > 0) {
            $payment = $this->payments->recordPayment([
                'invoice_id' => $invoice->id,
                'branch_id' => $invoice->branch_id ?: $branchId,
                'amount' => $amount,
                'method' => $a['method'] ?? 'cash',
                'direction' => 'inbound',
                'currency' => $invoice->currency ?? 'EGP',
                'payment_date' => $this->date($a['date'] ?? null)->toDateString(),
                'notes' => $this->note($a, 'تسوية: سداد جزئي يُغلق الشهر'),
            ], $actor);
            $paymentId = $payment->id;
        }

        $waived = $this->writeOffRemainder($invoice, $actor, $this->note($a, 'تسوية: إغلاق الشهر بالمبلغ المحصَّل'));

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'payment_id' => $paymentId,
            'collected' => $amount,
            'waived' => $waived,
            'label' => 'إغلاق ' . $invoice->number . ' بـ ' . format_money($amount)
                . ($waived > 0 ? ' وإسقاط ' . format_money($waived) : ''),
        ];
    }

    /** The month is dropped entirely: nothing was owed, or nothing will be collected. */
    private function waiveInvoice(Participant $participant, array $a, User $actor): array
    {
        $invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));

        // An invoice raised at zero — no price existed when it was issued — has
        // nothing to write off, and leaving it standing keeps a meaningless row
        // in the member's file forever. Dropping it is the whole intent here.
        if ((int) $invoice->total_amount === 0 && (int) $invoice->paid_amount === 0) {
            $this->invoices->cancel($invoice, $actor);

            return [
                'invoice_id' => $invoice->id,
                'invoice_number' => $invoice->number,
                'waived' => 0,
                'label' => 'إلغاء فاتورة صفرية ' . $invoice->number,
            ];
        }

        $waived = $this->writeOffRemainder($invoice, $actor, $this->note($a, 'تسوية: إسقاط الشهر'));

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'waived' => $waived,
            'label' => 'إسقاط ' . $invoice->number . ' (' . format_money($waived) . ')',
        ];
    }

    /**
     * A month he trained and nobody billed. Written with the month named in the
     * line, not implied by the issue date, so the roster files it under the
     * month it pays for however late it is entered (see SubscriptionLine).
     */
    private function billMonth(Participant $participant, array $a, User $actor, ?int $branchId): array
    {
        $amount = (int) ($a['amount'] ?? 0);
        if ($amount <= 0) {
            throw new DomainException('قيمة الشهر يجب أن تكون أكبر من صفر');
        }

        $month = (string) ($a['month'] ?? '');
        if (! preg_match('/^\d{4}-\d{2}$/', $month)) {
            throw new DomainException('الشهر يجب أن يكون بصيغة YYYY-MM');
        }

        $enrollment = Enrollment::withoutGlobalScopes()
            ->with('program')
            ->where('id', (int) ($a['enrollment_id'] ?? 0))
            ->where('participant_id', $participant->id)
            ->first();

        if (! $enrollment) {
            throw new DomainException('الاشتراك غير موجود لهذا المشترك');
        }

        $monthDate = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay();
        $label = self::ARABIC_MONTHS[$monthDate->month] . ' ' . $monthDate->year;
        $programName = $enrollment->program?->name_ar ?? 'اشتراك';

        $invoice = $this->invoices->create([
            'academy_id' => $participant->academy_id,
            'branch_id' => $enrollment->branch_id ?: ($branchId ?: $participant->branch_id),
            'billable_type' => $participant->getMorphClass(),
            'billable_id' => $participant->id,
            'total_amount' => $amount,
            'subtotal_amount' => $amount,
            'discount_amount' => 0,
            'tax_amount' => 0,
            'service_fee_amount' => 0,
            'issue_date' => $monthDate->toDateString(),
            'due_date' => $monthDate->copy()->addDays(7)->toDateString(),
            'contact_name' => $participant->person?->name_ar,
            'notes' => 'تسوية: اشتراك ' . $label . ' — ' . $programName,
            'metadata' => ['settlement' => true, 'month' => $month],
        ], [
            [
                'description' => 'اشتراك ' . $label . ': ' . $programName,
                'quantity' => 1,
                'unit_price' => $amount,
                'discount_amount' => 0,
                'tax_amount' => 0,
            ],
        ], $actor);

        $invoice->update(['status' => InvoiceStatus::Sent]);

        $paid = (int) ($a['paid_amount'] ?? 0);
        $paymentId = null;
        $waived = 0;

        if ($paid > 0) {
            $payment = $this->payments->recordPayment([
                'invoice_id' => $invoice->id,
                'branch_id' => $invoice->branch_id,
                'amount' => min($paid, $amount),
                'method' => $a['method'] ?? 'cash',
                'direction' => 'inbound',
                'currency' => 'EGP',
                'payment_date' => $this->date($a['date'] ?? null, $monthDate)->toDateString(),
                'notes' => $this->note($a, 'تسوية: تحصيل شهر ' . $label),
            ], $actor);
            $paymentId = $payment->id;
        }

        // "Paid a part and the month is closed" is the same decision as
        // settle_short, expressed while creating the invoice.
        if (! empty($a['close_month']) && $paid < $amount) {
            $waived = $this->writeOffRemainder($invoice->fresh(), $actor, $this->note($a, 'تسوية: إغلاق ' . $label));
        }

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'payment_id' => $paymentId,
            'month' => $month,
            'billed' => $amount,
            'collected' => min($paid, $amount),
            'waived' => $waived,
            'label' => 'فاتورة ' . $label . ' بمبلغ ' . format_money($amount),
        ];
    }

    /**
     * A card or a kit he already has, that was never sold through the system.
     *
     * The line carries the product's morph — that is what makes it a product
     * sale in every report rather than a description someone typed — and the
     * stock leaves the warehouse unless the operator says the count was already
     * adjusted by hand.
     */
    private function sellProduct(Participant $participant, array $a, User $actor, ?int $branchId): array
    {
        $product = Product::withoutGlobalScopes()
            ->where('id', (int) ($a['product_id'] ?? 0))
            ->where('academy_id', $participant->academy_id)
            ->first();

        if (! $product) {
            throw new DomainException('المنتج غير موجود');
        }

        $quantity = max(1, (int) ($a['quantity'] ?? 1));
        $unitPrice = (int) ($a['unit_price'] ?? 0);

        if ($unitPrice <= 0) {
            throw new DomainException('سعر المنتج يجب أن يكون أكبر من صفر');
        }

        $total = $unitPrice * $quantity;
        $date = $this->date($a['date'] ?? null);
        $invoiceBranch = $branchId ?: $participant->branch_id;

        $invoice = $this->invoices->create([
            'academy_id' => $participant->academy_id,
            'branch_id' => $invoiceBranch,
            'billable_type' => $participant->getMorphClass(),
            'billable_id' => $participant->id,
            'total_amount' => $total,
            'subtotal_amount' => $total,
            'discount_amount' => 0,
            'tax_amount' => 0,
            'service_fee_amount' => 0,
            'issue_date' => $date->toDateString(),
            'due_date' => $date->toDateString(),
            'contact_name' => $participant->person?->name_ar,
            'notes' => 'تسوية: ' . $product->name_ar,
            'metadata' => ['settlement' => true, 'retro_product_sale' => true],
        ], [
            [
                'description' => $product->name_ar,
                'quantity' => $quantity,
                'unit_price' => $unitPrice,
                'discount_amount' => 0,
                'tax_amount' => 0,
                'itemable_type' => $product->getMorphClass(),
                'itemable_id' => $product->id,
            ],
        ], $actor);

        $invoice->update(['status' => InvoiceStatus::Sent]);

        $paid = min((int) ($a['paid_amount'] ?? 0), $total);
        $paymentId = null;

        if ($paid > 0) {
            $payment = $this->payments->recordPayment([
                'invoice_id' => $invoice->id,
                'branch_id' => $invoice->branch_id,
                'amount' => $paid,
                'method' => $a['method'] ?? 'cash',
                'direction' => 'inbound',
                'currency' => 'EGP',
                'payment_date' => $date->toDateString(),
                'notes' => $this->note($a, 'تسوية: ' . $product->name_ar),
            ], $actor);
            $paymentId = $payment->id;
        }

        $movementId = null;
        if (! empty($a['adjust_stock']) && $product->track_inventory) {
            $movementId = $this->deductStock($product, $quantity, $invoiceBranch, $actor, $invoice->number);
        }

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'payment_id' => $paymentId,
            'product_id' => $product->id,
            'movement_id' => $movementId,
            'billed' => $total,
            'collected' => $paid,
            'label' => 'بيع ' . $product->name_ar . ' بأثر رجعي (' . format_money($total) . ')',
        ];
    }

    /**
     * The money is already on an invoice; only its classification is wrong.
     *
     * No amount changes — this is the difference between "a line someone typed"
     * and "a sale of this product", which is what every product report, and the
     * roster's bundle column, is counting.
     */
    private function linkLine(Participant $participant, array $a): array
    {
        $item = InvoiceItem::withoutGlobalScopes()
            ->with('invoice')
            ->where('id', (int) ($a['invoice_item_id'] ?? 0))
            ->first();

        if (! $item || ! $item->invoice
            || (int) $item->invoice->billable_id !== (int) $participant->id
            || $item->invoice->billable_type !== $participant->getMorphClass()) {
            throw new DomainException('البند غير موجود على فواتير هذا المشترك');
        }

        if ($item->itemable_type !== null) {
            throw new DomainException('البند مرتبط بمنتج بالفعل');
        }

        $product = Product::withoutGlobalScopes()
            ->where('id', (int) ($a['product_id'] ?? 0))
            ->where('academy_id', $participant->academy_id)
            ->first();

        if (! $product) {
            throw new DomainException('المنتج غير موجود');
        }

        $item->update([
            'itemable_type' => $product->getMorphClass(),
            'itemable_id' => $product->id,
        ]);

        return [
            'invoice_item_id' => $item->id,
            'invoice_id' => $item->invoice_id,
            'invoice_number' => $item->invoice->number,
            'product_id' => $product->id,
            'label' => 'ربط بند «' . $item->description . '» بمنتج ' . $product->name_ar,
        ];
    }

    /**
     * Put an agreed instalment plan on record, with the instalments already
     * collected counted. Without this a card being paid in three goes reads as
     * a shortfall and the coach chases money nobody owes yet.
     */
    private function planInstallments(Participant $participant, array $a): array
    {
        $invoice = $this->participantInvoice($participant, (int) ($a['invoice_id'] ?? 0));
        $total = (int) ($a['total_installments'] ?? 0);
        $paidCount = (int) ($a['paid_installments'] ?? 0);

        if ($total < 2) {
            throw new DomainException('عدد الأقساط يجب أن يكون اثنين على الأقل');
        }

        if ($paidCount < 0 || $paidCount > $total) {
            throw new DomainException('عدد الأقساط المسددة غير منطقي');
        }

        $existing = PaymentPlan::withoutGlobalScopes()->where('invoice_id', $invoice->id)->first();
        if ($existing) {
            throw new DomainException('للفاتورة خطة أقساط بالفعل');
        }

        $amount = (int) ($a['installment_amount'] ?? intdiv($invoice->total_amount, $total));

        $plan = PaymentPlan::create([
            'academy_id' => $invoice->academy_id,
            'invoice_id' => $invoice->id,
            // Only these four exist in payment_plans_status_check; a plan with
            // some instalments paid is still `active`, and the count is what
            // says how far along it is.
            'status' => $paidCount >= $total ? 'completed' : 'active',
            'total_installments' => $total,
            'paid_installments' => $paidCount,
            'installment_amount' => $amount,
            'frequency' => $a['frequency'] ?? 'monthly',
            'start_date' => $invoice->issue_date?->toDateString() ?? now()->toDateString(),
            'next_due_date' => $this->date($a['next_due_date'] ?? null)->toDateString(),
            'notes' => $this->note($a, 'تسوية: خطة أقساط متفق عليها'),
        ]);

        return [
            'invoice_id' => $invoice->id,
            'invoice_number' => $invoice->number,
            'payment_plan_id' => $plan->id,
            'label' => 'خطة أقساط ' . $paidCount . '/' . $total . ' على ' . $invoice->number,
        ];
    }

    /**
     * The money was taken, entered, and put against the wrong month.
     *
     * Common when two invoices are open at once: the receptionist settles
     * whichever the screen offered, and September's money closes July while
     * September goes on looking unpaid. The payment is reversed off the invoice
     * it should never have touched and re-recorded against the right one,
     * keeping its original date and method — both invoices end up telling the
     * truth, and the ledger carries the reversal rather than an edit.
     */
    private function movePayment(Participant $participant, array $a, User $actor): array
    {
        $payment = Payment::withoutGlobalScopes()
            ->where('id', (int) ($a['payment_id'] ?? 0))
            ->whereNull('deleted_at')
            ->first();

        if (! $payment || ! $payment->invoice_id) {
            throw new DomainException('الدفعة غير موجودة');
        }

        $from = $this->participantInvoice($participant, (int) $payment->invoice_id);
        $to = $this->participantInvoice($participant, (int) ($a['to_invoice_id'] ?? 0));

        if ($from->id === $to->id) {
            throw new DomainException('الدفعة على هذه الفاتورة بالفعل');
        }

        if ($payment->direction !== 'inbound' || $payment->status !== PaymentStatus::Confirmed) {
            throw new DomainException('لا يمكن نقل إلا دفعة واردة مؤكدة');
        }

        $amount = (int) $payment->amount;

        if ($amount > (int) $to->due_amount) {
            throw new DomainException(
                'المبلغ أكبر من المتبقي على ' . $to->number . ' (' . format_money($to->due_amount) . ')'
            );
        }

        // Reverse it off the wrong invoice: an outbound row, the ledger entry
        // that goes with it, and the balance restored. The original payment row
        // is left standing — payments are history, not a draft.
        $this->payments->refund($payment, $amount, $actor);

        $moved = $this->payments->recordPayment([
            'invoice_id' => $to->id,
            'branch_id' => $to->branch_id ?: $payment->branch_id,
            'amount' => $amount,
            'method' => $payment->method,
            'direction' => 'inbound',
            'currency' => $payment->currency ?? 'EGP',
            'payment_date' => $payment->payment_date?->toDateString() ?? now()->toDateString(),
            'notes' => $this->note($a, 'تسوية: نقل دفعة من ' . $from->number . ' إلى ' . $to->number),
        ], $actor);

        return [
            'payment_id' => $moved->id,
            'reversed_payment_id' => $payment->id,
            'from_invoice_id' => $from->id,
            'invoice_id' => $to->id,
            'invoice_number' => $to->number,
            // Not counted as collected: this money was already in the books.
            'label' => 'نقل ' . format_money($amount) . ' من ' . $from->number . ' إلى ' . $to->number,
        ];
    }

    /**
     * Money in hand that belongs to the member rather than to any invoice — he
     * paid 1,000 against a 900 bill, or a month he had already settled was
     * later dropped.
     *
     * It goes on his wallet as a credit he can spend on the next invoice, which
     * is what the wallet is for. It is not counted as collected: the cash was
     * recorded when it arrived, and counting it twice would overstate the day.
     */
    private function creditWallet(Participant $participant, array $a, User $actor): array
    {
        $amount = (int) ($a['amount'] ?? 0);

        if ($amount <= 0) {
            throw new DomainException('قيمة الرصيد يجب أن تكون أكبر من صفر');
        }

        $wallet = $this->wallets->getOrCreateWallet($participant, (int) $participant->academy_id);

        $transaction = $this->wallets->deposit(
            $wallet,
            $amount,
            $this->note($a, 'تسوية: رصيد لصالح المشترك'),
            $participant,
            $actor,
        );

        return [
            'wallet_id' => $wallet->id,
            'wallet_transaction_id' => $transaction->id,
            'label' => 'إضافة ' . format_money($amount) . ' لمحفظة المشترك',
        ];
    }

    // ---- helpers ----------------------------------------------------------

    /**
     * Write the remaining balance off the invoice, in the shape the rest of the
     * system already reads: discount_amount grows, total_amount shrinks,
     * subtotal_amount is untouched — which is what lets
     * ParticipantBillingService still say "650 of 900, because an admin
     * discounted it" rather than losing the original figure.
     *
     * @return int piastres written off
     */
    private function writeOffRemainder(Invoice $invoice, User $actor, string $reason): int
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('id', $invoice->id)
            ->lockForUpdate()
            ->firstOrFail();

        $remaining = max(0, (int) $invoice->total_amount - (int) $invoice->paid_amount);

        if ($remaining <= 0) {
            return 0;
        }

        $originalTotal = (int) $invoice->total_amount;
        $newTotal = (int) $invoice->paid_amount;

        $metadata = $invoice->metadata ?? [];
        $metadata['admin_override'] = [
            'applied_at' => now()->toIso8601String(),
            'applied_by_id' => $actor->id,
            'applied_by_name' => $actor->name,
            'original_total_piasters' => $originalTotal,
            'original_due_piasters' => $remaining,
            'new_total_piasters' => $newTotal,
            'discount_piasters' => $remaining,
            'reason' => $reason,
            'source' => 'settlement',
        ];

        $invoice->update([
            'discount_amount' => (int) $invoice->discount_amount + $remaining,
            'total_amount' => $newTotal,
            'due_amount' => 0,
            // Nothing was ever collected and nothing will be: the month drops
            // out of the books entirely rather than sitting at zero pretending
            // to be a settled bill.
            'status' => $newTotal > 0 ? InvoiceStatus::Paid : InvoiceStatus::Cancelled,
            'paid_at' => $newTotal > 0 ? ($invoice->paid_at ?? now()) : $invoice->paid_at,
            'cancelled_at' => $newTotal > 0 ? $invoice->cancelled_at : now(),
            'metadata' => $metadata,
        ]);

        return $remaining;
    }

    private function deductStock(Product $product, int $quantity, ?int $branchId, User $actor, string $invoiceNumber): ?int
    {
        // Same resolution the POS uses: the branch's own warehouse, falling
        // back to the academy-wide one, and silence rather than a failed
        // settlement when a branch has none configured.
        $warehouse = Warehouse::withoutGlobalScopes()
            ->where('is_active', true)
            ->where(fn ($q) => $q->where('branch_id', $branchId)->orWhereNull('branch_id'))
            ->orderByRaw('CASE WHEN branch_id = ? THEN 0 ELSE 1 END', [$branchId])
            ->first();

        if (! $warehouse) {
            return null;
        }

        $movement = $this->inventory->createMovement(
            product: $product,
            warehouse: $warehouse,
            type: MovementType::Sale,
            quantity: $quantity,
            actor: $actor,
            unitCost: $product->cost_price,
            reason: 'تسوية: بيع بأثر رجعي — ' . $invoiceNumber,
        );

        return $movement->id;
    }

    /** An invoice that actually belongs to this participant. */
    private function participantInvoice(Participant $participant, int $invoiceId): Invoice
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('id', $invoiceId)
            ->whereNull('deleted_at')
            ->first();

        if (! $invoice
            || (int) $invoice->billable_id !== (int) $participant->id
            || $invoice->billable_type !== $participant->getMorphClass()) {
            throw new DomainException('الفاتورة غير موجودة لهذا المشترك');
        }

        if ($invoice->status === InvoiceStatus::Cancelled) {
            throw new DomainException('الفاتورة ملغاة: ' . $invoice->number);
        }

        return $invoice;
    }

    private function date(?string $value, ?Carbon $fallback = null): Carbon
    {
        if ($value) {
            $date = Carbon::parse($value);

            if ($date->isFuture()) {
                throw new DomainException('لا يمكن تسجيل تسوية بتاريخ مستقبلي');
            }

            return $date;
        }

        return $fallback ?? now();
    }

    private function note(array $action, string $default): string
    {
        $note = trim((string) ($action['note'] ?? ''));

        return $note !== '' ? $note : $default;
    }
}
