<?php

namespace App\Http\Middleware;

use App\Domain\Shared\Context\BranchContext;
use App\Domain\Shared\Context\BranchScopeState;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

/**
 * Make sure every authenticated request knows which branch it is in — and
 * whether Eloquent should enforce it.
 *
 * Seeding used to happen inside BranchSwitcher::mount(), which meant a getter
 * with a write side effect that ran only on pages rendering the topbar — and
 * which re-pinned a concrete branch over the "all branches" null on every
 * render. Doing it here instead makes the switcher a pure reader.
 *
 * Runs after SetCurrentAcademy, which binds `current_academy`; BranchContext
 * needs that to know which branches exist.
 *
 * The second job — activating BranchScopeState — is what turns branch from a
 * filter each screen had to remember into an isolation boundary. It is done
 * here rather than in a service provider because it needs the resolved user
 * and the matched route, and neither exists at boot.
 */
class ResolveBranchContext
{
    public function __construct(
        private BranchContext $branch,
        private BranchScopeState $scope,
    ) {
    }

    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if (! $user) {
            // Guests get no branch and no enforcement: the public website and
            // the login screen have no branch to be in.
            return $next($request);
        }

        if (! $this->branch->isResolved()) {
            $this->branch->resolveForUser($user);
        }

        if ($this->shouldEnforce($request, $user)) {
            $this->scope->activate($this->branch->branchId());
        }

        return $next($request);
    }

    /**
     * Should Eloquent narrow this request to one branch?
     *
     * Answered no in every case where narrowing would hide a row its owner is
     * entitled to see. Everything else — the whole staff-facing application —
     * is scoped.
     *
     * The route-name half of that answer only covers the request that renders
     * a page. Every Livewire round-trip after it arrives on Livewire's own
     * update route, which matches nothing in `unscoped_routes`, so a page on
     * that list loads unscoped and then interacts scoped. Neither obvious
     * repair is safe: adding `livewire.*` to the list would unscope every
     * staff screen's round-trips and undo the isolation outright, and reading
     * the originating page out of the Referer header would let the browser
     * decide whether enforcement applies at all.
     *
     * So a surface that must stay unscoped on every request asserts it from
     * the component, where it is a fact about the screen rather than a guess
     * from a route name — see PortalScreen::bootPortalScreen(), which is what
     * keeps a staff account that is also a parent from having its own family
     * filtered by branch the moment it clicks something in the portal.
     */
    private function shouldEnforce(Request $request, User $user): bool
    {
        if ($this->routeIsUnscoped($request->route()?->getName())) {
            return false;
        }

        return ! $this->isMemberAccount($user);
    }

    private function routeIsUnscoped(?string $routeName): bool
    {
        if (! $routeName) {
            // An unnamed route is almost always a redirect or an asset. Nothing
            // renders a branch-scoped list from one, and refusing to enforce is
            // the safe direction: it can show too much, never too little.
            return false;
        }

        return Str::is(config('branch_scope.unscoped_routes', []), $routeName);
    }

    /**
     * A portal-only account: every role it holds is a member role.
     *
     * Checked by role slug rather than by permission because a member's
     * permissions overlap staff ones (portal.pay and invoices.list both read
     * invoices), while the roles never overlap. A user with no roles at all is
     * treated as staff — they see nothing anyway, and defaulting an unknown
     * account to "unscoped" would be the wrong way to be wrong.
     */
    private function isMemberAccount(User $user): bool
    {
        $memberRoles = config('branch_scope.member_roles', []);

        if ($memberRoles === []) {
            return false;
        }

        $slugs = $user->roles->pluck('slug')->filter()->all();

        if ($slugs === []) {
            return false;
        }

        return collect($slugs)->every(fn (string $slug) => in_array($slug, $memberRoles, true));
    }
}
