<?php

namespace App\Livewire\Admin;

use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Financial\Services\SettlementService;
use App\Domain\Financial\Support\BundledProductLine;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Support\ArabicSearch;
use App\Domain\Training\Models\TrainingGroup;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;

/**
 * Settle one participant's account, end to end.
 *
 * The desk already had four tools that each did a slice of this — collect a
 * payment, correct one invoice's amount, back-fill missing invoices, register
 * someone who started months ago — and none of them could answer the question
 * an operator actually has in front of a parent: *this file is wrong, in
 * several ways at once, what do we do about all of it?* So the corrections were
 * made wherever a screen allowed them, and the ledger drifted.
 *
 * This screen puts the whole account on one page: every invoice with what is
 * left on it, every free-text line that is really a product sale, every month
 * nobody billed, and the bundle nobody charged for. The operator builds a list
 * of corrections, sees exactly what it will collect, waive and bill, gives a
 * reason, and applies it as one settlement.
 *
 * Nothing is written until the last step, and everything is written in one
 * transaction (SettlementService). Branch handling is the models' — Participant
 * and Invoice both carry BranchScope, so the search, the invoice list and the
 * settlement all stay inside the branch the operator is in.
 */
#[Layout('layouts.app')]
#[Title('تسوية حساب مشترك')]
class AccountSettlementWizard extends Component
{
    public int $currentStep = 1;

    // Step 1
    public string $search = '';

    /**
     * Locked: resolved server-side from a branch-scoped query. A plain public
     * property here would let the browser point the whole wizard at another
     * branch's participant — validating in mount() and then trusting it in
     * render() is exactly the IDOR the Livewire rule warns about.
     */
    #[Locked]
    public ?int $participantId = null;

    /**
     * The corrections chosen so far. Every id inside is re-checked against this
     * participant by SettlementService when the settlement is applied, so a
     * tampered cart cannot reach another player's invoice.
     *
     * @var array<int, array<string, mixed>>
     */
    public array $cart = [];

    // The open editor: which correction is being described right now.
    public ?string $draftType = null;
    public array $draft = [];

    // Step 3
    public string $reason = '';

    // Step 4
    #[Locked]
    public ?array $result = null;

    public function mount(?int $participant = null): void
    {
        $this->authorize('settlements.manage');

        if ($participant) {
            // Scoped find: a participant from another branch, or one who has
            // been deleted, must not open here. Saying so beats dropping the
            // operator back on the search box with no explanation — they
            // followed a link and deserve to know why it went nowhere.
            $found = Participant::with('person')->find($participant);

            if ($found) {
                $this->participantId = $found->id;
                $this->currentStep = 2;
            } else {
                session()->flash('error', __('هذا المشترك غير متاح في الفرع الحالي أو تم حذفه — ابحث عنه بالاسم.'));
            }
        }
    }

    // ---- step 1 -----------------------------------------------------------

    public function selectParticipant(int $id): void
    {
        $this->authorize('settlements.manage');

        // Branch-scoped find: the global scope is the check.
        $participant = Participant::find($id);

        if (! $participant) {
            session()->flash('error', __('المشترك غير موجود'));

            return;
        }

        $this->participantId = $participant->id;
        $this->cart = [];
        $this->draftType = null;
        $this->draft = [];
        $this->currentStep = 2;
    }

    public function searchResults()
    {
        if (mb_strlen(trim($this->search)) < 2) {
            return collect();
        }

        // Folded spelling and word-wise matching: "عبدالله صلاح" has to find
        // "عبدالله أحمد صلاح سيد", and "احمد" has to find "أحمد". See
        // ArabicSearch for why a plain LIKE cannot.
        return Participant::with(['person', 'enrollments.program'])
            ->whereHas('person', fn ($q) => ArabicSearch::apply(
                $q,
                $this->search,
                ['people.name_ar', 'people.name'],
                'people.phone',
            ))
            ->limit(15)
            ->get();
    }

    // ---- step 2: the account ---------------------------------------------

    public function getParticipantProperty(): ?Participant
    {
        if (! $this->participantId) {
            return null;
        }

        return Participant::with(['person', 'enrollments.program'])->find($this->participantId);
    }

    public function getInvoicesProperty()
    {
        if (! $this->participantId) {
            return collect();
        }

        return Invoice::with('items')
            ->where('billable_type', Participant::class)
            ->where('billable_id', $this->participantId)
            ->orderBy('issue_date')
            ->get();
    }

    /**
     * Confirmed money already on this account, so a payment filed against the
     * wrong month can be pointed at the right one.
     */
    public function getPaymentsProperty()
    {
        if (! $this->participantId) {
            return collect();
        }

        $invoiceIds = $this->invoices->pluck('id');

        if ($invoiceIds->isEmpty()) {
            return collect();
        }

        return \App\Domain\Financial\Models\Payment::query()
            ->whereIn('invoice_id', $invoiceIds)
            ->where('direction', 'inbound')
            ->where('status', \App\Domain\Financial\Enums\PaymentStatus::Confirmed)
            ->orderBy('payment_date')
            ->get();
    }

    public function getDiagnosisProperty(): array
    {
        $participant = $this->participant;

        if (! $participant) {
            return [
                'money' => [], 'handtyped' => [], 'bundle_status' => [],
                'missing_bundle' => [], 'partial_bundle' => [], 'over_billed' => [],
                'unbilled_months' => [], 'duplicate_of' => [], 'no_enrollment' => null,
            ];
        }

        return app(AccountAnomalyScanner::class)->forParticipant($participant);
    }

    /**
     * For each requirement billed above its price, the exact invoice to reduce
     * and the exact amount to reduce it to.
     *
     * Detecting a double-billing is only half a tool. The operator standing in
     * front of a parent needs to know *which* of six invoices carries the
     * duplicate, and every second spent working that out by hand is a second in
     * which the wrong one gets cancelled. So the excess is matched back to the
     * invoices that could be carrying it, newest first, and only those with
     * nothing collected against them — money that actually arrived is never a
     * candidate for deletion, and an account whose excess sits entirely on paid
     * invoices is reported as needing a person rather than offered a button.
     *
     * @return array<int, array{product_name:string, over_billed:int, fixes:array, blocked:bool, collected_excess:int}>
     */
    public function getOverBilledFixesProperty(): array
    {
        $participant = $this->participant;
        $rows = $this->diagnosis['over_billed'] ?? [];

        if (! $participant || $rows === []) {
            return [];
        }

        $programNames = $participant->enrollments
            ->pluck('program.name_ar')->filter()->values()->all();

        $out = [];

        foreach ($rows as $row) {
            $identifying = BundledProductLine::identifyingWords($row['product_name'], $programNames);

            // Invoices that carry this product as free text — the hand-typed
            // instalments the excess is made of. A real product line froze its
            // own price and is not what over-billed this account.
            $carrying = $this->invoices->filter(function (Invoice $invoice) use ($row, $identifying) {
                return $invoice->items->contains(fn ($item) => $item->itemable_type === null
                    && BundledProductLine::matches($item->description, $row['product_name'], $identifying));
            });

            $unpaid = $carrying->where('paid_amount', 0)->sortByDesc('issue_date')->values();

            $left = (int) $row['over_billed'];
            $fixes = [];

            foreach ($unpaid as $invoice) {
                if ($left <= 0) {
                    break;
                }

                $before = (int) $invoice->total_amount;
                $cut = min($left, $before);
                $left -= $cut;

                $fixes[] = [
                    'invoice_id' => $invoice->id,
                    'invoice_number' => $invoice->number,
                    'description' => (string) ($invoice->items->first()?->description ?? ''),
                    'lines' => $invoice->items->count(),
                    'current' => $before,
                    'new_amount' => $before - $cut,
                    'removed' => $cut,
                ];
            }

            $out[] = [
                'product_name' => $row['product_name'],
                'over_billed' => (int) $row['over_billed'],
                'billed' => (int) $row['billed'],
                'price' => (int) $row['price'],
                'paid' => (int) $row['paid'],
                'fixes' => $fixes,
                // What could not be taken off an uncollected invoice: real money
                // that has to be moved or refunded, which is not this tool's call.
                'collected_excess' => $left,
                'blocked' => $left > 0,
            ];
        }

        return $out;
    }

    /**
     * What a month of this participant's programme costs today — the number the
     * operator starts from when billing a month nobody billed. The pricing
     * engine is the authority; when it has no price (which is a hard fail at
     * sale time, deliberately) the last subscription invoice is the next best
     * evidence, and the operator can always overwrite it.
     */
    public function getSuggestedMonthlyPriceProperty(): int
    {
        $participant = $this->participant;
        $enrollment = $participant?->enrollments->first();

        if ($enrollment?->program) {
            try {
                return (int) app(PricingService::class)->calculate(
                    priceable: $enrollment->program,
                    participant: $participant,
                    branchId: $enrollment->branch_id ?? $participant->branch_id,
                )->finalAmount;
            } catch (\Throwable) {
                // No active price: fall through to the evidence on the books.
            }
        }

        return (int) ($this->invoices->where('total_amount', '>', 0)->last()?->total_amount ?? 0);
    }

    /**
     * The groups a lost player can be put back into, best guess first.
     *
     * The enrolment that knew which programme he was in is gone; the only
     * surviving record is the programme name written on his last subscription
     * invoice. That name belongs to a programme that no longer exists — it was
     * deleted and recreated under a new one — so it cannot be looked up, only
     * matched.
     *
     * Matching is on the years in the name, because this club's programmes are
     * birth-year cohorts: «اكاديمية 2017 -2018» and «أكاديمية (2017-2018)» share
     * nothing as strings and are obviously the same cohort to a human. Years
     * overlap or they do not, and that survives every spelling of أكاديمية,
     * every stray space and every kind of bracket.
     *
     * It is a suggestion and it is labelled as one. Cohorts that write their
     * years in two digits («21/22/23») match nothing, and a squad whose
     * programme was retired has no successor to point at — those are exactly the
     * cases where the person at the desk knows and this screen does not, so the
     * full list is always there and nothing is preselected without being shown.
     *
     * @return \Illuminate\Support\Collection<int, TrainingGroup>
     */
    public function getRestorableGroupsProperty()
    {
        $participant = $this->participant;

        if (! $participant) {
            return collect();
        }

        $suggested = $this->suggestedGroupId;

        return TrainingGroup::with('program')
            ->whereHas('program', fn ($q) => $q->whereNull('deleted_at'))
            ->whereIn('status', ['forming', 'active'])
            ->when($participant->branch_id, fn ($q) => $q->where('branch_id', $participant->branch_id))
            ->get()
            // One composite key, not an array of closures: sortBy() reads an
            // array as [column, direction] pairs, so passing two callbacks
            // silently sorted by neither and the suggestion was buried
            // mid-list — where an operator in a hurry never sees it.
            ->sortBy(fn ($group) => ($group->id === $suggested ? '0' : '1') . ($group->program?->name_ar ?? ''))
            ->values();
    }

    /** The group the player's own last invoice points at, or null. */
    public function getSuggestedGroupIdProperty(): ?int
    {
        $wanted = $this->lastProgrammeYears();

        if ($wanted === []) {
            return null;
        }

        foreach ($this->restorableGroupsUnsorted() as $group) {
            $years = $this->yearsIn((string) ($group->program?->name_ar ?? ''));

            if (array_intersect($wanted, $years) !== []) {
                return (int) $group->id;
            }
        }

        return null;
    }

    /** @return \Illuminate\Support\Collection<int, TrainingGroup> */
    private function restorableGroupsUnsorted()
    {
        $participant = $this->participant;

        return TrainingGroup::with('program')
            ->whereHas('program', fn ($q) => $q->whereNull('deleted_at'))
            ->whereIn('status', ['forming', 'active'])
            ->when($participant?->branch_id, fn ($q) => $q->where('branch_id', $participant->branch_id))
            ->get();
    }

    /** @return array<int,int> */
    private function lastProgrammeYears(): array
    {
        return $this->yearsIn((string) ($this->diagnosis['no_enrollment']['last_program'] ?? ''));
    }

    /** @return array<int,int> */
    private function yearsIn(string $name): array
    {
        preg_match_all('/\b(20\d{2})\b/', $name, $matches);

        return array_map('intval', $matches[1]);
    }

    public function getBundleProductsProperty()
    {
        $participant = $this->participant;

        if (! $participant) {
            return collect();
        }

        $programIds = $participant->enrollments->pluck('training_program_id')->filter()->all();

        if ($programIds === []) {
            return Product::where('is_essential', true)->where('is_active', true)->get();
        }

        return Product::where('is_active', true)
            ->where(fn ($q) => $q->where('is_essential', true)
                ->orWhereHas('programs', fn ($p) => $p->whereIn('training_programs.id', $programIds)))
            ->get();
    }

    // ---- building the settlement -----------------------------------------

    public function startDraft(string $type, array $context = []): void
    {
        if (! in_array($type, SettlementService::ACTIONS, true)) {
            return;
        }

        $this->resetErrorBag();
        $this->draftType = $type;
        $this->draft = $this->defaultsFor($type, $context);
    }

    public function cancelDraft(): void
    {
        $this->draftType = null;
        $this->draft = [];
        $this->resetErrorBag();
    }

    /**
     * Defaults that reflect what usually happened, so the common case is a
     * click: a payment defaults to the whole outstanding amount, a retroactive
     * sale to the price for this member's tier.
     */
    private function defaultsFor(string $type, array $context): array
    {
        $today = now()->toDateString();

        return match ($type) {
            'record_payment', 'settle_short' => [
                'invoice_id' => (int) ($context['invoice_id'] ?? 0),
                'invoice_number' => (string) ($context['invoice_number'] ?? ''),
                'amount' => $this->money((int) ($context['due'] ?? 0)),
                // Today by default: money taken now is the ordinary case, and
                // back-dating is a deliberate act the operator types.
                'date' => $today,
                'method' => 'cash',
                'note' => '',
            ],
            'waive_invoice' => [
                'invoice_id' => (int) ($context['invoice_id'] ?? 0),
                'invoice_number' => (string) ($context['invoice_number'] ?? ''),
                'due' => (int) ($context['due'] ?? 0),
                'note' => '',
            ],
            'bill_month' => [
                'enrollment_id' => (int) ($context['enrollment_id'] ?? $this->participant?->enrollments->first()?->id ?? 0),
                'month' => (string) ($context['month'] ?? now()->subMonth()->format('Y-m')),
                'amount' => $this->money($this->suggestedMonthlyPrice),
                'paid_amount' => '0',
                'close_month' => false,
                'method' => 'cash',
                'date' => $today,
                'note' => '',
            ],
            'sell_product' => [
                'product_id' => (int) ($context['product_id'] ?? 0),
                'quantity' => 1,
                'unit_price' => $this->money((int) ($context['price'] ?? 0)),
                'paid_amount' => $this->money((int) ($context['price'] ?? 0)),
                'method' => 'cash',
                'date' => $today,
                'adjust_stock' => false,
                'note' => '',
            ],
            'move_payment' => [
                'payment_id' => (int) ($context['payment_id'] ?? 0),
                'from_invoice_number' => (string) ($context['from_invoice_number'] ?? ''),
                'amount_display' => (string) ($context['amount_display'] ?? ''),
                'to_invoice_id' => 0,
                'note' => '',
            ],
            'credit_wallet' => [
                'amount' => '0.00',
                'note' => '',
            ],
            'link_line' => [
                'invoice_item_id' => (int) ($context['invoice_item_id'] ?? 0),
                'product_id' => (int) ($context['product_id'] ?? 0),
                'description' => (string) ($context['description'] ?? ''),
                'product_name' => (string) ($context['product_name'] ?? ''),
            ],
            'void_duplicate' => [
                'invoice_id' => (int) ($context['invoice_id'] ?? 0),
                'invoice_number' => (string) ($context['invoice_number'] ?? ''),
                'current' => (int) ($context['current'] ?? 0),
                // The computed answer, already filled in: the operator confirms
                // a number rather than working one out at the desk.
                'new_amount' => $this->money((int) ($context['new_amount'] ?? 0)),
                'product_name' => (string) ($context['product_name'] ?? ''),
                'note' => '',
            ],
            'restore_enrollment' => [
                // Preselected only when the invoice actually points somewhere.
                // A wrong default that looks confident is worse than none: the
                // operator confirms what is in front of them, and a zero here
                // makes them choose rather than nod.
                'training_group_id' => (int) ($context['training_group_id'] ?? $this->suggestedGroupId ?? 0),
                'note' => '',
            ],
            'plan_installments' => [
                'invoice_id' => (int) ($context['invoice_id'] ?? 0),
                'invoice_number' => (string) ($context['invoice_number'] ?? ''),
                'total_installments' => 3,
                'paid_installments' => 1,
                'installment_amount' => $this->money(intdiv((int) ($context['total'] ?? 0), 3)),
                'next_due_date' => now()->addMonth()->toDateString(),
                'note' => '',
            ],
            default => [],
        };
    }

    public function addDraft(): void
    {
        $this->authorize('settlements.manage');

        if (! $this->draftType) {
            return;
        }

        $errors = $this->validateDraft();

        if ($errors !== []) {
            foreach ($errors as $field => $message) {
                $this->addError('draft.' . $field, $message);
            }

            return;
        }

        $this->cart[] = $this->normaliseDraft();
        $this->draftType = null;
        $this->draft = [];
    }

    public function removeCartItem(int $index): void
    {
        unset($this->cart[$index]);
        $this->cart = array_values($this->cart);
    }

    /**
     * Front-of-house validation only. Every rule that protects money — the
     * invoice belongs to this participant, the payment does not exceed what is
     * due, the date is not in the future — is enforced again inside the
     * service, because a disabled button is not a control.
     *
     * @return array<string, string>
     */
    private function validateDraft(): array
    {
        $errors = [];
        $type = $this->draftType;
        $d = $this->draft;

        $amount = fn ($key) => (int) round(((float) ($d[$key] ?? 0)) * 100);

        if (in_array($type, ['record_payment', 'settle_short'], true)) {
            if ($amount('amount') <= 0 && $type === 'record_payment') {
                $errors['amount'] = __('أدخل مبلغاً أكبر من صفر');
            }
            if ($amount('amount') < 0) {
                $errors['amount'] = __('المبلغ لا يمكن أن يكون سالباً');
            }
            $invoice = $this->invoices->firstWhere('id', (int) ($d['invoice_id'] ?? 0));
            if (! $invoice) {
                $errors['invoice_id'] = __('الفاتورة غير موجودة');
            } elseif ($amount('amount') > (int) $invoice->due_amount) {
                $errors['amount'] = __('المبلغ أكبر من المتبقي على الفاتورة');
            }
            if (! empty($d['date']) && Carbon::parse($d['date'])->isFuture()) {
                $errors['date'] = __('لا يمكن اختيار تاريخ في المستقبل');
            }
        }

        if ($type === 'bill_month') {
            if ($amount('amount') <= 0) {
                $errors['amount'] = __('أدخل قيمة الشهر');
            }
            if (! preg_match('/^\d{4}-\d{2}$/', (string) ($d['month'] ?? ''))) {
                $errors['month'] = __('اختر الشهر');
            }
            if ($amount('paid_amount') > $amount('amount')) {
                $errors['paid_amount'] = __('المحصَّل أكبر من قيمة الشهر');
            }
            if (empty($d['enrollment_id'])) {
                $errors['enrollment_id'] = __('لا يوجد اشتراك لهذا المشترك');
            }
        }

        if ($type === 'sell_product') {
            if (empty($d['product_id'])) {
                $errors['product_id'] = __('اختر المنتج');
            }
            if ($amount('unit_price') <= 0) {
                $errors['unit_price'] = __('أدخل سعر المنتج');
            }
            if ($amount('paid_amount') > $amount('unit_price') * max(1, (int) ($d['quantity'] ?? 1))) {
                $errors['paid_amount'] = __('المحصَّل أكبر من قيمة البيع');
            }
        }

        if ($type === 'link_line' && (empty($d['invoice_item_id']) || empty($d['product_id']))) {
            $errors['product_id'] = __('اختر المنتج');
        }

        if ($type === 'move_payment') {
            if (empty($d['payment_id'])) {
                $errors['payment_id'] = __('اختر الدفعة');
            }
            if (empty($d['to_invoice_id'])) {
                $errors['to_invoice_id'] = __('اختر الفاتورة التي تخصها الدفعة');
            }
        }

        if ($type === 'credit_wallet' && $amount('amount') <= 0) {
            $errors['amount'] = __('أدخل مبلغاً أكبر من صفر');
        }

        if ($type === 'void_duplicate') {
            $invoice = $this->invoices->firstWhere('id', (int) ($d['invoice_id'] ?? 0));

            if (! $invoice) {
                $errors['invoice_id'] = __('الفاتورة غير موجودة');
            } else {
                // The same three refusals the service makes, said early so the
                // operator learns why here rather than after pressing apply.
                if ((int) $invoice->paid_amount > 0) {
                    $errors['invoice_id'] = __('هذه الفاتورة محصَّل عليها — انقل الدفعة أو أضفها للمحفظة بدلاً من حذفها');
                }
                if ($amount('new_amount') >= (int) $invoice->total_amount) {
                    $errors['new_amount'] = __('المبلغ الجديد يجب أن يكون أقل من قيمة الفاتورة');
                }
                if ($invoice->items->count() !== 1) {
                    $errors['invoice_id'] = __('الفاتورة تحتوي على أكثر من بند — عدِّل البند المكرر يدوياً');
                }
            }

            if ($amount('new_amount') < 0) {
                $errors['new_amount'] = __('المبلغ لا يمكن أن يكون سالباً');
            }
        }

        if ($type === 'plan_installments') {
            if ((int) ($d['total_installments'] ?? 0) < 2) {
                $errors['total_installments'] = __('عدد الأقساط اثنان على الأقل');
            }
            if ((int) ($d['paid_installments'] ?? 0) > (int) ($d['total_installments'] ?? 0)) {
                $errors['paid_installments'] = __('المسدد أكبر من الإجمالي');
            }
        }

        if ($type === 'restore_enrollment') {
            $groupId = (int) ($d['training_group_id'] ?? 0);

            // Checked against the list this screen actually offered, not against
            // the table: the id arrives from the browser, and a group from
            // another branch would otherwise put the player on a roster nobody
            // here can see. SettlementService re-checks the academy on apply.
            if (! $this->restorableGroups->contains('id', $groupId)) {
                $errors['training_group_id'] = __('اختر المجموعة التي سيُسجَّل فيها');
            }

            if ($this->participant?->enrollments->contains('training_group_id', $groupId)) {
                $errors['training_group_id'] = __('المشترك مسجل بالفعل في هذه المجموعة');
            }
        }

        return $errors;
    }

    /** Convert the form's pounds into the piastres everything downstream uses. */
    private function normaliseDraft(): array
    {
        $d = $this->draft;
        $type = $this->draftType;
        $p = fn ($key) => (int) round(((float) ($d[$key] ?? 0)) * 100);

        $base = ['type' => $type, 'note' => trim((string) ($d['note'] ?? ''))];

        return match ($type) {
            'record_payment', 'settle_short' => $base + [
                'invoice_id' => (int) $d['invoice_id'],
                'invoice_number' => (string) ($d['invoice_number'] ?? ''),
                'amount' => $p('amount'),
                'method' => (string) ($d['method'] ?? 'cash'),
                'date' => (string) ($d['date'] ?? now()->toDateString()),
            ],
            'waive_invoice' => $base + [
                'invoice_id' => (int) $d['invoice_id'],
                'invoice_number' => (string) ($d['invoice_number'] ?? ''),
                'amount' => (int) ($d['due'] ?? 0),
            ],
            'bill_month' => $base + [
                'enrollment_id' => (int) $d['enrollment_id'],
                'month' => (string) $d['month'],
                'amount' => $p('amount'),
                'paid_amount' => $p('paid_amount'),
                'close_month' => (bool) ($d['close_month'] ?? false),
                'method' => (string) ($d['method'] ?? 'cash'),
                'date' => (string) ($d['date'] ?? now()->toDateString()),
            ],
            'sell_product' => $base + [
                'product_id' => (int) $d['product_id'],
                'quantity' => max(1, (int) ($d['quantity'] ?? 1)),
                'unit_price' => $p('unit_price'),
                'paid_amount' => $p('paid_amount'),
                'method' => (string) ($d['method'] ?? 'cash'),
                'date' => (string) ($d['date'] ?? now()->toDateString()),
                'adjust_stock' => (bool) ($d['adjust_stock'] ?? false),
            ],
            'move_payment' => $base + [
                'payment_id' => (int) $d['payment_id'],
                'to_invoice_id' => (int) $d['to_invoice_id'],
                'from_invoice_number' => (string) ($d['from_invoice_number'] ?? ''),
                'amount_display' => (string) ($d['amount_display'] ?? ''),
            ],
            'credit_wallet' => $base + [
                'amount' => $p('amount'),
            ],
            'link_line' => $base + [
                'invoice_item_id' => (int) $d['invoice_item_id'],
                'product_id' => (int) $d['product_id'],
                'description' => (string) ($d['description'] ?? ''),
                'product_name' => (string) ($d['product_name'] ?? ''),
            ],
            'void_duplicate' => $base + [
                'invoice_id' => (int) $d['invoice_id'],
                'invoice_number' => (string) ($d['invoice_number'] ?? ''),
                'new_amount' => $p('new_amount'),
                'removed' => max(0, (int) ($d['current'] ?? 0) - $p('new_amount')),
                'product_name' => (string) ($d['product_name'] ?? ''),
            ],
            'restore_enrollment' => $base + [
                'training_group_id' => (int) $d['training_group_id'],
                // Carried for the review step and the settlement record, so the
                // row reads "put back into أكاديمية 2015" a year from now
                // rather than "training_group_id 45".
                'group_label' => (string) optional(
                    $this->restorableGroups->firstWhere('id', (int) $d['training_group_id'])
                )->name_ar,
                'program_label' => (string) optional(optional(
                    $this->restorableGroups->firstWhere('id', (int) $d['training_group_id'])
                )->program)->name_ar,
            ],
            'plan_installments' => $base + [
                'invoice_id' => (int) $d['invoice_id'],
                'invoice_number' => (string) ($d['invoice_number'] ?? ''),
                'total_installments' => (int) $d['total_installments'],
                'paid_installments' => (int) $d['paid_installments'],
                'installment_amount' => $p('installment_amount'),
                'next_due_date' => (string) ($d['next_due_date'] ?? now()->toDateString()),
            ],
            default => $base,
        };
    }

    /** What this settlement will do to the books, before it does it. */
    public function getTotalsProperty(): array
    {
        $collected = 0;
        $waived = 0;
        $billed = 0;

        foreach ($this->cart as $item) {
            match ($item['type']) {
                'record_payment' => $collected += $item['amount'],
                'settle_short' => [$collected += $item['amount'], $waived += $this->remainderOf($item)],
                'waive_invoice' => $waived += $item['amount'],
                'bill_month' => [
                    $billed += $item['amount'],
                    $collected += $item['paid_amount'],
                    $waived += $item['close_month'] ? max(0, $item['amount'] - $item['paid_amount']) : 0,
                ],
                'sell_product' => [
                    $billed += $item['unit_price'] * $item['quantity'],
                    $collected += $item['paid_amount'],
                ],
                // Negative billing, not a waiver. Removing a demand the books
                // should never have carried costs the club nothing, and showing
                // it under "written off" would read as revenue given away.
                'void_duplicate' => $billed -= $item['removed'],
                default => null,
            };
        }

        return ['collected' => $collected, 'waived' => $waived, 'billed' => $billed];
    }

    private function remainderOf(array $item): int
    {
        $invoice = $this->invoices->firstWhere('id', $item['invoice_id'] ?? 0);

        return $invoice ? max(0, (int) $invoice->due_amount - (int) $item['amount']) : 0;
    }

    // ---- steps ------------------------------------------------------------

    public function goToStep(int $step): void
    {
        if ($step === 3 && $this->cart === []) {
            session()->flash('error', __('أضف إجراءً واحداً على الأقل'));

            return;
        }

        if ($step >= 1 && $step <= 4) {
            $this->currentStep = $step;
        }
    }

    public function applySettlement(SettlementService $settlements): void
    {
        $this->authorize('settlements.manage');

        $participant = $this->participant;

        if (! $participant) {
            session()->flash('error', __('المشترك غير موجود'));

            return;
        }

        if (mb_strlen(trim($this->reason)) < 10) {
            $this->addError('reason', __('اكتب سبب التسوية (10 أحرف على الأقل)'));

            return;
        }

        try {
            $settlement = $settlements->apply(
                $participant,
                $this->cart,
                $this->reason,
                auth()->user(),
                $participant->branch_id,
            );

            $this->result = [
                'reference' => $settlement->reference,
                'collected' => $settlement->collected_amount,
                'waived' => $settlement->waived_amount,
                'billed' => $settlement->billed_amount,
                'actions' => $settlement->actions,
            ];

            $this->cart = [];
            $this->reason = '';
            $this->currentStep = 4;
        } catch (DomainException $e) {
            session()->flash('error', $e->getMessage());
        } catch (\Throwable $e) {
            report($e);
            session()->flash('error', __('تعذر تنفيذ التسوية — لم يُحفظ أي تغيير'));
        }
    }

    public function startOver(): void
    {
        $this->reset(['participantId', 'cart', 'draft', 'draftType', 'reason', 'result', 'search']);
        $this->currentStep = 1;
    }

    private function money(int $piasters): string
    {
        return number_format($piasters / 100, 2, '.', '');
    }

    public function render()
    {
        return view('livewire.admin.account-settlement-wizard', [
            'results' => $this->currentStep === 1 ? $this->searchResults() : collect(),
        ]);
    }
}
