<?php

namespace Tests\Feature;

use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentProofService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Models\Academy;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

/**
 * The InstaPay path, against a restored copy of a real tenant database.
 *
 * Postgres-only, and deliberately so: half the guarantees here ARE database
 * objects — partial unique indexes on the sender reference and the pending
 * proof, a CHECK making an approved proof without a payment unrepresentable,
 * and a BEFORE UPDATE trigger freezing a settled row. Testing this on SQLite
 * would assert the service layer while silently skipping every control that
 * actually holds the line.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter PaymentProofTest
 */
class PaymentProofTest extends TestCase
{
    private Academy $academy;

    private User $member;

    private User $staff;

    private Participant $participant;

    private int $branchId;

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

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

        // Deliberately NOT Event::fake(): HasUuid generates the uuid from a
        // model `creating` event, and faking events leaves every row with a
        // null uuid against a NOT NULL column.
        $this->academy = Academy::firstOrFail();
        $this->app->instance('current_academy', $this->academy);

        $this->participant = Participant::whereNotNull('branch_id')->firstOrFail();
        $this->branchId = (int) $this->participant->branch_id;

        $users = User::withoutGlobalScopes()->where('academy_id', $this->academy->id)->limit(2)->get();
        $this->member = $users[0];
        $this->staff = $users[1];

        DB::beginTransaction();
    }

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

        parent::tearDown();
    }

    private function anOpenInvoice(int $total = 50000): Invoice
    {
        $invoice = app(InvoiceService::class)->create([
            'academy_id' => $this->academy->id,
            'branch_id' => $this->branchId,
            'type' => 'standard',
            'billable_type' => Participant::class,
            'billable_id' => $this->participant->id,
            'total_amount' => $total,
        ], [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => $total]], $this->staff);

        $invoice->update(['status' => 'sent']);

        return $invoice->refresh();
    }

    private function aProof(Invoice $invoice, int $claimed, string $reference): PaymentProof
    {
        return app(PaymentProofService::class)->submit([
            'invoice_id' => $invoice->id,
            'branch_id' => $this->branchId,
            'amount_claimed' => $claimed,
            'method' => 'instapay',
            'sender_reference' => $reference,
            'proof_path' => 'proofs/test.jpg',
        ], $this->member);
    }

    // ---- what a proof is, and is not ---------------------------------------

    public function test_submitting_a_proof_moves_no_money(): void
    {
        $invoice = $this->anOpenInvoice();

        $this->aProof($invoice, 50000, 'REF-A1');

        $this->assertSame(50000, (int) $invoice->refresh()->due_amount, 'a screenshot is not a payment');
        $this->assertSame(0, (int) $invoice->paid_amount);
    }

    public function test_a_sender_reference_cannot_be_used_twice(): void
    {
        // The backbone control: one reference per academy per method kills
        // replay, cross-invoice reuse, and claiming someone else's transfer.
        $this->aProof($this->anOpenInvoice(), 50000, 'REF-B1');

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

        $this->aProof($this->anOpenInvoice(), 50000, 'ref-b1 ');
    }

    public function test_only_one_proof_may_be_pending_on_an_invoice(): void
    {
        $invoice = $this->anOpenInvoice();
        $this->aProof($invoice, 50000, 'REF-C1');

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

        $this->aProof($invoice, 50000, 'REF-C2');
    }

    public function test_a_cancelled_invoice_refuses_a_proof(): void
    {
        $invoice = $this->anOpenInvoice();
        $invoice->update(['status' => 'cancelled']);

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

        $this->aProof($invoice, 50000, 'REF-D1');
    }

    // ---- approval ----------------------------------------------------------

    public function test_approval_posts_the_verified_amount_not_the_claimed_one(): void
    {
        $invoice = $this->anOpenInvoice(50000);
        $proof = $this->aProof($invoice, 90000, 'REF-E1');   // payer overstates

        $payment = app(PaymentProofService::class)->approve($proof, 40000, $this->staff);

        $this->assertSame(40000, (int) $payment->amount, 'the reviewer reads the statement, not the screenshot');
        $this->assertSame(10000, (int) $invoice->refresh()->due_amount);
    }

    public function test_an_overpayment_is_capped_and_the_excess_goes_to_the_wallet(): void
    {
        // Never InvoiceStatus::Overpaid: nothing consumes it and due_amount
        // goes negative, after which getCollectionRate() and
        // ParticipantBillingService start summing negative numbers.
        $invoice = $this->anOpenInvoice(50000);
        $proof = $this->aProof($invoice, 60000, 'REF-F1');

        $before = (int) (Wallet::where('owner_type', Participant::class)
            ->where('owner_id', $this->participant->id)->value('balance') ?? 0);

        $payment = app(PaymentProofService::class)->approve($proof, 60000, $this->staff);

        $invoice->refresh();

        $this->assertSame(50000, (int) $payment->amount);
        $this->assertSame(0, (int) $invoice->due_amount);
        $this->assertSame('paid', $invoice->status->value);

        $after = (int) Wallet::where('owner_type', Participant::class)
            ->where('owner_id', $this->participant->id)->value('balance');

        $this->assertSame($before + 10000, $after, 'the excess is credited, not discarded');
    }

    public function test_approval_posts_a_real_ledger_entry_with_a_branch(): void
    {
        $invoice = $this->anOpenInvoice();
        $proof = $this->aProof($invoice, 50000, 'REF-G1');

        $payment = app(PaymentProofService::class)->approve($proof, 50000, $this->staff);

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

        $this->assertNotEmpty($rows, 'an approved transfer must reach the ledger');
        $this->assertSame(50000, (int) $rows->sum('amount'));
        $this->assertSame($this->branchId, (int) $rows->first()->branch_id);
    }

    public function test_a_reviewer_cannot_approve_their_own_submission(): void
    {
        $proof = $this->aProof($this->anOpenInvoice(), 50000, 'REF-H1');

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

        app(PaymentProofService::class)->approve($proof, 50000, $this->member);
    }

    public function test_a_second_approval_finds_nothing_left_to_approve(): void
    {
        // The concurrency control is the conditional UPDATE, not a disabled
        // button: two reviewers both see an enabled Approve.
        $invoice = $this->anOpenInvoice();
        $proof = $this->aProof($invoice, 50000, 'REF-I1');

        app(PaymentProofService::class)->approve($proof, 50000, $this->staff);

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

        app(PaymentProofService::class)->approve($proof->refresh(), 50000, $this->staff);
    }

    public function test_a_settled_proof_cannot_be_edited(): void
    {
        $invoice = $this->anOpenInvoice();
        $proof = $this->aProof($invoice, 50000, 'REF-J1');

        app(PaymentProofService::class)->approve($proof, 50000, $this->staff);

        // Enforced by a database trigger, because approving a proof is the
        // moral equivalent of taking cash and Auditable::createAuditLog()
        // silently writes nothing when it cannot resolve an academy.
        $this->expectException(\Illuminate\Database\QueryException::class);

        DB::table('payment_proofs')->where('id', $proof->id)->update(['amount_approved' => 999999]);
    }

    public function test_rejecting_leaves_the_invoice_untouched(): void
    {
        $invoice = $this->anOpenInvoice();
        $proof = $this->aProof($invoice, 50000, 'REF-K1');

        app(PaymentProofService::class)->reject($proof, 'لا يوجد تحويل مطابق', $this->staff);

        $this->assertSame('rejected', $proof->refresh()->status);
        $this->assertSame(50000, (int) $invoice->refresh()->due_amount);

        // A rejected reference is released, so a member who mistyped it once
        // is not locked out of ever submitting that transfer.
        $this->aProof($this->anOpenInvoice(), 50000, 'REF-K1');

        $this->assertTrue(true);
    }
}
