<?php

namespace Tests\Feature;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\LedgerAccountResolver;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\RevenueRouter;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

/**
 * PaymentService::resolveDebitAccount() returned the literal `1` and
 * resolveCreditAccount() returned `2`, both with a `// TODO`. Seeder order
 * made that "Dr Cash / Cr Bank" on every payment the system has ever taken:
 * no revenue account was credited even once, so FinancialOverview's
 * getRevenueBySource() — which sums transactions grouped by credit_account_id
 * restricted to revenue accounts — could only ever return an empty array.
 *
 * On the restored oc_sport tenant copy, 627 of 701 transactions were Dr 1 Cr 2.
 *
 * These tests pin the replacement: accounts resolved by code within the
 * academy, revenue split across accounts in proportion to what the invoice
 * actually sold, and the guards that used to live in two Livewire components
 * enforced in the service where every caller inherits them.
 */
class PaymentLedgerTest extends TestCase
{
    private User $actor;

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

        // Symmetrical with the tenant tests, which skip off Postgres: this one
        // builds its own tables, so pointing it at a real tenant database
        // collides with the schema already there.
        if (config('database.default') !== 'sqlite') {
            $this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
        }

        // These tests are about what lands in the ledger, not about the
        // notification and enrolment listeners that hang off InvoiceCreated
        // and PaymentReceived.
        Event::fake();

        $this->app->instance('current_academy', (object) ['id' => 1]);
        $this->createMinimalSchema();
        $this->seedChartOfAccounts();

        $this->actor = new User();
        $this->actor->id = 7;
        $this->actor->academy_id = 1;
        $this->actor->exists = true;
    }

    private function payments(): PaymentService
    {
        $accounts = new LedgerAccountResolver();

        return new PaymentService(new InvoiceService(), $accounts, new RevenueRouter($accounts));
    }

    private function invoiceWithLines(array $lines, int $total): Invoice
    {
        return (new InvoiceService())->create([
            'academy_id' => 1,
            'branch_id' => 3,
            'type' => 'standard',
            'total_amount' => $total,
        ], $lines, $this->actor);
    }

    // ---- the ledger -------------------------------------------------------

    /**
     * The minimal schema above deliberately has no `revenue_mappings`, so every
     * case in this file also proves the property this one names: routing rules
     * are configuration, and an installation that has not received them yet
     * keeps taking money using the built-in behaviour rather than refusing a
     * payment at the till because a settings table is absent.
     */
    public function test_the_till_still_works_when_the_routing_tables_are_not_there(): void
    {
        $this->assertFalse(Schema::hasTable('revenue_mappings'));

        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 15000]],
            15000
        );

        $payment = $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 15000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);

        $rows = Transaction::where('payment_id', $payment->id)->get();

        $this->assertCount(1, $rows);
        $this->assertSame('4000', $this->code($rows[0]->credit_account_id));
        $this->assertSame(15000, (int) $rows[0]->amount);
    }

    public function test_a_payment_credits_revenue_not_the_bank_account(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 20000]],
            20000
        );

        $payment = $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 20000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);

        $rows = Transaction::where('payment_id', $payment->id)->get();

        $this->assertCount(1, $rows);
        $this->assertSame('1000', $this->code($rows[0]->debit_account_id), 'cash lands in Cash');
        $this->assertSame('4000', $this->code($rows[0]->credit_account_id), 'and credits Training Revenue');
        $this->assertSame(20000, (int) $rows[0]->amount);
    }

    public function test_revenue_is_split_across_accounts_in_proportion_to_the_invoice_lines(): void
    {
        $invoice = $this->invoiceWithLines([
            ['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 20000],
            ['description' => 'منتج', 'quantity' => 1, 'unit_price' => 10000,
             'itemable_type' => \App\Domain\Inventory\Models\Product::class, 'itemable_id' => 1],
        ], 30000);

        $payment = $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 30000,
            'method' => 'bank_transfer',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);

        $byCode = Transaction::where('payment_id', $payment->id)->get()
            ->mapWithKeys(fn ($t) => [$this->code($t->credit_account_id) => (int) $t->amount])
            ->all();

        $this->assertSame(['4000' => 20000, '4020' => 10000], $byCode);
    }

    public function test_a_part_payment_splits_pro_rata_and_the_remainder_lands_on_the_last_row(): void
    {
        // 1/3 subscription, 2/3 product; paying 1000 gives 333 / 667.
        $invoice = $this->invoiceWithLines([
            ['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 1000],
            ['description' => 'منتج', 'quantity' => 1, 'unit_price' => 2000,
             'itemable_type' => \App\Domain\Inventory\Models\Product::class, 'itemable_id' => 1],
        ], 3000);

        $payment = $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 1000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);

        $rows = Transaction::where('payment_id', $payment->id)->get();

        $this->assertSame(1000, (int) $rows->sum('amount'), 'no piaster is created or lost');
        $this->assertSame(
            ['4020' => 666, '4000' => 334],
            $rows->mapWithKeys(fn ($t) => [$this->code($t->credit_account_id) => (int) $t->amount])->all()
        );
    }

    public function test_the_branch_that_took_the_money_reaches_the_ledger(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );

        $payment = $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 5000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);

        $this->assertSame(3, (int) Transaction::where('payment_id', $payment->id)->value('branch_id'));
        $this->assertSame(3, (int) $invoice->fresh()->branch_id, 'and the invoice keeps its branch');
    }

    public function test_a_missing_account_hard_fails_instead_of_posting_somewhere_wrong(): void
    {
        FinancialAccount::where('code', '4000')->delete();

        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );

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

        $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 5000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);
    }

    // ---- the guards that used to live in the UI ---------------------------

    public function test_a_payment_cannot_exceed_what_is_still_due(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );

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

        $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 5001,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);
    }

    public function test_a_cancelled_invoice_cannot_be_paid(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );
        $invoice->update(['status' => InvoiceStatus::Cancelled]);

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

        $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 5000,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);
    }

    public function test_a_zero_or_negative_payment_is_refused(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );

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

        $this->payments()->recordPayment([
            'academy_id' => 1,
            'branch_id' => 3,
            'invoice_id' => $invoice->id,
            'amount' => 0,
            'method' => 'cash',
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => '2026-09-01',
        ], $this->actor);
    }

    // ---- invoice numbering ------------------------------------------------

    public function test_invoice_numbers_never_repeat(): void
    {
        $service = new InvoiceService();

        $numbers = [];
        for ($i = 0; $i < 25; $i++) {
            $numbers[] = $service->generateNumber(1);
        }

        $this->assertSame($numbers, array_values(array_unique($numbers)));
    }

    public function test_numbering_does_not_reuse_a_number_a_soft_deleted_invoice_holds(): void
    {
        $invoice = $this->invoiceWithLines(
            [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 5000]],
            5000
        );
        $issued = $invoice->number;
        $invoice->delete();

        // count()+1 would hand the deleted invoice's number straight back out,
        // and the UNIQUE(academy_id, number) index still holds it.
        $this->assertNotSame($issued, (new InvoiceService())->generateNumber(1));
    }

    // ---- helpers ----------------------------------------------------------

    private function code(int $accountId): string
    {
        return FinancialAccount::withoutGlobalScopes()->find($accountId)->code;
    }

    private function seedChartOfAccounts(): void
    {
        $accounts = [
            ['1000', 'Cash', 'asset'],
            ['1010', 'Bank Account', 'asset'],
            ['1100', 'Accounts Receivable', 'asset'],
            ['1200', 'Wallets Receivable', 'asset'],
            ['4000', 'Training Revenue', 'revenue'],
            ['4010', 'Registration Fees', 'revenue'],
            ['4020', 'Equipment Sales', 'revenue'],
            ['4030', 'Facility Rental', 'revenue'],
            ['4050', 'Tournament Fees', 'revenue'],
            ['4060', 'External Revenue', 'revenue'],
        ];

        foreach ($accounts as [$code, $name, $type]) {
            FinancialAccount::withoutGlobalScopes()->create([
                'academy_id' => 1,
                'code' => $code,
                'name' => $name,
                'name_ar' => $name,
                'type' => $type,
            ]);
        }
    }

    private function createMinimalSchema(): void
    {
        Schema::create('financial_accounts', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->nullable();
            $table->unsignedBigInteger('academy_id');
            $table->unsignedBigInteger('parent_id')->nullable();
            $table->string('code');
            $table->string('name');
            $table->string('name_ar')->nullable();
            $table->string('type')->nullable();
            $table->string('category')->nullable();
            $table->boolean('is_system')->default(false);
            $table->boolean('is_active')->default(true);
            $table->text('description')->nullable();
            $table->timestamps();
        });

        Schema::create('invoices', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->nullable();
            $table->unsignedBigInteger('academy_id');
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->string('number');
            $table->string('type')->default('standard');
            $table->string('status')->default('draft');
            $table->string('billable_type')->nullable();
            $table->unsignedBigInteger('billable_id')->nullable();
            $table->string('contact_name')->nullable();
            $table->string('contact_phone')->nullable();
            $table->string('contact_email')->nullable();
            $table->bigInteger('subtotal_amount')->default(0);
            $table->bigInteger('discount_amount')->default(0);
            $table->bigInteger('tax_amount')->default(0);
            $table->bigInteger('service_fee_amount')->default(0);
            $table->bigInteger('total_amount')->default(0);
            $table->bigInteger('paid_amount')->default(0);
            $table->bigInteger('due_amount')->default(0);
            $table->string('currency', 8)->default('EGP');
            $table->date('issue_date')->nullable();
            $table->date('due_date')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->timestamp('cancelled_at')->nullable();
            $table->text('notes')->nullable();
            $table->json('metadata')->nullable();
            $table->integer('print_count')->default(0);
            $table->timestamp('last_printed_at')->nullable();
            $table->unsignedBigInteger('created_by')->nullable();
            $table->unsignedBigInteger('approved_by')->nullable();
            $table->unsignedBigInteger('collected_by')->nullable();
            $table->timestamps();
            $table->softDeletes();
            $table->unique(['academy_id', 'number']);
        });

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

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

        Schema::create('payments', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->nullable();
            $table->unsignedBigInteger('academy_id');
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->unsignedBigInteger('cash_session_id')->nullable();
            $table->unsignedBigInteger('invoice_id')->nullable();
            $table->string('reference')->nullable();
            $table->string('direction')->default('inbound');
            $table->string('method');
            $table->string('status');
            $table->string('payer_type')->nullable();
            $table->unsignedBigInteger('payer_id')->nullable();
            $table->bigInteger('amount');
            $table->string('currency', 8)->default('EGP');
            $table->date('payment_date')->nullable();
            $table->timestamp('confirmed_at')->nullable();
            $table->timestamp('failed_at')->nullable();
            $table->text('notes')->nullable();
            $table->json('gateway_data')->nullable();
            $table->json('metadata')->nullable();
            $table->integer('print_count')->default(0);
            $table->timestamp('last_printed_at')->nullable();
            $table->unsignedBigInteger('received_by')->nullable();
            $table->unsignedBigInteger('created_by')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });

        Schema::create('transactions', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->nullable();
            $table->unsignedBigInteger('academy_id');
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->unsignedBigInteger('debit_account_id');
            $table->unsignedBigInteger('credit_account_id');
            $table->unsignedBigInteger('payment_id')->nullable();
            $table->unsignedBigInteger('invoice_id')->nullable();
            $table->string('reference_type')->nullable();
            $table->unsignedBigInteger('reference_id')->nullable();
            $table->bigInteger('amount');
            $table->string('currency', 8)->default('EGP');
            $table->string('type');
            $table->text('description')->nullable();
            $table->date('transaction_date');
            $table->json('metadata')->nullable();
            $table->unsignedBigInteger('created_by')->nullable();
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('audit_logs', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('academy_id')->nullable();
            $table->unsignedBigInteger('user_id')->nullable();
            $table->string('auditable_type')->nullable();
            $table->unsignedBigInteger('auditable_id')->nullable();
            $table->string('event')->nullable();
            $table->json('old_values')->nullable();
            $table->json('new_values')->nullable();
            $table->string('ip_address')->nullable();
            $table->text('user_agent')->nullable();
            $table->boolean('is_financial')->default(false);
            $table->timestamp('created_at')->nullable();
        });
    }
}
