<?php

namespace Tests\Feature;

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

/**
 * Renders every staff screen with a branch selected and proves none of them
 * contains another branch's records.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter BranchScopedScreensTest
 *
 * BranchIsolationTest proves the scope narrows a model. This proves the screens
 * actually go through it — which is the part that was broken, because the model
 * layer was never the problem: eighty components filtered their main list query
 * correctly and then rendered a <select> beside it that offered every branch.
 *
 * The assertion is by uuid rather than by name on purpose. OC-Sport's branches
 * share programme names almost exactly — "فريق 2018" exists at three of them —
 * so a name match would be noise. A uuid appearing in the HTML means that exact
 * row was rendered, and there is no other way for it to get there.
 */
class BranchScopedScreensTest extends TestCase
{
    /**
     * Routes that are cross-branch or non-branch by design.
     *
     * Kept deliberately short. Everything absent from it is expected to hold
     * the line, and adding a route here is a decision, not a convenience.
     */
    private const UNSCOPED_BY_DESIGN = [
        // Member-facing: scoped by membership, never by branch.
        'portal.*', 'parent.*', 'guardian.*',
        // Public and infrastructure.
        'home', 'login', 'logout', 'health', 'website.*', 'public.*', 'self-register.*',
        // Branch administration and the cross-academy panel list branches by definition.
        'branches.*', 'admin.panel',
        // File downloads: the body is a spreadsheet, not a page.
        'export.*',
    ];

    /**
     * Routes excluded from the render sweep for reasons other than scoping —
     * they stream a file, need a scanner, or are a redirect shim.
     */
    private const NOT_RENDERABLE = [
        'portal.manifest', 'portal.sw', 'portal.assetlinks', 'portal.aasa',
        'portal.session-exchange', 'portal.push.config',
        'logout', 'health', 'home',
    ];

    private User $owner;

    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);

        $owner = User::withoutGlobalScopes()
            ->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
            ->first();

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

        $this->owner = $owner;
    }

    /**
     * The uuids of every strictly-branch-owned row that does NOT belong to the
     * given branch. If one of these turns up in a page, that page leaked.
     *
     * @return array<string, string>  uuid => "table #id (branch N)"
     */
    private function foreignUuids(int $branchId): array
    {
        $tables = [
            'training_groups', 'facilities', 'participants',
            'invoices', 'expenses', 'pos_transactions', 'cash_sessions',
        ];

        $out = [];

        foreach ($tables as $table) {
            if (! DB::getSchemaBuilder()->hasColumn($table, 'uuid')) {
                continue;
            }

            $rows = DB::table($table)
                ->select('uuid', 'id', 'branch_id')
                ->whereNotNull('branch_id')
                ->where('branch_id', '!=', $branchId)
                ->when(
                    DB::getSchemaBuilder()->hasColumn($table, 'deleted_at'),
                    fn ($q) => $q->whereNull('deleted_at')
                )
                ->get();

            foreach ($rows as $row) {
                $out[(string) $row->uuid] = "{$table} #{$row->id} (branch {$row->branch_id})";
            }
        }

        return $out;
    }

    /** @return string[] */
    private function renderableRoutes(): array
    {
        $names = [];

        foreach (Route::getRoutes() as $route) {
            $name = $route->getName();

            if (! $name || ! in_array('GET', $route->methods(), true)) {
                continue;
            }

            if (str_contains($route->uri(), '{')) {
                continue;
            }

            if (in_array($name, self::NOT_RENDERABLE, true)) {
                continue;
            }

            if (\Illuminate\Support\Str::is(self::UNSCOPED_BY_DESIGN, $name)) {
                continue;
            }

            $names[] = $name;
        }

        sort($names);

        return array_values(array_unique($names));
    }

    private function branchesWithData(): array
    {
        return DB::table('training_groups')
            ->select('branch_id')
            ->whereNotNull('branch_id')
            ->groupBy('branch_id')
            ->orderBy('branch_id')
            ->pluck('branch_id')
            ->map(fn ($id) => (int) $id)
            ->all();
    }

    public function test_every_staff_screen_renders_with_a_branch_selected(): void
    {
        $branchId = $this->branchesWithData()[0] ?? null;
        $this->assertNotNull($branchId, 'The tenant has no branch carrying groups.');

        $broken = [];

        foreach ($this->renderableRoutes() as $name) {
            $response = $this->actingAs($this->owner)
                ->withSession([BranchContext::KEY => $branchId])
                ->get(route($name));

            // 403 is a legitimate answer — the owner may lack a permission —
            // a redirect is how the branch lock and the setup wizard work, and
            // a couple of routes answer 204 or 404 by design (the CSRF cookie,
            // the report viewer that wants a report id). A 5xx is never fine:
            // a screen that crashes cannot respect a branch either.
            if ($response->getStatusCode() >= 500) {
                $broken[$name] = $response->getStatusCode().': '
                    .substr(strip_tags($response->getContent()), 0, 200);
            }
        }

        $this->assertSame([], $broken, "Screens that stopped rendering:\n".json_encode($broken, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    }

    public function test_no_staff_screen_renders_another_branch_record(): void
    {
        $branches = $this->branchesWithData();
        $this->assertNotEmpty($branches);

        $leaks = [];

        foreach ($branches as $branchId) {
            $foreign = $this->foreignUuids($branchId);

            if ($foreign === []) {
                continue;
            }

            foreach ($this->renderableRoutes() as $name) {
                $response = $this->actingAs($this->owner)
                    ->withSession([BranchContext::KEY => $branchId])
                    ->get(route($name));

                if ($response->getStatusCode() !== 200) {
                    continue;
                }

                $html = $response->getContent();

                foreach ($foreign as $uuid => $label) {
                    if (str_contains($html, $uuid)) {
                        $leaks[] = "branch {$branchId} / {$name} rendered {$label}";
                    }
                }
            }
        }

        $this->assertSame([], $leaks, "Cross-branch records reached a screen:\n".implode("\n", $leaks));
    }

    public function test_a_url_supplied_branch_cannot_override_the_selected_one(): void
    {
        $branches = $this->branchesWithData();

        if (count($branches) < 2) {
            $this->markTestSkipped('Needs two branches carrying data.');
        }

        [$mine, $theirs] = $branches;
        $foreign = $this->foreignUuids($mine);

        if ($foreign === []) {
            $this->markTestSkipped('No other-branch records to try to smuggle in.');
        }

        $leaks = [];

        // Every list screen that ever had a `branch_id` filter is worth trying:
        // the filter was #[Url], so the value arrives in the query string and
        // used to drive the whole page.
        foreach ($this->renderableRoutes() as $name) {
            $response = $this->actingAs($this->owner)
                ->withSession([BranchContext::KEY => $mine])
                ->get(route($name).'?branch_id='.$theirs);

            if ($response->getStatusCode() !== 200) {
                continue;
            }

            foreach ($foreign as $uuid => $label) {
                if (str_contains($response->getContent(), $uuid)) {
                    $leaks[] = "{$name}?branch_id={$theirs} rendered {$label}";
                }
            }
        }

        $this->assertSame([], $leaks, "A branch id in the URL overrode the selected branch:\n".implode("\n", $leaks));
    }

    /**
     * Detail routes, keyed by the route parameter's name.
     *
     * Only strictly-scoped tables appear here. A shared table (products, base
     * prices, trainers) legitimately answers 200 for a null-branch row, so it
     * cannot distinguish a leak from correct behaviour and would only add noise.
     */
    private const DETAIL_PARAMS = [
        'participant' => 'participants',
        'group' => 'training_groups',
        'facility' => 'facilities',
        'invoice' => 'invoices',
        'expense' => 'expenses',
    ];

    public function test_a_detail_screen_refuses_another_branch_record(): void
    {
        $branches = $this->branchesWithData();

        if (count($branches) < 2) {
            $this->markTestSkipped('Needs two branches carrying data.');
        }

        $mine = $branches[0];
        $reachable = [];

        foreach (Route::getRoutes() as $route) {
            $name = $route->getName();

            if (! $name || ! in_array('GET', $route->methods(), true)) {
                continue;
            }

            if (\Illuminate\Support\Str::is(self::UNSCOPED_BY_DESIGN, $name)) {
                continue;
            }

            preg_match_all('/\{(\w+)\??\}/', $route->uri(), $matches);

            if (count($matches[1]) !== 1) {
                continue;
            }

            $table = self::DETAIL_PARAMS[$matches[1][0]] ?? null;

            if (! $table) {
                continue;
            }

            // A row that exists, and belongs to a branch that is not the one
            // the session has selected.
            $foreign = DB::table($table)
                ->whereNotNull('branch_id')
                ->where('branch_id', '!=', $mine)
                ->when(
                    DB::getSchemaBuilder()->hasColumn($table, 'deleted_at'),
                    fn ($q) => $q->whereNull('deleted_at')
                )
                ->first();

            if (! $foreign) {
                continue;
            }

            $key = DB::getSchemaBuilder()->hasColumn($table, 'uuid') ? $foreign->uuid : $foreign->id;

            $response = $this->actingAs($this->owner)
                ->withSession([BranchContext::KEY => $mine])
                ->get(url($route->uri(), [$key]));

            // 404 is the right answer and what the global scope produces: from
            // this branch, that record does not exist. 403 and a redirect are
            // fine too. A 200 means the page opened another branch's record.
            if ($response->getStatusCode() === 200) {
                $reachable[] = "{$name} opened {$table} #{$foreign->id} (branch {$foreign->branch_id}) "
                    ."while branch {$mine} was selected";
            }
        }

        $this->assertSame([], $reachable, "Detail screens reachable across the branch boundary:\n".implode("\n", $reachable));
    }

    public function test_a_member_account_is_not_branch_scoped(): void
    {
        // Enforcement must stay off for the portal. A guardian's child may train
        // at any branch, and a branch filter here would hide a family's own
        // records from them — a worse bug than the leak this change fixes.
        $member = User::withoutGlobalScopes()
            ->whereHas('roles', fn ($q) => $q->whereIn('slug', config('branch_scope.member_roles')))
            ->whereDoesntHave('roles', fn ($q) => $q->whereNotIn('slug', config('branch_scope.member_roles')))
            ->first();

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

        $response = $this->actingAs($member)->get(route('portal.home'));

        $this->assertContains(
            $response->getStatusCode(),
            [200, 302, 403],
            'The member portal stopped answering once branch enforcement went in.'
        );

        $this->assertFalse(
            app(BranchScopeState::class)->shouldFilter(),
            'Branch enforcement was left switched on after a member-portal request.'
        );
    }
}
