<?php

namespace Tests\Feature;

use App\Domain\Financial\Enums\SubscriptionPaymentCase;
use App\Domain\Financial\Services\ParticipantBillingService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

/**
 * The group roster's الدفع column used to answer a different question from the
 * one it appeared to answer.
 *
 * It showed a LIFETIME subscription total next to a monthly bill, so a player
 * who had paid 650 in July, 1,200 for a kit bag and 650 in August read as
 * "2,500" for the current month. Worse, the red "unpaid" flag beside it came
 * from an unrelated calculation — matching invoice text with ilike '%اشتراك%'
 * plus the programme name — so a row could be flagged unpaid and show a green
 * figure at the same time.
 *
 * Every scenario below is taken from real production rows that rendered wrongly.
 */
class SubscriptionCycleFigureTest extends TestCase
{
    private const CYCLE_START = '2026-08-01';
    private const CYCLE_END = '2026-09-01';

    protected function setUp(): void
    {
        parent::setUp();

        if (config('database.default') !== 'sqlite') {
            $this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
        }
        $this->createMinimalSchema();
    }

    private function service(): ParticipantBillingService
    {
        return new ParticipantBillingService();
    }

    private function facts(int $participantId): ?array
    {
        return $this->service()
            ->subscriptionForPeriod([$participantId], self::CYCLE_START, self::CYCLE_END)[$participantId] ?? null;
    }

    // ---- the reported bug -------------------------------------------------

    public function test_a_lifetime_total_no_longer_leaks_into_the_current_cycle(): void
    {
        // Production participant 145: July subscription, a kit bag typed as a
        // free-text line, and August's subscription. The roster showed 2,500.
        $this->invoice(1, 145, '2026-07-01', subtotal: 65000, total: 65000, paid: 65000)
            ->line(1, 'اشتراك يوليو 2026', 65000);
        $this->invoice(2, 145, '2026-07-28', subtotal: 120000, total: 120000, paid: 120000)
            ->line(2, 'الشنطة', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);
        $this->invoice(3, 145, '2026-08-02', subtotal: 65000, total: 65000, paid: 65000)
            ->line(3, 'تجديد اشتراك: اكاديمية اطفال 2020', 65000);

        $facts = $this->facts(145);

        $this->assertSame(65000, $facts['paid'], 'Only August subscription money belongs to the August cycle.');
        $this->assertSame(65000, $facts['billed']);
        $this->assertSame(
            SubscriptionPaymentCase::Full,
            SubscriptionPaymentCase::classify($facts, false)
        );
    }

    public function test_a_row_flagged_as_owing_can_never_also_show_settled_money(): void
    {
        // Production participant 133: paid 650 in July, August renewal unpaid.
        // Old view: red row, green "650". Both now read the same calculation.
        $this->invoice(1, 133, '2026-07-01', subtotal: 65000, total: 65000, paid: 65000)
            ->line(1, 'اشتراك يوليو 2026', 65000);
        $this->invoice(2, 133, '2026-08-02', subtotal: 90000, total: 90000, paid: 0)
            ->line(2, 'تجديد اشتراك: اكاديمية 2011', 90000);

        $facts = $this->facts(133);
        $case = SubscriptionPaymentCase::classify($facts, false);

        $this->assertSame(0, $facts['paid']);
        $this->assertSame(SubscriptionPaymentCase::Unpaid, $case);
        $this->assertTrue($case->isOwing());
    }

    public function test_two_months_of_subscriptions_are_not_added_together(): void
    {
        // Production participant 199 read as 1,350 — July's 450 plus August's 900.
        $this->invoice(1, 199, '2026-07-21', subtotal: 45000, total: 45000, paid: 45000)
            ->line(1, 'اكاديمية 2008-20011 (متناسب: 10 من 30 يوم)', 45000);
        $this->invoice(2, 199, '2026-08-05', subtotal: 90000, total: 90000, paid: 90000)
            ->line(2, 'تجديد اشتراك: اكاديمية 2008-2011', 90000);

        $this->assertSame(90000, $this->facts(199)['paid']);
    }

    // ---- bundled invoices -------------------------------------------------

    public function test_a_product_on_the_same_invoice_is_kept_out_of_the_subscription_figure(): void
    {
        // Production participant 316: 390 subscription + 1,200 kit, paid together.
        $this->invoice(1, 316, '2026-08-18', subtotal: 159000, total: 159000, paid: 159000)
            ->line(1, 'اكاديمية 2008-2011 (متناسب: 13 من 30 يوم)', 39000)
            ->line(2, 'شنطة ملابس كرة القدم', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);

        $facts = $this->facts(316);

        $this->assertSame(39000, $facts['paid']);
        $this->assertSame(39000, $facts['billed']);
        $this->assertSame(SubscriptionPaymentCase::Prorated, SubscriptionPaymentCase::classify($facts, false));
    }

    public function test_a_partly_paid_bundle_allocates_only_the_subscription_share(): void
    {
        // Production participant 271: 900 subscription + 1,200 kit, 1,400 paid.
        $this->invoice(1, 271, '2026-08-03', subtotal: 210000, total: 210000, paid: 140000)
            ->line(1, 'اكاديمية اطفال 2020', 90000)
            ->line(2, 'شنطة ملابس كرة القدم', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);

        $facts = $this->facts(271);

        $this->assertSame(60000, $facts['paid'], '1,400 of a 2,100 bill is 600 of the 900 subscription.');
        $this->assertSame(90000, $facts['due']);
        $this->assertSame(SubscriptionPaymentCase::Partial, SubscriptionPaymentCase::classify($facts, false));
    }

    public function test_allocation_never_credits_more_than_the_line_cost(): void
    {
        // Service fees and tax settle on the total, so a full payment allocates
        // more than the line was billed unless it is capped.
        $this->invoice(1, 500, '2026-08-10', subtotal: 90000, total: 99000, paid: 99000)
            ->line(1, 'اشتراك', 90000);

        $facts = $this->facts(500);

        $this->assertSame(90000, $facts['paid']);
        $this->assertLessThanOrEqual($facts['billed'], $facts['paid']);
    }

    // ---- why the figure is what it is -------------------------------------

    public function test_an_invoice_level_admin_discount_is_reported_as_the_reason(): void
    {
        // Production participant 57: 650 billed, 325 discounted away, 325 paid.
        $this->invoice(1, 57, '2026-08-02', subtotal: 65000, total: 32500, paid: 32500, metadata: json_encode([
            'admin_override' => [
                'reason' => 'دفع المتبقى من الشهر',
                'applied_by_name' => 'OCZ',
                'discount_piasters' => 32500,
            ],
        ]))->line(1, 'تجديد اشتراك: فريق 2011/2012', 65000);

        $facts = $this->facts(57);
        $case = SubscriptionPaymentCase::classify($facts, false);

        $this->assertSame(32500, $facts['due'], 'The discount lowers what is owed, not what was billed.');
        $this->assertSame(65000, $facts['billed']);
        $this->assertSame(SubscriptionPaymentCase::AdminDiscount, $case);
        $this->assertFalse($case->isOwing(), 'A discounted bill paid in full is settled, not outstanding.');
        $this->assertStringContainsString('دفع المتبقى من الشهر', $case->explain($facts));
        $this->assertStringContainsString('OCZ', $case->explain($facts));
    }

    public function test_a_line_level_price_override_is_reported_with_its_original_price(): void
    {
        // Production participant 317: pro-rated to 690, overridden down to 450.
        $this->invoice(1, 317, '2026-08-18', subtotal: 45000, total: 45000, paid: 45000)
            ->line(1, 'اكاديمية 2008-2011 (متناسب: 23 من 30 يوم)', 45000, metadata: json_encode([
                'overridden_by' => 'Muhammad Aladdin Hammouda',
                'original_price' => 69000,
                'override_reason' => 'نصف شهر',
                'overridden_price' => 45000,
            ]));

        $facts = $this->facts(317);
        $case = SubscriptionPaymentCase::classify($facts, false);

        $this->assertSame(SubscriptionPaymentCase::PriceOverride, $case);
        $this->assertStringContainsString('نصف شهر', $case->explain($facts));
        $this->assertStringContainsString('690', $case->explain($facts));
    }

    public function test_an_agreed_instalment_plan_outranks_a_plain_shortfall(): void
    {
        $this->invoice(1, 900, '2026-08-01', subtotal: 65000, total: 65000, paid: 25900)
            ->line(1, 'اشتراك', 65000);

        DB::table('payment_plans')->insert([
            'id' => 1, 'invoice_id' => 1, 'status' => 'active',
            'total_installments' => 3, 'paid_installments' => 1, 'installment_amount' => 25900,
        ]);

        $facts = $this->facts(900);

        $this->assertTrue($facts['has_plan']);
        $this->assertSame(SubscriptionPaymentCase::Installment, SubscriptionPaymentCase::classify($facts, false));
    }

    // ---- reading a legacy invoice -----------------------------------------

    public function test_a_registration_fee_typed_by_hand_is_not_subscription_money(): void
    {
        // Production: "قسط القيد" — an instalment of the federation registration,
        // typed into the same box as a subscription and therefore itemable-less.
        // It used to read as 2,500 EGP of training money the player never paid.
        $this->program(1, 'اكاديمية 2015');
        $this->product(1, 'قيد اشتراك فريق اتحاد الكرة');

        $this->invoice(1, 500, '2026-08-03', subtotal: 250000, total: 250000, paid: 250000)
            ->line(1, 'قسط القيد', 250000);

        $this->assertNull($this->facts(500), 'A registration fee is not the training subscription.');
    }

    public function test_a_kit_typed_by_hand_is_not_subscription_money(): void
    {
        // Production: "الزي" on its own line, no product link, 2,800 EGP.
        $this->product(1, 'شنطة ملابس كرة القدم');

        $this->invoice(1, 501, '2026-08-06', subtotal: 130000, total: 130000, paid: 130000)
            ->line(1, 'الزي', 65000)
            ->line(2, 'تجديد اشتراك: فريق 2015', 65000);

        $facts = $this->facts(501);

        $this->assertSame(65000, $facts['billed'], 'Only the subscription line is the subscription.');
        $this->assertSame(65000, $facts['paid']);
    }

    public function test_a_programme_whose_name_reads_like_kit_still_counts(): void
    {
        // "تجهيزي" (preparatory) contains the letters of "زي" (kit).
        $this->program(1, 'اكاديمية تجهيزي 2019');

        $this->invoice(1, 502, '2026-08-04', subtotal: 65000, total: 65000, paid: 65000)
            ->line(1, 'اكاديمية تجهيزي  2019', 65000);

        $this->assertSame(65000, $this->facts(502)['paid']);
    }

    public function test_a_month_named_on_the_invoice_beats_the_day_it_was_typed(): void
    {
        // August's subscription, caught up on in September. Judged by its issue
        // date it belongs to no cycle anyone is looking at; it is August's money.
        $this->invoice(1, 503, '2026-09-20', subtotal: 65000, total: 65000, paid: 65000)
            ->line(1, 'اشتراك أغسطس 2026', 65000);

        $facts = $this->facts(503);

        $this->assertNotNull($facts, 'A back-dated August invoice is still August.');
        $this->assertSame(65000, $facts['paid']);
        $this->assertSame(SubscriptionPaymentCase::Full, SubscriptionPaymentCase::classify($facts, false));
    }

    public function test_a_month_named_on_the_invoice_also_keeps_money_out_of_this_cycle(): void
    {
        // Paid in August, for September. It is not August's collection.
        $this->invoice(1, 504, '2026-08-28', subtotal: 65000, total: 65000, paid: 65000)
            ->line(1, 'اشتراك سبتمبر 2026', 65000);

        $this->assertNull($this->facts(504));
    }

    public function test_one_invoice_covering_two_months_pays_half_of_each(): void
    {
        // The bundled invoices the old system is full of: two months, one paper.
        $this->invoice(1, 505, '2026-07-05', subtotal: 130000, total: 130000, paid: 130000)
            ->line(1, 'اشتراك يوليو وأغسطس 2026', 130000);

        $facts = $this->facts(505);

        $this->assertSame(65000, $facts['billed'], 'August is owed half of a two-month invoice.');
        $this->assertSame(65000, $facts['paid']);
    }

    public function test_an_odd_amount_split_across_months_never_invents_a_piastre(): void
    {
        // 3 months of 65,001 total: 21,667 each and the odd piastre stays on the
        // last month rather than being rounded into existence three times.
        $this->invoice(1, 506, '2026-07-01', subtotal: 65001, total: 65001, paid: 65001)
            ->line(1, 'اشتراك من يوليو إلى سبتمبر', 65001);

        $july = $this->service()->subscriptionForPeriod([506], '2026-07-01', '2026-08-01')[506];
        $august = $this->facts(506);
        $september = $this->service()->subscriptionForPeriod([506], '2026-09-01', '2026-10-01')[506];

        $this->assertSame(21667, $july['billed']);
        $this->assertSame(21667, $august['billed']);
        $this->assertSame(21667, $september['billed']);
        $this->assertSame(65001, $july['billed'] + $august['billed'] + $september['billed']);
    }

    public function test_an_invoice_that_names_no_month_is_still_judged_by_its_date(): void
    {
        // The common case, and the only evidence there is. This must not change.
        $this->invoice(1, 507, '2026-08-02', subtotal: 90000, total: 90000, paid: 0)
            ->line(1, 'تجديد اشتراك: فريق 2015', 90000);
        $this->invoice(2, 507, '2026-07-02', subtotal: 90000, total: 90000, paid: 90000)
            ->line(2, 'تجديد اشتراك: فريق 2015', 90000);

        $facts = $this->facts(507);

        $this->assertSame(90000, $facts['billed'], "Only August's renewal is August's.");
        $this->assertSame(0, $facts['paid']);
        $this->assertSame(SubscriptionPaymentCase::Unpaid, SubscriptionPaymentCase::classify($facts, false));
    }

    // ---- classification edges ---------------------------------------------

    public function test_a_free_player_outranks_every_other_case(): void
    {
        $this->invoice(1, 7, '2026-08-01', subtotal: 65000, total: 65000, paid: 0)->line(1, 'اشتراك', 65000);

        $this->assertSame(SubscriptionPaymentCase::Free, SubscriptionPaymentCase::classify($this->facts(7), true));
    }

    public function test_no_invoice_in_the_cycle_reads_as_not_billed_rather_than_unpaid(): void
    {
        $case = SubscriptionPaymentCase::classify(null, false);

        $this->assertSame(SubscriptionPaymentCase::NotBilled, $case);
        $this->assertTrue($case->isOwing(), 'A missing renewal is still money the group has not collected.');
    }

    public function test_a_cancelled_invoice_contributes_nothing(): void
    {
        $this->invoice(1, 800, '2026-08-04', subtotal: 90000, total: 90000, paid: 90000, status: 'cancelled')
            ->line(1, 'اشتراك', 90000);

        $this->assertNull($this->facts(800));
    }

    public function test_an_unconfirmed_or_outbound_payment_is_not_counted_as_collected(): void
    {
        $this->invoice(1, 801, '2026-08-04', subtotal: 90000, total: 90000, paid: 0)->line(1, 'اشتراك', 90000);

        DB::table('payments')->insert([
            ['id' => 90, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'pending', 'direction' => 'inbound', 'deleted_at' => null],
            ['id' => 91, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'confirmed', 'direction' => 'outbound', 'deleted_at' => null],
            ['id' => 92, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'confirmed', 'direction' => 'inbound', 'deleted_at' => '2026-08-05 00:00:00'],
        ]);

        $this->assertSame(SubscriptionPaymentCase::Unpaid, SubscriptionPaymentCase::classify($this->facts(801), false));
    }

    public function test_every_case_has_a_label_a_legend_a_colour_and_a_glyph(): void
    {
        foreach (SubscriptionPaymentCase::cases() as $case) {
            $this->assertNotSame('', $case->label(), "{$case->value} has no label");
            $this->assertNotSame('', $case->legend(), "{$case->value} has no legend");
            $this->assertStringStartsWith('text-', $case->textClass());
            $this->assertStringStartsWith('bg-', $case->swatchClass());
            $this->assertNotSame('', $case->glyph());
        }

        $this->assertSame(
            count(SubscriptionPaymentCase::cases()),
            count(SubscriptionPaymentCase::PRECEDENCE),
            'A case missing from PRECEDENCE would silently vanish from the legend.'
        );
    }

    public function test_the_roster_view_only_calls_methods_the_enum_actually_has(): void
    {
        // The view is Blade, so a renamed enum method is a 500 at request time
        // rather than a failure here. This pins the contract between the two.
        $view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));

        preg_match_all('/\$(?:sub)?[cC]ase->([a-zA-Z]+)\(/', $view, $matches);

        $called = array_unique($matches[1]);
        $this->assertNotEmpty($called, 'Expected the roster view to call enum methods.');

        foreach ($called as $method) {
            $this->assertTrue(
                method_exists(SubscriptionPaymentCase::class, $method),
                "The roster view calls {$method}() which SubscriptionPaymentCase does not define."
            );
        }
    }

    public function test_the_roster_view_no_longer_reads_the_removed_lifetime_variables(): void
    {
        $view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));

        foreach (['$subscriptionPaid', '$notPaidParticipantIds'] as $gone) {
            $this->assertStringNotContainsString(
                $gone,
                $view,
                "{$gone} is no longer passed to the view; reading it would render an empty figure."
            );
        }
    }

    public function test_the_roster_view_uses_logical_properties_only(): void
    {
        // Arabic is the default locale; a physical margin flips the layout.
        $view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));

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

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

    // ---- fixtures ---------------------------------------------------------

    private int $currentInvoice = 0;

    private function program(int $id, string $nameAr): void
    {
        DB::table('training_programs')->insert(['id' => $id, 'academy_id' => 1, 'name_ar' => $nameAr, 'deleted_at' => null]);
    }

    private function product(int $id, string $nameAr): void
    {
        DB::table('products')->insert(['id' => $id, 'academy_id' => 1, 'name_ar' => $nameAr, 'deleted_at' => null]);
    }

    private function invoice(
        int $id,
        int $participantId,
        string $issueDate,
        int $subtotal,
        int $total,
        int $paid,
        string $status = 'paid',
        ?string $metadata = null,
        ?string $notes = null,
    ): self {
        DB::table('invoices')->insert([
            'id' => $id,
            'number' => 'INV-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT),
            'academy_id' => 1,
            'billable_type' => 'App\\Domain\\Participant\\Models\\Participant',
            'billable_id' => $participantId,
            'subtotal_amount' => $subtotal,
            'total_amount' => $total,
            'issue_date' => $issueDate,
            'status' => $status,
            'notes' => $notes,
            'metadata' => $metadata,
            'deleted_at' => null,
        ]);

        if ($paid > 0) {
            DB::table('payments')->insert([
                'id' => $id,
                'invoice_id' => $id,
                'amount' => $paid,
                'status' => 'confirmed',
                'direction' => 'inbound',
                'deleted_at' => null,
            ]);
        }

        $this->currentInvoice = $id;

        return $this;
    }

    private function line(
        int $id,
        string $description,
        int $total,
        ?string $itemableType = null,
        ?int $itemableId = null,
        ?string $metadata = null,
    ): self {
        DB::table('invoice_items')->insert([
            'id' => $id,
            'invoice_id' => $this->currentInvoice,
            'itemable_type' => $itemableType,
            'itemable_id' => $itemableId,
            'description' => $description,
            'quantity' => 1,
            'total_amount' => $total,
            'metadata' => $metadata,
        ]);

        return $this;
    }

    private function createMinimalSchema(): void
    {
        Schema::create('invoices', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->string('number')->nullable();
            $table->unsignedBigInteger('academy_id')->nullable();
            $table->string('billable_type')->nullable();
            $table->unsignedBigInteger('billable_id')->nullable();
            $table->bigInteger('subtotal_amount')->default(0);
            $table->bigInteger('total_amount')->default(0);
            $table->date('issue_date')->nullable();
            $table->string('status')->nullable();
            $table->text('notes')->nullable();
            $table->text('metadata')->nullable();
            $table->timestamp('deleted_at')->nullable();
        });

        Schema::create('training_programs', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->unsignedBigInteger('academy_id')->nullable();
            $table->string('name_ar')->nullable();
            $table->timestamp('deleted_at')->nullable();
        });

        Schema::create('products', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->unsignedBigInteger('academy_id')->nullable();
            $table->string('name_ar')->nullable();
            $table->timestamp('deleted_at')->nullable();
        });

        Schema::create('invoice_items', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->unsignedBigInteger('invoice_id');
            $table->string('itemable_type')->nullable();
            $table->unsignedBigInteger('itemable_id')->nullable();
            $table->text('description')->nullable();
            $table->integer('quantity')->default(1);
            $table->bigInteger('total_amount')->default(0);
            $table->text('metadata')->nullable();
        });

        Schema::create('payments', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->unsignedBigInteger('invoice_id');
            $table->bigInteger('amount')->default(0);
            $table->string('status')->nullable();
            $table->string('direction')->nullable();
            $table->timestamp('deleted_at')->nullable();
        });

        Schema::create('payment_plans', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary();
            $table->unsignedBigInteger('invoice_id');
            $table->string('status')->nullable();
            $table->integer('total_installments')->default(0);
            $table->integer('paid_installments')->default(0);
            $table->bigInteger('installment_amount')->default(0);
        });
    }
}
