<?php

namespace Tests\Feature;

use App\Domain\Shared\Context\BranchScopeState;
use App\Domain\Shared\Models\Academy;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

/**
 * Proves the branch boundary holds, against a restored copy of a real tenant.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter BranchIsolationTest
 *
 * The point of running this against real data rather than factories is that
 * OC-Sport is exactly the shape that broke: nine branches, one of them live,
 * and six half-configured ones whose stray groups, facilities and programmes
 * kept surfacing in the live branch's dropdowns. A fixture built for the test
 * would not have had the awkward rows — the guardians shared between branches,
 * the invoices that arrived with no branch at all — that the scope has to get
 * right.
 *
 * Each assertion compares what Eloquent returns under a branch against what
 * raw SQL says is in that branch. Anything the scope lets through that SQL
 * says belongs elsewhere is the bug this whole change exists to prevent.
 */
class BranchIsolationTest extends TestCase
{
    private ?BranchScopeState $state = null;

    /** Models that must return their branch's rows and nothing else. */
    private const STRICT = [
        \App\Domain\Training\Models\TrainingGroup::class => 'training_groups',
        \App\Domain\Facility\Models\Facility::class => 'facilities',
        \App\Domain\Participant\Models\Participant::class => 'participants',
        \App\Domain\Training\Models\Enrollment::class => 'enrollments',
        \App\Domain\Training\Models\TrainingSession::class => 'training_sessions',
        \App\Domain\Attendance\Models\AttendanceRecord::class => 'attendance_records',
        \App\Domain\Financial\Models\Invoice::class => 'invoices',
        \App\Domain\Financial\Models\Payment::class => 'payments',
        \App\Domain\Financial\Models\Transaction::class => 'transactions',
        \App\Domain\Financial\Models\Expense::class => 'expenses',
        \App\Domain\Facility\Models\SpaceReservation::class => 'space_reservations',
        \App\Domain\Facility\Models\SpaceLayout::class => 'space_layouts',
        \App\Domain\Scheduling\Models\Assignment::class => 'assignments',
        \App\Domain\POS\Models\POSTransaction::class => 'pos_transactions',
        \App\Domain\Financial\Models\CashSession::class => 'cash_sessions',
        // The catalogue. A branch runs its own programmes at its own prices and
        // sells its own products; a programme offered at one branch showing up
        // in another's dropdown is the same bug as a group doing it.
        \App\Domain\Training\Models\TrainingProgram::class => 'training_programs',
        \App\Domain\Pricing\Models\BasePrice::class => 'base_prices',
        \App\Domain\Pricing\Models\PricingRule::class => 'pricing_rules',
        \App\Domain\Pricing\Models\Promotion::class => 'promotions',
        \App\Domain\Inventory\Models\Product::class => 'products',
        \App\Domain\Inventory\Models\Warehouse::class => 'warehouses',
        \App\Domain\Inventory\Models\Kit::class => 'kits',
        \App\Domain\Inventory\Models\ProductCategory::class => 'product_categories',
        \App\Domain\POS\Models\ReceiptTemplate::class => 'receipt_templates',
        \App\Domain\Financial\Models\Wallet::class => 'wallets',
    ];

    /**
     * Models where branch_id null still means "every branch uses this row".
     *
     * Only people and academy-wide calendar entries remain. A person is the one
     * thing that legitimately belongs to two branches at once — a coach working
     * two pitches needs one record visible from both, not two that drift — and a
     * public holiday is not a branch's property either.
     */
    private const SHARED = [
        \App\Domain\HR\Models\Employee::class => 'employees',
        \App\Domain\HR\Models\Trainer::class => 'trainers',
        \App\Domain\Identity\Models\Guardian::class => 'guardians',
        \App\Domain\Training\Models\Holiday::class => 'holidays',
    ];

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

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

        $academy = Academy::first();

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

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

        $this->state = app(BranchScopeState::class);
        $this->state->deactivate();
    }

    protected function tearDown(): void
    {
        // Nullable because setUp skips before assigning it when the tenant is
        // absent, and PHPUnit still runs tearDown after a skip.
        $this->state?->deactivate();

        parent::tearDown();
    }

    /** @return int[] */
    private function branchIds(): array
    {
        return DB::table('branches')->whereNull('deleted_at')->orderBy('id')->pluck('id')->all();
    }

    private function rawCount(string $table, string $where): int
    {
        $academyId = app('current_academy')->id;

        $query = DB::table($table)->whereRaw($where);

        if (DB::getSchemaBuilder()->hasColumn($table, 'academy_id')) {
            $query->where('academy_id', $academyId);
        }

        if (DB::getSchemaBuilder()->hasColumn($table, 'deleted_at')) {
            $query->whereNull('deleted_at');
        }

        return $query->count();
    }

    public function test_a_strictly_scoped_model_returns_only_the_active_branch(): void
    {
        $branches = $this->branchIds();
        $this->assertNotEmpty($branches, 'The tenant has no branches to test with.');

        foreach (self::STRICT as $class => $table) {
            foreach ($branches as $branchId) {
                $this->state->activate($branchId);
                $scoped = $class::count();

                $this->assertSame(
                    $this->rawCount($table, "branch_id = {$branchId}"),
                    $scoped,
                    "{$class} under branch {$branchId} does not match the rows SQL says are in that branch."
                );
            }
        }
    }

    public function test_no_strictly_scoped_row_is_visible_from_two_branches(): void
    {
        $branches = $this->branchIds();

        foreach (self::STRICT as $class => $table) {
            $seen = [];

            foreach ($branches as $branchId) {
                $this->state->activate($branchId);

                foreach ($class::query()->pluck('id') as $id) {
                    $this->assertArrayNotHasKey(
                        $id,
                        $seen,
                        "{$class} #{$id} is visible from branch {$branchId} and from branch "
                            .($seen[$id] ?? '?').' — a strictly scoped row belongs to exactly one branch.'
                    );

                    $seen[$id] = $branchId;
                }
            }
        }
    }

    public function test_a_shared_model_shows_its_branch_and_the_academy_wide_rows(): void
    {
        foreach (self::SHARED as $class => $table) {
            foreach ($this->branchIds() as $branchId) {
                $this->state->activate($branchId);

                $this->assertSame(
                    $this->rawCount($table, "(branch_id = {$branchId} OR branch_id IS NULL)"),
                    $class::count(),
                    "{$class} under branch {$branchId} should show that branch's rows plus the shared ones."
                );
            }
        }
    }

    public function test_a_shared_model_never_shows_another_branch_row(): void
    {
        $branches = $this->branchIds();

        foreach (self::SHARED as $class => $table) {
            foreach ($branches as $branchId) {
                $this->state->activate($branchId);

                $foreign = $class::query()
                    ->whereNotNull('branch_id')
                    ->where('branch_id', '!=', $branchId)
                    ->count();

                $this->assertSame(0, $foreign, "{$class} leaked a row from another branch into branch {$branchId}.");
            }
        }
    }

    public function test_a_line_item_is_scoped_through_its_parent(): void
    {
        $children = [
            \App\Domain\Financial\Models\InvoiceItem::class => ['invoices', 'invoice_id'],
            \App\Domain\POS\Models\POSTransactionItem::class => ['pos_transactions', 'pos_transaction_id'],
            \App\Domain\Training\Models\TrainingSchedule::class => ['training_groups', 'training_group_id'],
        ];

        foreach ($children as $class => [$parentTable, $foreignKey]) {
            $table = (new $class)->getTable();

            foreach ($this->branchIds() as $branchId) {
                $this->state->activate($branchId);

                $expected = DB::table($table)
                    ->join($parentTable, $parentTable.'.id', '=', $table.'.'.$foreignKey)
                    ->where($parentTable.'.branch_id', $branchId)
                    ->when(
                        DB::getSchemaBuilder()->hasColumn($parentTable, 'deleted_at'),
                        fn ($q) => $q->whereNull($parentTable.'.deleted_at')
                    )
                    ->count();

                $this->assertSame(
                    $expected,
                    $class::count(),
                    "{$class} under branch {$branchId} does not match its parents in that branch."
                );
            }
        }
    }

    public function test_enforcement_off_reads_every_branch(): void
    {
        // The console, the queues and the member portal all run with the scope
        // inactive. A cron that silently billed one branch would be a far worse
        // bug than the leak this change fixes, so the off switch is pinned too.
        $this->state->deactivate();

        $total = \App\Domain\Training\Models\TrainingGroup::count();

        $perBranch = 0;
        foreach ($this->branchIds() as $branchId) {
            $this->state->activate($branchId);
            $perBranch += \App\Domain\Training\Models\TrainingGroup::count();
        }

        $this->state->deactivate();

        $this->assertSame($total, $perBranch, 'Groups went missing between the unscoped read and the per-branch reads.');
        $this->assertGreaterThan(0, $total);
    }

    public function test_across_branches_lifts_the_scope_for_a_block(): void
    {
        $branches = $this->branchIds();
        $this->state->activate($branches[0]);

        $scoped = \App\Domain\Training\Models\TrainingGroup::count();

        $unscoped = \App\Domain\Training\Models\TrainingGroup::acrossBranches(
            fn () => \App\Domain\Training\Models\TrainingGroup::count()
        );

        // And enforcement must come back on afterwards.
        $this->assertSame($scoped, \App\Domain\Training\Models\TrainingGroup::count());
        $this->assertGreaterThanOrEqual($scoped, $unscoped);
    }

    public function test_every_strictly_scoped_table_has_a_branch_on_every_row(): void
    {
        // A null branch on a strictly scoped table is worse than a leak: the
        // row is invisible from every branch at once. This is what the
        // migration's backfill and main-branch fallback exist to guarantee.
        foreach (self::STRICT as $class => $table) {
            $orphans = DB::table($table)
                ->whereNull('branch_id')
                ->when(
                    DB::getSchemaBuilder()->hasColumn($table, 'deleted_at'),
                    fn ($q) => $q->whereNull('deleted_at')
                )
                ->count();

            $this->assertSame(0, $orphans, "{$table} has {$orphans} row(s) with no branch — they show on no screen at all.");
        }
    }
}
