<?php

namespace Tests\Feature;

use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Financial\Listeners\UpdateCashSessionTotals;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Services\CashSessionService;
use App\Domain\Shared\Context\BranchContext;
use App\Domain\Shared\Context\BranchScopeState;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;

/**
 * A cash drawer belongs to a branch, so every branch can have one open.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter PerBranchCashSessionTest
 *
 * The bug this pins deadlocked every till but one. The guard on opening a
 * shift looked across all branches; the POS and the manage screen looked only
 * at the current one. A cashier with a drawer left open at branch A therefore
 * arrived at branch B to find the terminal asking for a shift, the open form
 * refusing with "there is already one open", and nothing on screen to close —
 * the shift it meant was in a branch the screen would not show. On OC-Sport
 * three users had drawers sitting open at ZSC since July, which is every
 * account the other seven branches sell through.
 *
 * Everything runs inside a transaction that is rolled back: the tenant is a
 * restored copy, but it is a copy of real money and these tests open tills.
 */
class PerBranchCashSessionTest extends TestCase
{
    private User $cashier;

    private int $branchA;

    private int $branchB;

    private CashSessionService $service;

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

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

        if (! $academy = Academy::first()) {
            $this->markTestSkipped('No academy in the restored tenant.');
        }

        app()->instance('current_academy', $academy);

        $branches = DB::table('branches')
            ->where('academy_id', $academy->id)
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->pluck('id')
            ->map(fn ($id) => (int) $id)
            ->all();

        if (count($branches) < 2) {
            $this->markTestSkipped('Needs at least two branches to have a second till.');
        }

        [$this->branchA, $this->branchB] = $branches;

        $user = User::where('academy_id', $academy->id)->orderBy('id')->first();

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

        $this->cashier = $user;
        $this->service = app(CashSessionService::class);

        DB::beginTransaction();

        // Start from a clean drawer for this cashier, inside the transaction —
        // the restored tenant carries the very sessions that caused the bug.
        CashSession::withoutBranchScope()
            ->where('user_id', $this->cashier->id)
            ->where('status', 'open')
            ->update(['status' => 'closed', 'closed_at' => now()]);
    }

    protected function tearDown(): void
    {
        DB::rollBack();

        parent::tearDown();
    }

    /** Put the request in a branch the way the topbar switcher does. */
    private function inBranch(int $branchId): void
    {
        session([BranchContext::KEY => $branchId]);
        app()->forgetInstance(BranchContext::class);
        app(BranchScopeState::class)->activate($branchId);
    }

    public function test_a_drawer_open_in_another_branch_does_not_block_this_one(): void
    {
        $this->actingAs($this->cashier);

        $this->inBranch($this->branchA);
        $first = $this->service->open($this->cashier, $this->branchA, 10000);

        $this->inBranch($this->branchB);
        $second = $this->service->open($this->cashier, $this->branchB, 5000);

        $this->assertNotSame($first->id, $second->id);
        $this->assertSame($this->branchA, (int) $first->branch_id);
        $this->assertSame($this->branchB, (int) $second->branch_id);

        $open = CashSession::withoutBranchScope()
            ->where('user_id', $this->cashier->id)
            ->where('status', 'open')
            ->pluck('branch_id')
            ->map(fn ($id) => (int) $id)
            ->all();

        sort($open);
        $this->assertSame([$this->branchA, $this->branchB], $open);
    }

    public function test_a_second_drawer_in_the_same_branch_is_still_refused(): void
    {
        $this->actingAs($this->cashier);
        $this->inBranch($this->branchA);

        $this->service->open($this->cashier, $this->branchA, 10000);

        $this->expectException(DomainException::class);
        $this->service->open($this->cashier, $this->branchA, 2000);
    }

    public function test_each_branch_resolves_its_own_drawer(): void
    {
        $this->actingAs($this->cashier);

        $this->inBranch($this->branchA);
        $atA = $this->service->open($this->cashier, $this->branchA, 10000);

        // Standing in B, the drawer in A is not this till's drawer...
        $this->inBranch($this->branchB);
        $this->assertNull($this->service->getOpenSession($this->cashier, $this->branchB));

        // ...but it is still findable by name, from anywhere. This is the
        // lookup a refund uses to put cash back in the branch it came from.
        $this->assertSame(
            $atA->id,
            $this->service->getOpenSession($this->cashier, $this->branchA)?->id
        );

        $elsewhere = $this->service->getOpenSessionsInOtherBranches($this->cashier, $this->branchB);
        $this->assertCount(1, $elsewhere);
        $this->assertSame($this->branchA, (int) $elsewhere->first()->branch_id);
    }

    public function test_the_till_and_the_shift_screen_agree_and_the_shift_opens(): void
    {
        $this->actingAs($this->cashier);

        $this->inBranch($this->branchA);
        $this->service->open($this->cashier, $this->branchA, 10000);

        // At branch B: the POS says there is no shift here, the manage screen
        // offers the open form rather than a close form, and opening works.
        // Before the fix the first two held and the third threw.
        $this->inBranch($this->branchB);

        $manage = \Livewire\Livewire::test(\App\Livewire\CashSessions\CashSessionManage::class);
        $manage->assertSet('currentSession', null);
        $this->assertNotEmpty($manage->get('otherBranchSessions'));

        $manage->set('opening_balance', 50)
            ->call('openSession')
            ->assertSet('currentSession.branch_id', $this->branchB);

        $terminal = \Livewire\Livewire::test(\App\Livewire\POS\POSTerminal::class);
        $terminal->assertSet('hasOpenSession', true);
    }

    public function test_cash_counts_into_the_drawer_of_the_branch_that_took_it(): void
    {
        $this->actingAs($this->cashier);

        $this->inBranch($this->branchA);
        $atA = $this->service->open($this->cashier, $this->branchA, 0);
        $this->inBranch($this->branchB);
        $atB = $this->service->open($this->cashier, $this->branchB, 0);

        // The listener runs on a queue, where branch enforcement is off — the
        // condition that let it credit whichever drawer sorted first.
        app(BranchScopeState::class)->deactivate();

        $payment = Payment::create([
            'uuid' => (string) Str::uuid(),
            'academy_id' => $this->cashier->academy_id,
            'branch_id' => $this->branchB,
            'direction' => 'inbound',
            'method' => 'cash',
            'status' => 'confirmed',
            'amount' => 7500,
            'payment_date' => now()->toDateString(),
            'received_by' => $this->cashier->id,
        ]);

        app(UpdateCashSessionTotals::class)->handle(
            new PaymentReceived($payment, $this->cashier)
        );

        $this->assertSame(7500, (int) $atB->fresh()->total_cash_in);
        $this->assertSame(0, (int) $atA->fresh()->total_cash_in);
    }
}
