<?php

namespace App\Http\Middleware;

use App\Models\Central\Tenant;
use App\Models\Scopes\TenantScope;
use App\Services\CurrentContext;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;

/**
 * Resolves the tenant for the request and binds it onto
 * {@see CurrentContext} (driving {@see TenantScope}).
 *
 * Tenant identification reads {@see config('tenancy.routing')}:
 *
 * - "subdomain" — from the host: "<tenant-slug>.<app_domain>"; central hosts
 *   (config tenancy.central_domains) run unscoped with a route-name lock
 *   keeping the tenant panel off the landlord host (see
 *   docs/path-based-tenancy-plan.md).
 * - "path" — from the first URL segment: "<app_domain>/<slug>/app/...";
 *   central is the segment matching tenancy.central_prefix.
 *
 * Runs first among the appended web middleware — after StartSession (so the
 * authenticated user is resolvable) but before ResolveBranchContext (which
 * queries the now tenant-scoped Branch model).
 *
 * In path mode, requests to the site root (Livewire's AJAX endpoints at
 * /livewire-<hash>/..., public pin-login/impersonation, the dev ops routes,
 * the bare-domain home) carry no workspace segment and pass through
 * unresolved: Livewire re-runs this middleware against the *original* page
 * path during its persistent-middleware pass (registered in
 * AppServiceProvider::boot()), where the {workspace} route parameter is
 * resolvable again, so a tenant page's AJAX still binds its tenant.
 *
 * A session belonging to a different tenant is severed — a cookie minted on
 * one tenant can never be replayed on another (with a shared host + path=/
 * cookie this is the PRIMARY isolation boundary, docs/path-based-tenancy-plan.md
 * Phase 3).
 *
 * Branding adaptation (docs/migration-plan.md §3): the source app pushed
 * tenant branding/theme into Blade `View::share()` for the pre-hydration head
 * of the React SPA. Filament has no equivalent need for that — panel config
 * (`->colors()`, `->brandLogo()`, `->favicon()`, etc. in the Panel providers)
 * is itself evaluated per-request via closures, which read
 * `CurrentContext::tenant()->setting(...)` directly once this middleware has
 * bound the tenant. Nothing needs to be shared through the view layer.
 */
class IdentifyTenant
{
    public function __construct(private readonly CurrentContext $context) {}

    public function handle(Request $request, Closure $next): Response
    {
        return config('tenancy.routing', 'subdomain') === 'path'
            ? $this->handlePathRouting($request, $next)
            : $this->handleSubdomainRouting($request, $next);
    }

    /**
     * Host-header tenancy ("<slug>.<app_domain>") — the original behaviour,
     * preserved verbatim for the tenancy.routing = "subdomain" rollback path.
     */
    private function handleSubdomainRouting(Request $request, Closure $next): Response
    {
        $host = strtolower($request->getHost());

        if (in_array($host, config('tenancy.central_domains', []), true)) {
            $this->context->markCentral();

            // The literal master-control host (e.g. admin.example.com) must serve
            // ONLY the central panel. Without this check, the tenant panel's
            // /app/login would still resolve right alongside the landlord's
            // /login on the same host — and, because tenant scoping is bypassed
            // here (central context), that login would match a user by email
            // across every tenant instead of failing to find one. Everything
            // outside the `central` Filament panel is unreachable on this host.
            if ($host === config('tenancy.central_domain')) {
                $name = $request->route()?->getName();

                $isCentralPanelRoute = is_string($name) && str_starts_with($name, 'filament.central.');

                // Livewire's shared AJAX plumbing (component updates, file uploads/
                // previews) is registered globally with just the `web` middleware
                // group — its route names never carry the `filament.central.`
                // prefix, even when the request originates from a central-panel
                // page. Blocking it here would 404 every Livewire interaction
                // (e.g. submitting the login form) on this host. These endpoints
                // stay safe without the route-name check: they only ever act on a
                // component snapshot that was legitimately mounted by an already
                // panel-authorized page, and Filament's own guard/auth checks run
                // inside the component itself.
                $isLivewireInternalRoute = is_string($name) && (
                    str_ends_with($name, 'livewire.update')
                    || str_ends_with($name, 'livewire.upload-file')
                    || str_ends_with($name, 'livewire.preview-file')
                );

                if (! $isCentralPanelRoute && ! $isLivewireInternalRoute) {
                    abort(404);
                }
            }

            return $next($request);
        }

        $slug = explode('.', $host)[0];

        return $this->resolveTenantAndContinue($request, $next, $slug);
    }

    /**
     * Path tenancy ("/<workspace>/app/..."). The first segment identifies the
     * tenant; central is the configured prefix; everything else at the site
     * root (Livewire AJAX, public routes, dev ops) passes through unresolved.
     */
    private function handlePathRouting(Request $request, Closure $next): Response
    {
        $workspace = $request->route('workspace');

        if ($workspace !== null) {
            return $this->resolveTenantAndContinue($request, $next, $workspace);
        }

        $first = $request->segment(1);

        if ($first !== null && $first === config('tenancy.central_prefix')) {
            $this->context->markCentral();

            return $next($request);
        }

        if ($first === null
            || str_starts_with($first, 'livewire-')
            || in_array($first, config('tenancy.reserved_slugs', []), true)) {
            return $next($request);
        }

        return $this->resolveTenantAndContinue($request, $next, $first);
    }

    /**
     * Resolve, validate and bind the tenant; sever a session that belongs to a
     * different one.
     */
    private function resolveTenantAndContinue(Request $request, Closure $next, string $slug): Response
    {
        $tenant = Tenant::query()->where('slug', $slug)->first();

        if (! $tenant) {
            abort(404, 'Unknown workspace.');
        }

        if (! $tenant->isActive()) {
            abort(403, 'This workspace is currently unavailable. Please contact support.');
        }

        $this->context->setTenant($tenant);

        // Back-fill the {workspace} route parameter so every existing
        // route()/getUrl() call keeps generating the tenant prefix without
        // passing it in explicitly (docs/path-based-tenancy-plan.md Lever 2).
        // A no-op in subdomain mode, where no route carries the parameter.
        URL::defaults(['workspace' => $tenant->slug]);

        $this->applyTenantTimezone();

        // The primary cross-tenant isolation boundary in path mode (shared
        // host + path=/ session cookie). With subdomain routing this is
        // defence in depth on top of host-only session cookies.
        $user = $request->user();
        if ($user && (int) $user->tenant_id !== (int) $tenant->id) {
            Auth::guard('web')->logout();
            $request->session()->invalidate();
            $request->session()->regenerateToken();

            return redirect()->route('filament.app.auth.login');
        }

        return $next($request);
    }

    /**
     * Makes `now()`/Carbon and every date/time column agree with what the
     * tenant configured on the Master Control "Locale" tab. Scoped to this
     * request only in effect, not just in intent: `date_default_timezone_set()`
     * is process-global, but every request boots a fresh application (config
     * reloaded from source, no state carried over from the last request), so
     * nothing here can leak into the next tenant's request. Queued jobs never
     * run this middleware in the first place — they read
     * {@see CurrentContext::timezone()} directly, under
     * `CurrentContext::runForTenant()`, the same as any other tenant-scoped
     * background work. Falls back to the application default when the
     * tenant hasn't set one.
     */
    private function applyTenantTimezone(): void
    {
        $timezone = $this->context->timezone();

        date_default_timezone_set($timezone);
        config(['app.timezone' => $timezone]);
    }
}
