<?php

namespace Tests\Feature;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Models\Settlement;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Financial\Services\ParticipantBillingService;
use App\Domain\Financial\Services\SettlementService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

/**
 * Settling an account, against a restored tenant.
 *
 * Runs on a real Postgres copy rather than the SQLite suite, because every
 * assertion here is about what happens to money across invoices, payments, the
 * ledger and the roster's own reading of them — the schema and the seeded
 * accounts are the test fixture, and rebuilding them by hand would be testing a
 * different system:
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter AccountSettlementTest
 *
 * Every case is wrapped in a transaction and rolled back, so the copy is left
 * exactly as it was found.
 */
class AccountSettlementTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        if (config('database.default') !== 'pgsql') {
            $this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
        }

        DB::beginTransaction();
    }

    protected function tearDown(): void
    {
        if (config('database.default') === 'pgsql') {
            DB::rollBack();
        }

        parent::tearDown();
    }

    private function service(): SettlementService
    {
        return app(SettlementService::class);
    }

    private function actor(): User
    {
        return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'admin'))->first()
            ?? User::query()->firstOrFail();
    }

    /** A participant carrying at least one invoice with money still on it. */
    private function participantWithDue(): Participant
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->firstOrFail();

        return Participant::withoutGlobalScopes()->with('person')->findOrFail($invoice->billable_id);
    }

    private function dueInvoiceOf(Participant $participant): Invoice
    {
        return Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->firstOrFail();
    }

    // ---- money that was collected and never entered -----------------------

    public function test_a_back_dated_payment_lands_on_the_invoice_and_the_ledger(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);
        $due = (int) $invoice->due_amount;

        $before = DB::table('transactions')->count();

        $settlement = $this->service()->apply($participant, [[
            'type' => 'record_payment',
            'invoice_id' => $invoice->id,
            'amount' => $due,
            'method' => 'cash',
            'date' => '2026-08-20',
            'note' => 'حُصِّل نقداً يوم التسجيل',
        ]], 'مراجعة دفتر الكاش مع الاستقبال', $this->actor(), $participant->branch_id);

        $invoice->refresh();

        $this->assertSame($due, $settlement->collected_amount);
        $this->assertSame(0, (int) $invoice->due_amount);
        $this->assertSame(InvoiceStatus::Paid, $invoice->status);

        $payment = Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->latest('id')->first();
        $this->assertSame('2026-08-20', $payment->payment_date->toDateString(), 'The money is filed on the day it was taken.');
        $this->assertGreaterThan($before, DB::table('transactions')->count(), 'A payment must post to the ledger.');
    }

    public function test_a_payment_larger_than_the_balance_is_refused(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'record_payment',
            'invoice_id' => $invoice->id,
            'amount' => (int) $invoice->due_amount + 100000,
        ]], 'محاولة تحصيل أكبر من المستحق', $this->actor(), $participant->branch_id);
    }

    public function test_another_participants_invoice_cannot_be_settled(): void
    {
        $participant = $this->participantWithDue();

        $foreign = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', '!=', $participant->id)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->firstOrFail();

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'record_payment',
            'invoice_id' => $foreign->id,
            'amount' => 1000,
        ]], 'محاولة الوصول لفاتورة مشترك آخر', $this->actor(), $participant->branch_id);
    }

    public function test_a_future_dated_settlement_is_refused(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'record_payment',
            'invoice_id' => $invoice->id,
            'amount' => 1000,
            'date' => now()->addWeek()->toDateString(),
        ]], 'تاريخ مستقبلي يجب أن يُرفض', $this->actor(), $participant->branch_id);
    }

    // ---- the month that closed for less ------------------------------------

    public function test_settling_short_closes_the_month_and_records_the_discount(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $due = (int) $invoice->due_amount;
        $subtotalBefore = (int) $invoice->subtotal_amount;
        $paying = intdiv($due, 2);

        $settlement = $this->service()->apply($participant, [[
            'type' => 'settle_short',
            'invoice_id' => $invoice->id,
            'amount' => $paying,
            'date' => '2026-08-25',
            'note' => 'انضم في نصف الشهر ودفع ما تبقى منه',
        ]], 'تسوية شهر الانضمام حسب الأيام المتبقية', $this->actor(), $participant->branch_id);

        $invoice->refresh();

        $this->assertSame($paying, $settlement->collected_amount);
        $this->assertSame($due - $paying, $settlement->waived_amount);
        $this->assertSame(0, (int) $invoice->due_amount, 'The month must stop being owed.');
        $this->assertSame(
            $subtotalBefore,
            (int) $invoice->subtotal_amount,
            'subtotal_amount is what the lines came to — the roster still needs the original figure.'
        );
        $this->assertNotEmpty($invoice->metadata['admin_override'] ?? null, 'A write-off must say who and why.');
        $this->assertSame('settlement', $invoice->metadata['admin_override']['source']);
    }

    public function test_a_waived_invoice_with_nothing_paid_drops_out_of_the_books(): void
    {
        $participant = $this->participantWithDue();
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)
            ->where('paid_amount', 0)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->first();

        if (! $invoice) {
            $this->markTestSkipped('This participant has no wholly unpaid invoice.');
        }

        $total = (int) $invoice->total_amount;

        $settlement = $this->service()->apply($participant, [[
            'type' => 'waive_invoice',
            'invoice_id' => $invoice->id,
            'note' => 'المجموعة لم تبدأ في هذا الشهر',
        ]], 'إسقاط شهر لم تُقدَّم فيه خدمة', $this->actor(), $participant->branch_id);

        $invoice->refresh();

        $this->assertSame($total, $settlement->waived_amount);
        $this->assertSame(InvoiceStatus::Cancelled, $invoice->status);
        $this->assertSame(0, (int) $invoice->due_amount);
    }

    // ---- months and products that were never entered -----------------------

    public function test_a_missing_month_is_billed_under_the_month_it_pays_for(): void
    {
        $participant = Participant::withoutGlobalScopes()
            ->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
            ->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
            ->firstOrFail();

        $enrollment = $participant->enrollments->first();

        $settlement = $this->service()->apply($participant, [[
            'type' => 'bill_month',
            'enrollment_id' => $enrollment->id,
            'month' => '2026-07',
            'amount' => 90000,
            'paid_amount' => 90000,
            'method' => 'cash',
            'date' => '2026-07-05',
        ]], 'شهر يوليو دُفع نقداً ولم تصدر له فاتورة', $this->actor(), $participant->branch_id);

        $invoiceId = $settlement->actions[0]['invoice_id'];
        $invoice = Invoice::withoutGlobalScopes()->with('items')->findOrFail($invoiceId);

        $this->assertSame(90000, $settlement->billed_amount);
        $this->assertSame(90000, $settlement->collected_amount);
        $this->assertSame('2026-07', $invoice->metadata['month']);
        $this->assertSame(0, (int) $invoice->due_amount);

        // The month has to be readable off the line, not inferred from the day
        // the correction was typed — that is what files it under July.
        $facts = app(ParticipantBillingService::class)
            ->subscriptionForPeriod([$participant->id], '2026-07-01', '2026-08-01')[$participant->id] ?? null;

        $this->assertNotNull($facts, 'July must now show a subscription.');
        $this->assertGreaterThanOrEqual(90000, $facts['paid']);
    }

    public function test_a_retroactive_product_sale_carries_the_products_morph(): void
    {
        $participant = $this->participantWithDue();
        $product = Product::withoutGlobalScopes()
            ->where('academy_id', $participant->academy_id)
            ->where('is_active', true)
            ->firstOrFail();

        $settlement = $this->service()->apply($participant, [[
            'type' => 'sell_product',
            'product_id' => $product->id,
            'quantity' => 1,
            'unit_price' => 250000,
            'paid_amount' => 250000,
            'method' => 'cash',
            'date' => '2026-07-26',
            'adjust_stock' => false,
        ]], 'اشترى المنتج نقداً ولم يُسجَّل وقتها', $this->actor(), $participant->branch_id);

        $invoice = Invoice::withoutGlobalScopes()->with('items')->findOrFail($settlement->actions[0]['invoice_id']);
        $line = $invoice->items->first();

        $this->assertSame($product->getMorphClass(), $line->itemable_type, 'A sale is a product line, not a description.');
        $this->assertSame((int) $product->id, (int) $line->itemable_id);
        $this->assertSame(250000, $settlement->collected_amount);
        $this->assertSame(0, (int) $invoice->due_amount);
    }

    public function test_a_hand_typed_line_can_be_linked_to_its_product_without_moving_money(): void
    {
        $item = InvoiceItem::withoutGlobalScopes()
            ->whereNull('itemable_type')
            ->whereHas('invoice', fn ($q) => $q->where('billable_type', Participant::class)->whereNull('deleted_at'))
            ->with('invoice')
            ->firstOrFail();

        $participant = Participant::withoutGlobalScopes()->findOrFail($item->invoice->billable_id);
        $product = Product::withoutGlobalScopes()->where('academy_id', $participant->academy_id)->firstOrFail();

        $totalBefore = (int) $item->invoice->total_amount;
        $paidBefore = (int) $item->invoice->paid_amount;

        $this->service()->apply($participant, [[
            'type' => 'link_line',
            'invoice_item_id' => $item->id,
            'product_id' => $product->id,
        ]], 'ربط بند مكتوب يدوياً بمنتجه الصحيح', $this->actor(), $participant->branch_id);

        $item->refresh();
        $invoice = $item->invoice->fresh();

        $this->assertSame($product->getMorphClass(), $item->itemable_type);
        $this->assertSame($totalBefore, (int) $invoice->total_amount, 'Classification must not move money.');
        $this->assertSame($paidBefore, (int) $invoice->paid_amount);
    }

    public function test_an_instalment_plan_is_recorded_with_what_was_already_paid(): void
    {
        $participant = $this->participantWithDue();
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)
            ->whereDoesntHave('paymentPlan')
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->first();

        if (! $invoice) {
            $this->markTestSkipped('No plan-less invoice on this account.');
        }

        $this->service()->apply($participant, [[
            'type' => 'plan_installments',
            'invoice_id' => $invoice->id,
            'total_installments' => 3,
            'paid_installments' => 1,
            'installment_amount' => 250000,
        ]], 'خطة أقساط متفق عليها على القيد', $this->actor(), $participant->branch_id);

        $plan = PaymentPlan::withoutGlobalScopes()->where('invoice_id', $invoice->id)->firstOrFail();

        $this->assertSame(3, $plan->total_installments);
        $this->assertSame(1, $plan->paid_installments);
        // payment_plans_status_check allows active/completed/defaulted/cancelled
        // only: a part-paid plan is active, and paid_installments is what says
        // how far along it is.
        $this->assertSame('active', $plan->status);
    }

    // ---- money on the wrong month, and money that is not any month's --------

    public function test_a_payment_can_be_moved_to_the_month_it_actually_pays_for(): void
    {
        // Two open invoices, the money on the wrong one. The reversal is a
        // ledger row, not an edit, and both invoices end up telling the truth.
        $participant = Participant::withoutGlobalScopes()
            ->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
            ->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
            ->firstOrFail();

        $enrollment = $participant->enrollments->first();
        $actor = $this->actor();

        $first = $this->service()->apply($participant, [[
            'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-05',
            'amount' => 90000, 'paid_amount' => 90000, 'date' => '2026-05-03',
        ]], 'فاتورة مايو مدفوعة بالخطأ', $actor, $participant->branch_id);

        $second = $this->service()->apply($participant, [[
            'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-06',
            'amount' => 90000, 'paid_amount' => 0,
        ]], 'فاتورة يونيو غير مسددة', $actor, $participant->branch_id);

        $wrongInvoiceId = $first->actions[0]['invoice_id'];
        $rightInvoiceId = $second->actions[0]['invoice_id'];
        $paymentId = $first->actions[0]['payment_id'];

        $moved = $this->service()->apply($participant, [[
            'type' => 'move_payment',
            'payment_id' => $paymentId,
            'to_invoice_id' => $rightInvoiceId,
        ]], 'الدفعة كانت لشهر يونيو وسُجِّلت على مايو', $actor, $participant->branch_id);

        $wrong = Invoice::withoutGlobalScopes()->findOrFail($wrongInvoiceId);
        $right = Invoice::withoutGlobalScopes()->findOrFail($rightInvoiceId);

        $this->assertSame(90000, (int) $wrong->due_amount, 'The month it never paid for is owed again.');
        $this->assertSame(0, (int) $right->due_amount, 'The month it actually paid for is settled.');
        $this->assertSame(0, $moved->collected_amount, 'Moving money is not collecting it twice.');

        $reversal = Payment::withoutGlobalScopes()
            ->where('invoice_id', $wrongInvoiceId)->where('direction', 'outbound')->first();
        $this->assertNotNull($reversal, 'The reversal must exist as its own row.');
    }

    public function test_a_payment_cannot_be_moved_onto_an_invoice_that_cannot_hold_it(): void
    {
        $participant = Participant::withoutGlobalScopes()
            ->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
            ->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
            ->firstOrFail();

        $enrollment = $participant->enrollments->first();
        $actor = $this->actor();

        $big = $this->service()->apply($participant, [[
            'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-04',
            'amount' => 90000, 'paid_amount' => 90000,
        ]], 'فاتورة أبريل مدفوعة بالكامل', $actor, $participant->branch_id);

        $small = $this->service()->apply($participant, [[
            'type' => 'bill_month', 'enrollment_id' => $enrollment->id, 'month' => '2026-03',
            'amount' => 10000, 'paid_amount' => 0,
        ]], 'فاتورة مارس بمبلغ صغير', $actor, $participant->branch_id);

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'move_payment',
            'payment_id' => $big->actions[0]['payment_id'],
            'to_invoice_id' => $small->actions[0]['invoice_id'],
        ]], 'محاولة نقل مبلغ أكبر من المتبقي', $actor, $participant->branch_id);
    }

    public function test_an_overpayment_can_be_held_as_wallet_credit(): void
    {
        $participant = $this->participantWithDue();

        $settlement = $this->service()->apply($participant, [[
            'type' => 'credit_wallet',
            'amount' => 25000,
            'note' => 'دفع 250 زيادة عن قيمة الفاتورة',
        ]], 'رصيد زائد يُحسب على فواتير الشهور القادمة', $this->actor(), $participant->branch_id);

        $wallet = \App\Domain\Financial\Models\Wallet::withoutGlobalScopes()
            ->where('owner_type', $participant->getMorphClass())
            ->where('owner_id', $participant->id)
            ->firstOrFail();

        $this->assertGreaterThanOrEqual(25000, (int) $wallet->balance);
        $this->assertSame(0, $settlement->collected_amount, 'Credit is not a second collection of the same cash.');
    }

    public function test_a_zero_amount_invoice_is_dropped_rather_than_left_standing(): void
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('total_amount', 0)
            ->where('paid_amount', 0)
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->first();

        if (! $invoice) {
            $this->markTestSkipped('No zero-amount invoice in this tenant.');
        }

        $participant = Participant::withoutGlobalScopes()->findOrFail($invoice->billable_id);

        $this->service()->apply($participant, [[
            'type' => 'waive_invoice',
            'invoice_id' => $invoice->id,
        ]], 'فاتورة صدرت بصفر لعدم وجود سعر وقتها', $this->actor(), $participant->branch_id);

        $this->assertSame(InvoiceStatus::Cancelled, $invoice->fresh()->status);
    }

    // ---- the settlement as a record ---------------------------------------

    public function test_a_failing_action_rolls_the_whole_settlement_back(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $paymentsBefore = Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count();
        $settlementsBefore = Settlement::withoutGlobalScopes()->count();

        try {
            $this->service()->apply($participant, [
                ['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 1000],
                ['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 999999999],
            ], 'الإجراء الثاني يجب أن يُسقط الأول', $this->actor(), $participant->branch_id);

            $this->fail('The oversized second payment should have thrown.');
        } catch (DomainException) {
            // expected
        }

        $this->assertSame($paymentsBefore, Payment::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count());
        $this->assertSame($settlementsBefore, Settlement::withoutGlobalScopes()->count());
    }

    public function test_a_settlement_without_a_reason_is_refused(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [
            ['type' => 'record_payment', 'invoice_id' => $invoice->id, 'amount' => 1000],
        ], 'قصير', $this->actor(), $participant->branch_id);
    }

    public function test_an_unknown_action_is_refused(): void
    {
        $participant = $this->participantWithDue();

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [
            ['type' => 'delete_everything', 'invoice_id' => 1],
        ], 'إجراء غير معروف يجب أن يُرفض', $this->actor(), $participant->branch_id);
    }

    public function test_the_settlement_records_what_it_did(): void
    {
        $participant = $this->participantWithDue();
        $invoice = $this->dueInvoiceOf($participant);

        $settlement = $this->service()->apply($participant, [[
            'type' => 'record_payment',
            'invoice_id' => $invoice->id,
            'amount' => 1000,
            'date' => '2026-08-20',
        ]], 'تسجيل دفعة نقدية سابقة بعد مراجعة الدفتر', $this->actor(), $participant->branch_id);

        $this->assertMatchesRegularExpression('/^SET-\d{6}$/', $settlement->reference);
        $this->assertSame($participant->id, $settlement->participant_id);
        $this->assertNotNull($settlement->applied_by);
        $this->assertSame('record_payment', $settlement->actions[0]['type']);
        $this->assertSame($invoice->id, $settlement->actions[0]['invoice_id']);
        $this->assertNotEmpty($settlement->actions[0]['payment_id']);
    }

    // ---- the scanner ------------------------------------------------------

    public function test_the_scanner_finds_accounts_and_explains_each_one(): void
    {
        $rows = app(AccountAnomalyScanner::class)->scan(limit: 50);

        $this->assertNotEmpty($rows, 'The restored tenant is known to carry unsettled accounts.');

        foreach ($rows as $row) {
            $this->assertNotEmpty($row['cases']);
            foreach ($row['cases'] as $case) {
                $this->assertArrayHasKey($case, AccountAnomalyScanner::CASES, "Unknown case: {$case}");
            }
        }

        // Worst first: a file where money was probably collected and never
        // recorded outranks one that is merely behind.
        $severities = array_column($rows, 'severity');
        $sorted = $severities;
        sort($sorted);
        $this->assertSame($sorted, $severities);
    }

    public function test_this_months_renewal_is_not_an_anomaly(): void
    {
        // The screen exists for files a person has to decide about. A renewal
        // issued on the 1st and due on the 8th is ordinary business that the
        // collect-payment screen handles; flagging it buries everything else.
        $participant = Participant::withoutGlobalScopes()
            ->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
            ->with(['enrollments' => fn ($q) => $q->where('status', 'active')])
            ->firstOrFail();

        $before = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];

        $invoice = Invoice::withoutGlobalScopes()->create([
            'academy_id' => $participant->academy_id,
            'branch_id' => $participant->branch_id,
            'number' => 'INV-TEST-' . uniqid(),
            'billable_type' => $participant->getMorphClass(),
            'billable_id' => $participant->id,
            'subtotal_amount' => 90000,
            'total_amount' => 90000,
            'paid_amount' => 0,
            'due_amount' => 90000,
            'issue_date' => now()->startOfMonth()->toDateString(),
            'due_date' => now()->addWeek()->toDateString(),
            'status' => InvoiceStatus::Sent,
            'currency' => 'EGP',
        ]);

        $after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];

        $this->assertSame($before['overdue'], $after['overdue'], 'A month that is still running is never late.');
        $this->assertSame($before['unpaid'] + 1, $after['unpaid'], 'It is still shown as outstanding.');
        $this->assertGreaterThan(0, $invoice->id);
    }

    public function test_the_month_that_has_ended_is_what_makes_an_account_late(): void
    {
        // Same invoice, last month instead of this one. Due date is deliberately
        // in the future to prove the rule is the MONTH, not the due date: a club
        // collects all month, so nothing inside the current month counts, and
        // everything from a month that has closed does.
        $participant = Participant::withoutGlobalScopes()
            ->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
            ->firstOrFail();

        $before = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];

        Invoice::withoutGlobalScopes()->create([
            'academy_id' => $participant->academy_id,
            'branch_id' => $participant->branch_id,
            'number' => 'INV-TEST-' . uniqid(),
            'billable_type' => $participant->getMorphClass(),
            'billable_id' => $participant->id,
            'subtotal_amount' => 90000,
            'total_amount' => 90000,
            'paid_amount' => 0,
            'due_amount' => 90000,
            'issue_date' => now()->subMonth()->startOfMonth()->toDateString(),
            'due_date' => now()->addWeeks(2)->toDateString(),
            'status' => InvoiceStatus::Sent,
            'currency' => 'EGP',
            'metadata' => ['month' => now()->subMonth()->format('Y-m')],
        ]);

        $after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];

        $this->assertSame($before['overdue'] + 1, $after['overdue'], 'Last month going uncollected is the anomaly.');
    }

    public function test_a_card_being_paid_on_schedule_keeps_a_player_off_the_list(): void
    {
        // The rule the operator asked for: someone who owes only the month we
        // are in, and whose bundled product is bought or paid up to date, must
        // not appear at all.
        $rows = app(AccountAnomalyScanner::class)->scan(limit: 500);

        foreach ($rows as $row) {
            foreach ($row['detail']['bundle_status'] ?? [] as $bundle) {
                if ($bundle['status'] === 'paid' || $bundle['on_schedule']) {
                    $this->assertNotContains(
                        'partial_bundle',
                        $row['cases'],
                        'A card paid, or being paid on an agreed plan, is not an anomaly.'
                    );
                }
            }
        }

        $this->addToAssertionCount(1);
    }

    public function test_a_card_paid_in_a_combined_invoice_reads_as_part_paid_not_missing(): void
    {
        // Production participant 219: 2,500 toward the federation card, typed
        // as "القسط الاول" on the same invoice as his kit. The screen used to
        // say he had never bought one.
        $participant = Participant::withoutGlobalScopes()->find(219);

        if (! $participant) {
            $this->markTestSkipped('This tenant copy does not carry participant 219.');
        }

        $diagnosis = app(AccountAnomalyScanner::class)->forParticipant($participant);
        $card = collect($diagnosis['bundle_status'])->firstWhere('product_id', 2);

        $this->assertNotNull($card, 'His programme requires the federation card.');
        $this->assertSame('partial', $card['status'], 'Money on a combined invoice still counts.');
        $this->assertSame(250000, $card['paid']);
        $this->assertSame(800000, $card['expected'], 'The total is the price for his membership tier.');
        $this->assertTrue($card['from_text']);
        $this->assertSame([], $diagnosis['missing_bundle'], 'He is not missing it — he is paying it off.');
    }

    public function test_the_diagnosis_answers_the_wizards_questions(): void
    {
        $participant = $this->participantWithDue();

        $diagnosis = app(AccountAnomalyScanner::class)->forParticipant($participant);

        foreach (['money', 'handtyped', 'missing_bundle', 'unbilled_months', 'duplicate_of'] as $key) {
            $this->assertArrayHasKey($key, $diagnosis);
        }

        $this->assertGreaterThan(0, $diagnosis['money']['owed'], 'This participant was chosen for owing money.');
    }

    // ---- money the books ask for twice ------------------------------------

    /**
     * An invoice nothing was collected against, carrying one line: the shape a
     * duplicate always has, because the duplicate is the demand nobody paid.
     */
    private function unpaidSingleLineInvoiceOf(Participant $participant): ?Invoice
    {
        return Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)
            ->where('paid_amount', 0)
            ->where('total_amount', '>', 0)
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->has('items', '=', 1)
            ->orderByDesc('id')
            ->first();
    }

    private function participantWithUnpaidInvoice(): Participant
    {
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('paid_amount', 0)
            ->where('total_amount', '>', 0)
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->has('items', '=', 1)
            ->orderBy('id')
            ->firstOrFail();

        return Participant::withoutGlobalScopes()->with('person')->findOrFail($invoice->billable_id);
    }

    public function test_a_duplicate_demand_is_reduced_and_counts_as_negative_billing(): void
    {
        $participant = $this->participantWithUnpaidInvoice();
        $invoice = $this->unpaidSingleLineInvoiceOf($participant);
        $before = (int) $invoice->total_amount;
        $target = intdiv($before, 2);

        $settlement = $this->service()->apply($participant, [[
            'type' => 'void_duplicate',
            'invoice_id' => $invoice->id,
            'new_amount' => $target,
            'note' => 'القسط الأول محاسَب مرتين',
        ]], 'مراجعة حساب — بند مكرر على فاتورتين', $this->actor(), $participant->branch_id);

        $invoice->refresh();

        $this->assertSame($target, (int) $invoice->total_amount);
        $this->assertSame($target, (int) $invoice->subtotal_amount);
        $this->assertSame($target, (int) $invoice->due_amount);
        $this->assertSame($target, (int) $invoice->items()->first()->total_amount,
            'The line has to move with the header or the invoice stops adding up.');

        $this->assertSame(-($before - $target), $settlement->billed_amount,
            'Removing a demand is negative billing, not revenue collected.');
        $this->assertSame(0, $settlement->waived_amount,
            'Nothing was written off: the club never had a claim to this money.');
        $this->assertSame(0, $settlement->collected_amount);

        $this->assertArrayHasKey('duplicate_void', $invoice->metadata ?? [],
            'The correction has to say who did it and what it removed.');
    }

    public function test_zeroing_a_duplicate_takes_the_invoice_off_the_books(): void
    {
        $participant = $this->participantWithUnpaidInvoice();
        $invoice = $this->unpaidSingleLineInvoiceOf($participant);

        $this->service()->apply($participant, [[
            'type' => 'void_duplicate',
            'invoice_id' => $invoice->id,
            'new_amount' => 0,
        ]], 'فاتورة مكررة بالكامل — تُلغى', $this->actor(), $participant->branch_id);

        $invoice->refresh();

        $this->assertSame(InvoiceStatus::Cancelled, $invoice->status,
            'A zeroed demand is withdrawn, not a settled bill sitting at zero.');
        $this->assertNotNull($invoice->cancelled_at);
        $this->assertSame(0, (int) $invoice->due_amount);
    }

    /** The guard that matters: collected money is never deleted. */
    public function test_an_invoice_with_money_on_it_is_refused(): void
    {
        $participant = $this->participantWithDue();

        $paid = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)
            ->where('paid_amount', '>', 0)
            ->whereNull('deleted_at')
            ->first();

        if (! $paid) {
            $this->markTestSkipped('This participant has no collected invoice to try it on.');
        }

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'void_duplicate',
            'invoice_id' => $paid->id,
            'new_amount' => 0,
        ]], 'محاولة حذف فاتورة محصَّل عليها', $this->actor(), $participant->branch_id);
    }

    public function test_raising_an_invoice_through_this_action_is_refused(): void
    {
        $participant = $this->participantWithUnpaidInvoice();
        $invoice = $this->unpaidSingleLineInvoiceOf($participant);

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'void_duplicate',
            'invoice_id' => $invoice->id,
            'new_amount' => (int) $invoice->total_amount + 100,
        ]], 'محاولة رفع قيمة الفاتورة من أداة الحذف', $this->actor(), $participant->branch_id);
    }

    public function test_another_participants_invoice_cannot_be_voided(): void
    {
        $participant = $this->participantWithUnpaidInvoice();

        $foreign = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->where('billable_id', '!=', $participant->id)
            ->where('paid_amount', 0)
            ->where('status', '!=', InvoiceStatus::Cancelled)
            ->whereNull('deleted_at')
            ->firstOrFail();

        $this->expectException(DomainException::class);

        $this->service()->apply($participant, [[
            'type' => 'void_duplicate',
            'invoice_id' => $foreign->id,
            'new_amount' => 0,
        ]], 'محاولة الوصول لفاتورة مشترك آخر', $this->actor(), $participant->branch_id);
    }
}
