<?php

namespace Tests\Feature;

use App\Domain\Financial\Models\Invoice;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

/**
 * The monthly renewal run.
 *
 * On 1 September 2026 OC-Sport had 230 active players due for renewal and the
 * nightly command raised zero invoices — for the whole month. It resolved the
 * invoice's actor with `User::find($enrollment->created_by)`, and `enrollments`
 * has no `created_by` column; Eloquent answers null for a missing attribute, so
 * every enrolment took the "no valid user" branch. The command printed a tidy
 * summary and exited SUCCESS. Nothing in the system disagreed.
 *
 * Nothing pinned any of it, which is why a one-word column name could stop an
 * academy billing anybody and nobody found out for four weeks. These tests run
 * the real command against a real schema, with the real pricing engine, and
 * assert on invoices that actually exist.
 */
class GenerateRenewalInvoicesTest extends TestCase
{
    private const ACADEMY = 1;
    private const BRANCH = 1;
    private const PROGRAM = 7;
    private const MEMBER_PRICE = 65000;
    private const NON_MEMBER_PRICE = 90000;

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

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

        Carbon::setTestNow('2026-09-01 07:00:00');
        Event::fake();

        $this->createSchema();
        $this->seedAcademy();
    }

    protected function tearDown(): void
    {
        Carbon::setTestNow();
        parent::tearDown();
    }

    // ---- the outage ------------------------------------------------------

    public function test_the_first_of_the_month_bills_every_active_player(): void
    {
        $this->player(1, 'member');
        $this->player(2, 'non_member');
        $this->player(3, 'non_member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(3, Invoice::count(), 'Every active enrolment due on the 1st must be billed.');
    }

    public function test_a_run_that_bills_nobody_it_found_reports_failure(): void
    {
        // The whole point. A command that cannot bill must not exit SUCCESS —
        // that silence is what let a broken run repeat every day for a month.
        $this->player(1, 'member');
        DB::table('base_prices')->delete();

        $this->artisan('enrollments:generate-renewals')->assertFailed();

        $this->assertSame(0, Invoice::count());
    }

    public function test_the_actor_comes_from_enrolled_by_not_a_column_that_does_not_exist(): void
    {
        $this->assertFalse(
            Schema::hasColumn('enrollments', 'created_by'),
            'enrollments has never had a created_by column — code reading one gets null, silently.'
        );

        $this->player(1, 'member');
        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(9, Invoice::first()->created_by, 'The invoice is attributed to whoever enrolled the player.');
    }

    public function test_a_deleted_receptionist_does_not_stop_a_player_being_billed(): void
    {
        $this->player(1, 'member');
        DB::table('enrollments')->update(['enrolled_by' => 999]); // long gone

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(1, Invoice::count());
        $this->assertNotNull(Invoice::first()->created_by);
    }

    // ---- member vs non-member --------------------------------------------

    public function test_a_member_is_billed_the_member_price_and_a_non_member_the_non_member_price(): void
    {
        $this->player(1, 'member');
        $this->player(2, 'non_member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(
            self::MEMBER_PRICE,
            (int) Invoice::where('billable_id', 1)->value('total_amount')
        );
        $this->assertSame(
            self::NON_MEMBER_PRICE,
            (int) Invoice::where('billable_id', 2)->value('total_amount')
        );
    }

    public function test_the_full_subscription_price_is_billed_not_a_prorated_one(): void
    {
        $this->player(1, 'non_member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $invoice = Invoice::first();
        $this->assertSame(self::NON_MEMBER_PRICE, (int) $invoice->subtotal_amount);
        $this->assertSame(self::NON_MEMBER_PRICE, (int) $invoice->total_amount);
    }

    public function test_a_programme_with_no_price_fails_loudly_instead_of_billing_zero(): void
    {
        // "No active base price = hard fail" is a project invariant. Production
        // has one such programme (عبدالعال 2012) with six players on it: they
        // must show up as failures, never as a silent zero-value invoice.
        $this->player(1, 'member');
        $this->player(2, 'member', programId: 99); // programme with no base price

        $this->artisan('enrollments:generate-renewals')->assertFailed();

        $this->assertSame(1, Invoice::count(), 'The priced player is still billed.');
        $this->assertSame(1, Invoice::where('billable_id', 1)->count());
    }

    // ---- which month the money belongs to --------------------------------

    public function test_the_invoice_is_dated_the_first_even_when_the_run_is_late(): void
    {
        Carbon::setTestNow('2026-09-20 07:00:00');
        $this->player(1, 'member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $invoice = Invoice::first();
        $this->assertSame('2026-09-01', $invoice->issue_date->toDateString(), 'September money, dated September.');
        $this->assertSame('2026-09', $invoice->metadata['month']);
    }

    public function test_the_month_is_stamped_where_subscription_line_looks_first(): void
    {
        $this->player(1, 'member');
        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $months = \App\Domain\Financial\Support\SubscriptionLine::monthsCovered(
            DB::table('invoice_items')->value('description'),
            Invoice::first()->notes,
            Invoice::first()->metadata ?? [],
            Invoice::first()->issue_date->toDateString(),
        );

        $this->assertSame(['2026-09'], $months);
    }

    // ---- a missed run must not cost a month ------------------------------

    public function test_a_missed_month_is_caught_up_with_each_invoice_on_its_own_first(): void
    {
        // The container was redeploying at 07:00 on 1 August, so August never
        // ran. The old code advanced by one month from whatever it found and
        // raised a single invoice, so August was skipped permanently.
        $this->player(1, 'member', nextBillingDate: '2026-08-01');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $dates = Invoice::orderBy('issue_date')->pluck('issue_date')->map->toDateString()->all();
        $this->assertSame(['2026-08-01', '2026-09-01'], $dates);

        $months = Invoice::orderBy('issue_date')->get()->map(fn ($i) => $i->metadata['month'])->all();
        $this->assertSame(['2026-08', '2026-09'], $months);
    }

    public function test_the_next_cycle_is_the_first_of_next_month_however_late_the_run(): void
    {
        Carbon::setTestNow('2026-09-25 07:00:00');
        $this->player(1, 'member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(
            '2026-10-01',
            Enrollment::first()->next_billing_date->toDateString(),
            'Lateness must never move the billing day.'
        );
    }

    public function test_running_twice_in_a_day_does_not_bill_twice(): void
    {
        $this->player(1, 'member');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();
        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(1, Invoice::count());
    }

    public function test_a_cycle_already_raised_by_hand_is_not_billed_again(): void
    {
        $this->player(1, 'member');

        // The receptionist raised September from the payment wizard this
        // morning; the cron must recognise it rather than duplicate it.
        DB::table('invoices')->insert([
            'academy_id' => self::ACADEMY,
            'number' => 'INV-MANUAL',
            'status' => 'sent',
            'billable_type' => (new \App\Domain\Participant\Models\Participant())->getMorphClass(),
            'billable_id' => 1,
            'subtotal_amount' => self::MEMBER_PRICE,
            'discount_amount' => 0,
            'tax_amount' => 0,
            'total_amount' => self::MEMBER_PRICE,
            'paid_amount' => 0,
            'due_amount' => self::MEMBER_PRICE,
            'issue_date' => '2026-09-01',
            'due_date' => '2026-09-08',
            'metadata' => json_encode(['month' => '2026-09', 'renewal_enrollment_id' => '1']),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(1, Invoice::count());
        $this->assertSame('2026-10-01', Enrollment::first()->next_billing_date->toDateString());
    }

    // ---- who is in and who is out ----------------------------------------

    public function test_a_free_player_is_never_billed(): void
    {
        $this->player(1, 'member', isFree: true);

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(0, Invoice::count());
    }

    public function test_a_player_with_no_billing_date_is_adopted_rather_than_ignored_forever(): void
    {
        // Every renewal query in the system filters `next_billing_date IS NOT
        // NULL`, so a paying active player with a null one is invisible to all
        // of them — permanently, and with nothing to show it.
        $this->player(1, 'member', nextBillingDate: null);

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(1, Invoice::count());
        $this->assertSame('2026-09-01', Invoice::first()->issue_date->toDateString());
        $this->assertSame('2026-10-01', Enrollment::first()->next_billing_date->toDateString());
    }

    public function test_a_player_whose_cycle_is_still_ahead_is_left_alone(): void
    {
        $this->player(1, 'member', nextBillingDate: '2026-10-01');

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(0, Invoice::count());
    }

    public function test_a_cancelled_enrolment_is_not_billed(): void
    {
        $this->player(1, 'member');
        DB::table('enrollments')->update(['status' => 'cancelled']);

        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $this->assertSame(0, Invoice::count());
    }

    // ---- discounts --------------------------------------------------------

    public function test_a_pricing_discount_is_subtracted_once_not_twice(): void
    {
        // recalculateTotals() computes total = sum(line totals) - header
        // discount. The old code put the same discount on BOTH, so every
        // discounted renewal charged half the discount too little. OC-Sport has
        // an active sibling-discount rule, so this was live money.
        DB::table('pricing_rules')->insert([
            'academy_id' => self::ACADEMY,
            'name' => 'Flat discount',
            'name_ar' => 'خصم',
            'rule_type' => 'sibling_order',
            'adjustment_type' => 'fixed_discount',
            'adjustment_value' => 5000,
            'priority' => 1,
            'is_active' => true,
            'is_manual' => false,
            'is_stackable' => true,
            'conditions' => json_encode([]),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->player(1, 'member');
        $this->artisan('enrollments:generate-renewals')->assertSuccessful();

        $invoice = Invoice::first();
        $this->assertSame(5000, (int) $invoice->discount_amount);
        $this->assertSame(self::MEMBER_PRICE, (int) $invoice->subtotal_amount, 'Lines carry the undiscounted price.');
        $this->assertSame(self::MEMBER_PRICE - 5000, (int) $invoice->total_amount, 'Discount applied exactly once.');
    }

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

    private function player(
        int $id,
        string $membership,
        ?string $nextBillingDate = '2026-09-01',
        bool $isFree = false,
        int $programId = self::PROGRAM
    ): void {
        DB::table('people')->insert([
            'id' => $id, 'academy_id' => self::ACADEMY, 'name' => "Player {$id}",
            'name_ar' => "لاعب {$id}", 'gender' => 'male', 'date_of_birth' => '2014-05-05',
            'created_at' => now(), 'updated_at' => now(),
        ]);

        DB::table('participants')->insert([
            'id' => $id, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
            'person_id' => $id, 'status' => 'active', 'membership_type' => $membership,
            'is_free' => $isFree, 'classification' => 'regular',
            'created_at' => now(), 'updated_at' => now(),
        ]);

        DB::table('enrollments')->insert([
            'id' => $id, 'academy_id' => self::ACADEMY, 'participant_id' => $id,
            'training_group_id' => 1, 'training_program_id' => $programId,
            'enrollment_date' => '2026-07-01', 'start_date' => '2026-07-01',
            'status' => 'active', 'enrolled_by' => 9, 'payment_status' => 'pending',
            'next_billing_date' => $nextBillingDate,
            'created_at' => now(), 'updated_at' => now(),
        ]);
    }

    private function seedAcademy(): void
    {
        DB::table('users')->insert([
            'id' => 9, 'academy_id' => self::ACADEMY, 'name' => 'Reception',
            'email' => 'reception@example.test', 'password' => 'x', 'is_super_admin' => false,
            'created_at' => now(), 'updated_at' => now(),
        ]);

        foreach ([self::PROGRAM, 99] as $programId) {
            DB::table('training_programs')->insert([
                'id' => $programId, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
                'name' => "Program {$programId}", 'name_ar' => "برنامج {$programId}",
                'renewal_policy' => 'auto_renew', 'billing_cycle' => 'monthly', 'billing_day' => 1,
                'created_by' => 9, 'created_at' => now(), 'updated_at' => now(),
            ]);
        }

        DB::table('training_groups')->insert([
            'id' => 1, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
            'training_program_id' => self::PROGRAM, 'name' => 'G1', 'name_ar' => 'مجموعة',
            'status' => 'active', 'created_at' => now(), 'updated_at' => now(),
        ]);

        // Exactly the shape production uses: branch-scoped, tier in metadata,
        // member cheaper and at a lower priority number than non-member.
        foreach ([['member', self::MEMBER_PRICE, 1], ['non_member', self::NON_MEMBER_PRICE, 2]] as [$tier, $amount, $priority]) {
            DB::table('base_prices')->insert([
                'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
                'priceable_type' => 'App\\Domain\\Training\\Models\\TrainingProgram',
                'priceable_id' => self::PROGRAM,
                'name' => $tier, 'name_ar' => $tier, 'amount' => $amount, 'currency' => 'EGP',
                'effective_from' => '2026-01-01', 'is_active' => true, 'priority' => $priority,
                'metadata' => json_encode(['membership_type' => $tier]),
                'created_at' => now(), 'updated_at' => now(),
            ]);
        }
    }

    private function createSchema(): void
    {
        Schema::create('academies', function (Blueprint $t) {
            $t->id();
            $t->string('name')->nullable();
            $t->timestamps();
        });
        DB::table('academies')->insert(['id' => self::ACADEMY, 'name' => 'OC']);

        Schema::create('users', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->string('email')->nullable();
            $t->string('password')->nullable();
            $t->boolean('is_super_admin')->default(false);
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('people', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->string('gender')->nullable();
            $t->date('date_of_birth')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('participants', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->unsignedBigInteger('person_id')->nullable();
            $t->string('status')->default('active');
            $t->string('membership_type')->nullable();
            $t->string('classification')->nullable();
            $t->boolean('is_free')->default(false);
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('guardians', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('person_id')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('guardian_participant', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('guardian_id');
            $t->unsignedBigInteger('participant_id');
            $t->timestamps();
        });

        Schema::create('training_programs', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->string('renewal_policy')->nullable();
            $t->string('billing_cycle')->nullable();
            $t->unsignedTinyInteger('billing_day')->nullable();
            $t->unsignedInteger('program_duration_weeks')->nullable();
            $t->unsignedInteger('total_sessions')->nullable();
            $t->unsignedBigInteger('created_by')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('training_groups', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->unsignedBigInteger('training_program_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->string('status')->default('active');
            $t->timestamps();
            $t->softDeletes();
        });

        // Deliberately mirrors the real migration: enrolled_by, and NO
        // created_by. A test that invented a created_by column would have
        // agreed with the broken code and shipped the outage all over again.
        Schema::create('enrollments', function (Blueprint $t) {
            $t->id();
            $t->uuid('uuid')->nullable();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('participant_id');
            $t->unsignedBigInteger('training_group_id')->nullable();
            $t->unsignedBigInteger('training_program_id')->nullable();
            $t->date('enrollment_date')->nullable();
            $t->date('start_date')->nullable();
            $t->date('end_date')->nullable();
            $t->string('status')->default('active');
            $t->unsignedBigInteger('enrolled_by')->nullable();
            $t->unsignedBigInteger('invoice_id')->nullable();
            $t->string('payment_status')->nullable();
            $t->integer('sessions_attended')->nullable();
            $t->integer('sessions_total')->nullable();
            $t->date('next_billing_date')->nullable();
            $t->date('last_billed_at')->nullable();
            $t->text('notes')->nullable();
            $t->json('metadata')->nullable();
            $t->timestamps();
        });

        Schema::create('invoices', function (Blueprint $t) {
            $t->id();
            $t->uuid('uuid')->nullable();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->string('number')->nullable();
            $t->string('type')->nullable();
            $t->string('status')->default('draft');
            $t->string('billable_type')->nullable();
            $t->unsignedBigInteger('billable_id')->nullable();
            $t->string('contact_name')->nullable();
            $t->bigInteger('subtotal_amount')->default(0);
            $t->bigInteger('discount_amount')->default(0);
            $t->bigInteger('tax_amount')->default(0);
            $t->bigInteger('service_fee_amount')->default(0);
            $t->bigInteger('total_amount')->default(0);
            $t->bigInteger('paid_amount')->default(0);
            $t->bigInteger('due_amount')->default(0);
            $t->string('currency')->default('EGP');
            $t->date('issue_date')->nullable();
            $t->date('due_date')->nullable();
            $t->timestamp('paid_at')->nullable();
            $t->timestamp('cancelled_at')->nullable();
            $t->text('notes')->nullable();
            $t->json('metadata')->nullable();
            $t->unsignedBigInteger('created_by')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('invoice_items', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('invoice_id');
            $t->string('itemable_type')->nullable();
            $t->unsignedBigInteger('itemable_id')->nullable();
            $t->string('description')->nullable();
            $t->integer('quantity')->default(1);
            $t->bigInteger('unit_price')->default(0);
            $t->bigInteger('discount_amount')->default(0);
            $t->bigInteger('tax_amount')->default(0);
            $t->bigInteger('total_amount')->default(0);
            $t->json('metadata')->nullable();
            $t->timestamps();
        });

        Schema::create('base_prices', function (Blueprint $t) {
            $t->id();
            $t->uuid('uuid')->nullable();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->string('priceable_type');
            $t->unsignedBigInteger('priceable_id');
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->bigInteger('amount')->default(0);
            $t->string('currency')->default('EGP');
            $t->date('effective_from')->nullable();
            $t->date('effective_to')->nullable();
            $t->boolean('is_active')->default(true);
            $t->integer('priority')->default(0);
            $t->json('metadata')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('pricing_rules', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->unsignedBigInteger('branch_id')->nullable();
            $t->string('name')->nullable();
            $t->string('name_ar')->nullable();
            $t->string('rule_type');
            $t->string('adjustment_type');
            $t->bigInteger('adjustment_value')->default(0);
            $t->integer('priority')->default(0);
            $t->integer('max_discount_percent')->nullable();
            $t->boolean('is_active')->default(true);
            $t->boolean('is_manual')->default(false);
            $t->boolean('is_stackable')->default(true);
            $t->date('effective_from')->nullable();
            $t->date('effective_to')->nullable();
            $t->json('conditions')->nullable();
            $t->string('target_type')->nullable();
            $t->unsignedBigInteger('target_id')->nullable();
            $t->integer('usage_limit')->nullable();
            $t->integer('usage_count')->default(0);
            $t->json('metadata')->nullable();
            $t->unsignedBigInteger('created_by')->nullable();
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('pricing_rule_branches', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('pricing_rule_id');
            $t->unsignedBigInteger('branch_id');
        });

        Schema::create('pricing_rule_targets', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('pricing_rule_id');
            $t->string('targetable_type')->nullable();
            $t->unsignedBigInteger('targetable_id')->nullable();
        });

        Schema::create('invoice_number_counters', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->unique();
            $t->unsignedBigInteger('next_number')->default(1);
            $t->timestamps();
        });

        Schema::create('audit_logs', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('academy_id')->nullable();
            $t->string('auditable_type')->nullable();
            $t->unsignedBigInteger('auditable_id')->nullable();
            $t->string('event')->nullable();
            $t->json('old_values')->nullable();
            $t->json('new_values')->nullable();
            $t->unsignedBigInteger('user_id')->nullable();
            $t->string('ip_address')->nullable();
            $t->string('user_agent')->nullable();
            $t->timestamps();
        });
    }
}
