<?php

namespace Tests\Feature;

use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Livewire\Admin\AccountSettlementWizard;
use App\Livewire\Admin\SettlementWorklist;
use App\Models\User;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Tests\TestCase;

/**
 * The settlement screens are Blade, so a renamed method or a wrong variable is
 * a 500 at request time and nothing earlier catches it.
 *
 * Runs against a restored Postgres tenant; skips on the default SQLite suite:
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter SettlementScreensRenderTest
 */
class SettlementScreensRenderTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

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

    private function anAdmin(): User
    {
        $user = User::query()->get()->first(fn (User $u) => $u->can('settlements.manage'));

        if (! $user) {
            $this->markTestSkipped('No user in the restored tenant holds settlements.manage.');
        }

        return $user;
    }

    public function test_the_worklist_renders_and_finds_accounts(): void
    {
        $response = $this->actingAs($this->anAdmin())->get(route('admin.settlement-worklist'));

        $response->assertOk();
        $response->assertSee('حالات تحتاج تسوية', escape: false);
        $response->assertSee('تسوية', escape: false);
    }

    public function test_the_worklist_can_be_filtered_to_one_case(): void
    {
        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'never_paid']));

        $response->assertOk();
        $response->assertSee('لم يُسجَّل له أي دفع', escape: false);
    }

    public function test_selecting_a_case_shows_how_it_is_handled(): void
    {
        // The guidance is the point of the console. An operator who cannot see
        // what a case means skips it, and a skipped case is money — so the
        // meaning, the cause and the numbered handling all have to be on the
        // page, not one click further in.
        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'never_paid']));

        $response->assertOk();
        $response->assertSee('كيف تُعالَج', escape: false);
        $response->assertSee('لماذا تحدث', escape: false);
        $response->assertSee('راجع دفتر الكاش', escape: false);
    }

    public function test_the_case_rail_keeps_every_count_while_one_case_is_selected(): void
    {
        // The scanner's own `only` filter would have dropped the other cases
        // from the result entirely, so the rail beside a selected case would
        // read zero everywhere — which an operator reads as "those are fixed",
        // not "those are hidden".
        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'never_paid']));

        $response->assertOk();
        $response->assertSee('متأخرات متراكمة', escape: false);
        $response->assertSee('لا يوجد له اشتراك مسجَّل', escape: false);
    }

    public function test_the_worklist_surfaces_players_with_no_enrolment(): void
    {
        // The case nobody could find by looking: renewal billing reads
        // `enrollments` and nothing else, so a player with no row there is not
        // billed late — he is not billed at all, and no other screen lists him.
        $orphans = collect(app(AccountAnomalyScanner::class)->scan())
            ->filter(fn ($row) => in_array('no_enrollment', $row['cases'], true));

        if ($orphans->isEmpty()) {
            $this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
        }

        // The programme is read off the last invoice line, because the enrolment
        // that knew it is gone — and it is the one fact the operator needs to
        // put the player back.
        $this->assertNotNull(
            $orphans->first()['detail']['no_enrollment']['last_program'] ?? null,
            'An orphan whose invoices name a programme must carry it into the row.'
        );

        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'no_enrollment']));

        $response->assertOk();
        $response->assertSee('سجل الاشتراك هو الشيء الوحيد', escape: false);
        // The programme column falls back to the last invoice's wording and says
        // so, rather than printing an em-dash and leaving the operator to guess.
        $response->assertSee('من فاتورة سابقة — لا اشتراك', escape: false);
        $response->assertDontSee('لا توجد حالات تحتاج تسوية', escape: false);
    }

    public function test_a_row_opens_its_evidence_without_leaving_the_list(): void
    {
        // The detail block reads eight different shapes out of the scanner's
        // `detail` array. A renamed key there is a 500 the moment an operator
        // clicks a row, and nothing but rendering it catches that.
        $rows = app(AccountAnomalyScanner::class)->scan();

        if ($rows === []) {
            $this->markTestSkipped('This tenant snapshot has no flagged accounts.');
        }

        $component = Livewire::actingAs($this->anAdmin())
            ->test(SettlementWorklist::class);

        // Every flagged account, not just the first: each case contributes its
        // own detail block, and one broken branch is one operator's dead end.
        foreach (array_slice($rows, 0, 40) as $row) {
            $component->call('toggleRow', $row['participant']->id)
                ->assertOk()
                ->assertSee('رقم المشترك', escape: false);
        }
    }

    public function test_the_open_row_cannot_be_pointed_at_another_account(): void
    {
        // `expanded` reveals a participant's own money, so it is #[Locked]:
        // settable from the browser it would be an id to aim somewhere else.
        $this->expectException(CannotUpdateLockedPropertyException::class);

        Livewire::actingAs($this->anAdmin())
            ->test(SettlementWorklist::class)
            ->set('expanded', 1);
    }

    public function test_the_wizard_opens_on_a_participant_and_shows_the_account(): void
    {
        // A live participant: an invoice whose player was later deleted must
        // not open the wizard, and the screen says so rather than pretending.
        $invoice = Invoice::withoutGlobalScopes()
            ->where('billable_type', Participant::class)
            ->whereColumn('total_amount', '>', 'paid_amount')
            ->whereNull('deleted_at')
            ->whereIn('billable_id', Participant::withoutGlobalScopes()->whereNull('deleted_at')->select('id'))
            ->firstOrFail();

        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.account-settlement', ['participant' => $invoice->billable_id]));

        $response->assertOk();
        $response->assertSee('الفواتير', escape: false);
        $response->assertSee('دفعة سابقة', escape: false);
        $response->assertSee($invoice->number);
    }

    public function test_the_wizard_can_put_a_lost_player_back_in_a_programme(): void
    {
        // The whole point of the action: the desk could see the diagnosis and
        // had no button. This walks the real path — open the account, stage the
        // restore, apply it — and then checks the player is enrolled, that no
        // invoice was raised behind the operator's back, and that the cycle
        // resumes next month rather than billing the current one by surprise.
        $orphan = collect(app(AccountAnomalyScanner::class)->scan())
            ->first(fn ($row) => in_array('no_enrollment', $row['cases'], true));

        if (! $orphan) {
            $this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
        }

        $participant = $orphan['participant'];
        $invoicesBefore = Invoice::where('billable_type', Participant::class)
            ->where('billable_id', $participant->id)->count();

        $component = Livewire::actingAs($this->anAdmin())
            ->test(AccountSettlementWizard::class, ['participant' => $participant->id]);

        $group = $component->get('restorableGroups')->first();
        $this->assertNotNull($group, 'There has to be somewhere to put him back.');
        $this->assertSame(
            $component->get('suggestedGroupId'),
            $group->id,
            'The group his own last invoice points at has to be the first one offered.'
        );

        DB::transaction(function () use ($component, $group, $participant, $invoicesBefore) {
            $component->call('startDraft', 'restore_enrollment')
                ->set('draft.training_group_id', $group->id)
                ->call('addDraft')
                ->assertHasNoErrors()
                ->set('reason', 'إعادة تسجيل بعد حذف البرنامج القديم')
                ->call('applySettlement')
                ->assertHasNoErrors();

            $enrollment = Enrollment::where('participant_id', $participant->id)->first();

            $this->assertNotNull($enrollment, 'The player is enrolled again.');
            $this->assertSame($group->id, (int) $enrollment->training_group_id);
            $this->assertSame('active', $enrollment->status->value);

            // Next month, not this one. Restoring a subscription and charging
            // for it are two decisions, and the second is the operator's.
            $this->assertSame(
                now()->startOfMonth()->addMonth()->toDateString(),
                $enrollment->next_billing_date->toDateString(),
            );

            $this->assertSame(
                $invoicesBefore,
                Invoice::where('billable_type', Participant::class)->where('billable_id', $participant->id)->count(),
                'Restoring an enrolment must not raise an invoice.'
            );

            // Read-only test against a live snapshot: nothing it writes stays.
            DB::rollBack();
        });
    }

    public function test_a_group_from_another_branch_cannot_be_used_to_restore(): void
    {
        // The id arrives from the browser. A group outside the operator's branch
        // would put the player on a roster nobody at this desk can see, while
        // his own branch keeps billing him.
        $orphan = collect(app(AccountAnomalyScanner::class)->scan())
            ->first(fn ($row) => in_array('no_enrollment', $row['cases'], true));

        if (! $orphan) {
            $this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
        }

        Livewire::actingAs($this->anAdmin())
            ->test(AccountSettlementWizard::class, ['participant' => $orphan['participant']->id])
            ->call('startDraft', 'restore_enrollment')
            ->set('draft.training_group_id', 999999)
            ->call('addDraft')
            ->assertHasErrors('draft.training_group_id');
    }

    public function test_the_wizard_opens_empty_without_a_participant(): void
    {
        $response = $this->actingAs($this->anAdmin())->get(route('admin.account-settlement'));

        $response->assertOk();
        $response->assertSee('ابحث بالاسم أو رقم الهاتف', escape: false);
    }

    public function test_a_user_without_the_permission_is_refused(): void
    {
        $user = User::query()->get()->first(fn (User $u) => ! $u->can('settlements.manage'));

        if (! $user) {
            $this->markTestSkipped('Every user in this tenant may settle accounts.');
        }

        $this->actingAs($user)->get(route('admin.settlement-worklist'))->assertForbidden();
        $this->actingAs($user)->get(route('admin.account-settlement'))->assertForbidden();
    }

    public function test_the_screens_use_logical_properties_only(): void
    {
        // Arabic is the default locale; a physical margin flips the layout.
        foreach ([
            'livewire/admin/account-settlement-wizard.blade.php',
            'livewire/admin/settlement-worklist.blade.php',
            'livewire/admin/partials/settlement-cart.blade.php',
        ] as $view) {
            $content = file_get_contents(resource_path('views/' . $view));

            $offenders = [];
            foreach (explode("\n", $content) as $i => $line) {
                if (preg_match('/class="[^"]*\b(m[lr]|p[lr])-[0-9]/', $line)) {
                    $offenders[] = $view . ':' . ($i + 1) . ' ' . trim($line);
                }
            }

            $this->assertSame([], $offenders, "Physical margins/paddings found:\n" . implode("\n", $offenders));
        }
    }

    public function test_no_dead_links_in_the_settlement_screens(): void
    {
        // href="#" is forbidden: a link that goes nowhere is a bug report from
        // a parent standing at the desk.
        foreach ([
            'livewire/admin/account-settlement-wizard.blade.php',
            'livewire/admin/settlement-worklist.blade.php',
            'livewire/admin/partials/settlement-cart.blade.php',
        ] as $view) {
            $content = file_get_contents(resource_path('views/' . $view));
            $this->assertStringNotContainsString('href="#"', $content, $view);
        }
    }

    // ---- the double-billing case, end to end on the screen -----------------

    /** A participant the scanner flags as billed above a requirement's price. */
    private function anOverBilledParticipant(): Participant
    {
        $rows = app(\App\Domain\Financial\Services\AccountAnomalyScanner::class)
            ->scan(null, 'over_billed_bundle', 5);

        if ($rows === []) {
            $this->markTestSkipped('No account in the restored tenant is billed above a card price.');
        }

        return $rows[0]['participant'];
    }

    public function test_the_worklist_offers_the_double_billing_filter(): void
    {
        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'over_billed_bundle']));

        $response->assertOk();
        $response->assertSee('مستلزم محاسَب بأكثر من سعره', escape: false);
    }

    public function test_the_worklist_row_states_the_excess_without_opening_the_wizard(): void
    {
        $this->anOverBilledParticipant();

        $response = $this->actingAs($this->anAdmin())
            ->get(route('admin.settlement-worklist', ['case' => 'over_billed_bundle']));

        $response->assertOk();
        $response->assertSee('زيادة', escape: false);
    }

    public function test_the_wizard_proposes_the_invoice_to_reduce_and_the_amount(): void
    {
        $participant = $this->anOverBilledParticipant();

        $component = \Livewire\Livewire::actingAs($this->anAdmin())
            ->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id]);

        $fixes = $component->instance()->overBilledFixes;

        $this->assertNotEmpty($fixes, 'A flagged account must come with something to do about it.');

        $fix = $fixes[0];
        $this->assertGreaterThan(0, $fix['over_billed']);
        $this->assertNotEmpty($fix['fixes'], 'The excess sits on an uncollected invoice, so a fix is offerable.');

        $candidate = $fix['fixes'][0];
        $this->assertSame(
            $candidate['current'] - $candidate['removed'],
            $candidate['new_amount'],
            'The proposed amount is the invoice minus its share of the excess.'
        );

        $invoice = Invoice::withoutGlobalScopes()->findOrFail($candidate['invoice_id']);
        $this->assertSame(0, (int) $invoice->paid_amount,
            'Only invoices with nothing collected are ever proposed for reduction.');
        $this->assertSame((int) $participant->id, (int) $invoice->billable_id);
    }

    public function test_the_fix_can_be_staged_into_the_settlement_cart(): void
    {
        $participant = $this->anOverBilledParticipant();

        $component = \Livewire\Livewire::actingAs($this->anAdmin())
            ->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id]);

        $candidate = $component->instance()->overBilledFixes[0]['fixes'][0];

        $component->call('startDraft', 'void_duplicate', [
            'invoice_id' => $candidate['invoice_id'],
            'invoice_number' => $candidate['invoice_number'],
            'current' => $candidate['current'],
            'new_amount' => $candidate['new_amount'],
        ])->call('addDraft')->assertHasNoErrors();

        $cart = $component->instance()->cart;

        $this->assertCount(1, $cart);
        $this->assertSame('void_duplicate', $cart[0]['type']);
        $this->assertSame($candidate['new_amount'], $cart[0]['new_amount']);
        $this->assertSame($candidate['removed'], $cart[0]['removed']);

        // Negative billing, and nothing written off — the club is not out of pocket.
        $totals = $component->instance()->totals;
        $this->assertSame(-$candidate['removed'], $totals['billed']);
        $this->assertSame(0, $totals['waived']);
        $this->assertSame(0, $totals['collected']);
    }

    public function test_the_wizard_refuses_to_stage_a_reduction_on_a_collected_invoice(): void
    {
        $participant = $this->anOverBilledParticipant();

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

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

        \Livewire\Livewire::actingAs($this->anAdmin())
            ->test(\App\Livewire\Admin\AccountSettlementWizard::class, ['participant' => $participant->id])
            ->call('startDraft', 'void_duplicate', [
                'invoice_id' => $collected->id,
                'invoice_number' => $collected->number,
                'current' => (int) $collected->total_amount,
                'new_amount' => 0,
            ])
            ->call('addDraft')
            ->assertHasErrors('draft.invoice_id');
    }
}
