<?php

namespace Livewire\Mechanisms\HandleRequests;

use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
use Livewire\Features\SupportScriptsAndAssets\SupportScriptsAndAssets;
use Livewire\Features\SupportReactiveProps\SupportReactiveProps;
use Livewire\Mechanisms\HandleRequests\EndpointResolver;
use Livewire\Exceptions\PayloadTooLargeException;
use Livewire\Exceptions\TooManyComponentsException;

use Livewire\Mechanisms\Mechanism;

use function Livewire\trigger;

class HandleRequests extends Mechanism
{
    protected $updateRoute;

    protected $shouldPropagateExceptions = false;

    function boot()
    {
        // Register the default route immediately (before routes files load)
        // so it's positioned before any catch-all routes.
        if (! $this->updateRoute && ! $this->updateRouteExists()) {
            app($this::class)->setUpdateRoute(function ($handle, $path) {
                return Route::post($path, $handle)
                    ->middleware(['web', RequireLivewireHeaders::class])
                    ->name('default-livewire.update');
            });
        }

        $this->skipRequestPayloadTamperingMiddleware();
    }

    protected function updateRouteExists()
    {
        return $this->findUpdateRoute() !== null;
    }

    function getUriPrefix()
    {
        return EndpointResolver::prefix();
    }

    function getUpdateUri()
    {
        // When routes are cached, $this->updateRoute may be null because
        // setUpdateRoute() was never called (the route already existed).
        // In this case, find the route from the router.
        $route = $this->updateRoute ?? $this->findUpdateRoute();

        return (string) str(app('url')->toRoute($route, [], false))->start('/');
    }

    protected function findUpdateRoute()
    {
        // Find the route with name ending in 'livewire.update'.
        // Custom routes can have prefixes (e.g., 'tenant.livewire.update')
        // so we check for routes ending with 'livewire.update', not just exact matches.
        // Prioritise custom routes over the default route.
        $defaultRoute = null;

        foreach (Route::getRoutes()->getRoutes() as $route) {
            if (str($route->getName())->endsWith('livewire.update')) {
                // If it's the default route, save it but keep looking for a custom one
                if ($route->getName() === 'default-livewire.update') {
                    $defaultRoute = $route;
                    continue;
                }

                // Found a custom route, return it immediately
                return $route;
            }
        }

        return $defaultRoute;
    }

    function skipRequestPayloadTamperingMiddleware()
    {
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen(function () {
            return $this->isLivewireRequest();
        });

        \Illuminate\Foundation\Http\Middleware\TrimStrings::skipWhen(function () {
            return $this->isLivewireRequest();
        });
    }

    function setUpdateRoute($callback)
    {
        $route = $callback([self::class, 'handleUpdate'], EndpointResolver::updatePath());

        // Ensure the route includes the `web` middleware group.
        // Without it, CSRF protection is lost entirely on the update endpoint.
        // Note: we use middleware() (not gatherMiddleware()) to avoid polluting
        // the route's computed middleware cache before it's fully configured.
        if (! in_array('web', $route->middleware())) {
            $route->middleware('web');
        }

        // Ensure the header guard middleware is always present, even on custom routes.
        // Only append if its not exists on current middleware stack
        if (! in_array(RequireLivewireHeaders::class, $route->middleware())) {
            $route->middleware(RequireLivewireHeaders::class);
        }

        // Append `livewire.update` to the existing name, if any.
        if (! str($route->getName())->endsWith('livewire.update')) {
            $route->name('livewire.update');
        }

        $this->updateRoute = $route;
    }

    function isLivewireRequest()
    {
        return request()->hasHeader('X-Livewire');
    }

    function isLivewireRoute()
    {
        // @todo: Rename this back to `isLivewireRequest` once the need for it in tests has been fixed.
        $route = request()->route();

        if (! $route) return false;

        /*
         * Check to see if route name ends with `livewire.update`, as if
         * a custom update route is used and they add a name, then when
         * we call `->name('livewire.update')` on the route it will
         * suffix the existing name with `livewire.update`.
         */
        return $route->named('*livewire.update');
    }

    function temporarilyPropagateExceptions($callback)
    {
        $cachedShouldPropagateExceptions = $this->shouldPropagateExceptions;

        $this->shouldPropagateExceptions = true;

        try {
            return $callback();
        } finally {
            $this->shouldPropagateExceptions = $cachedShouldPropagateExceptions;
        }
    }

    function handleUpdate()
    {
        // When a custom update route is registered, reject requests that arrive
        // via the default route. This prevents attackers from bypassing middleware
        // (e.g. auth, tenant scoping) added to the custom route.
        if (request()->route()?->getName() === 'default-livewire.update'
            && $this->findUpdateRoute()?->getName() !== 'default-livewire.update') {
            abort(404);
        }

        // Check payload size limit...
        $maxSize = config('livewire.payload.max_size');

        if ($maxSize !== null) {
            $size = strlen(request()->getContent());

            if ($size > $maxSize) {
                throw new PayloadTooLargeException($size, $maxSize);
            }
        }

        $requestPayload = request('components');

        if (! is_array($requestPayload) || empty($requestPayload)) {
            abort(404);
        }

        foreach ($requestPayload as $component) {
            if (! is_array($component)
                || ! is_string($component['snapshot'] ?? null)
                || ! is_array($component['updates'] ?? null)
                || ! is_array($component['calls'] ?? null)
            ) {
                abort(404);
            }
        }

        // Check max components limit...
        $maxComponents = config('livewire.payload.max_components');

        if ($maxComponents !== null && count($requestPayload) > $maxComponents) {
            throw new TooManyComponentsException(count($requestPayload), $maxComponents);
        }

        $finish = trigger('request', $requestPayload);

        $requestPayload = $finish($requestPayload);

        $componentResponses = [];

        foreach ($requestPayload as $componentPayload) {
            $snapshot = json_decode($componentPayload['snapshot'], associative: true);
            $updates = $componentPayload['updates'];
            $calls = $componentPayload['calls'];

            // If this is a reactive child whose props didn't change,
            // skip its entire lifecycle (hydrate, render, dehydrate)...
            if (empty($updates) && SupportReactiveProps::shouldSkipUpdate($snapshot, $calls)) {
                $componentResponses[] = [
                    'skip' => true,
                    'id' => $snapshot['memo']['id'],
                ];

                continue;
            }

            try {
                [ $snapshot, $effects ] = app('livewire')->update($snapshot, $updates, $calls);
            } catch (\TypeError $e) {
                report($e);

                if (config('app.debug') || $this->shouldPropagateExceptions) throw $e;

                abort(419);
            }

            $componentResponses[] = [
                'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR),
                'effects' => $effects,
            ];
        }

        $responsePayload = [
            'components' => $componentResponses,
            'assets' => SupportScriptsAndAssets::getAssets(),
        ];

        $finish = trigger('response', $responsePayload);

        $payload = $finish($responsePayload);

        // When wire:stream is used, headers are sent early by SupportStreaming::ensureStreamResponseStarted().
        // The streaming content has already been output via echo/flush in SupportStreaming::streamContent().
        // This final JSON response contains the component snapshot and must be output without attempting
        // to send additional headers, which would cause "headers already sent" warnings (since Symfony 7.2.7).
        if (headers_sent()) {
            $response = new StreamedResponse(
                json_encode($payload),
                200,
                ['Content-Type' => 'application/json']
            );

            // Headers won't be sent due to override, but are documented on the object
            return $response;
        }

        return $payload;
    }
}
