<?php

namespace Livewire\Drawer;

use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
use BackedEnum;
use ReflectionClass;
use ReflectionMethod;
use Livewire\Component;
use Illuminate\Support\Reflector;
use Illuminate\Support\Collection;
use Illuminate\Routing\Route;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\Routing\UrlRoutable;

/**
 * This class mirrors the functionality of Laravel's Illuminate\Routing\ImplicitRouteBinding class.
 */
class ImplicitRouteBinding
{
    protected $container;

    public function __construct($container)
    {
        $this->container = $container;
    }

    public function resolveAllParameters(Route $route, Component $component)
    {
        // Resolve every bindable route parameter in URI order first (like Laravel's
        // own implicit binding) so that scoped child bindings always see their
        // parent as an already-resolved model, regardless of whether the parent
        // is a public property or a mount() parameter...
        $this->resolveRouteParametersInOrder($route, $component);

        $params = $this->resolveMountParameters($route, $component);
        $props = $this->resolveComponentProps($route, $component);

        return $params->merge($props)->all();
    }

    protected function resolveRouteParametersInOrder(Route $route, Component $component)
    {
        $types = $this->getPublicPropertyTypes($component)
            ->merge($this->getMountParameterTypes($component)->filter())
            ->filter(function ($className) {
                return $className && (is_subclass_of($className, UrlRoutable::class) || is_subclass_of($className, BackedEnum::class));
            });

        foreach ($route->parametersWithoutNulls() as $name => $value) {
            if (! $types->has($name)) continue;

            $route->setParameter($name, $this->resolveParameter($route, $name, $types->get($name)));
        }
    }

    protected function getMountParameterTypes(Component $component)
    {
        if (! method_exists($component, 'mount')) {
            return new Collection();
        }

        return collect((new ReflectionMethod($component, 'mount'))->getParameters())
            ->mapWithKeys(function ($parameter) {
                return [$parameter->getName() => Reflector::getParameterClassName($parameter)];
            });
    }

    public function resolveMountParameters(Route $route, Component $component)
    {
        if (! method_exists($component, 'mount')) {
            return new Collection();
        }

        // Cache the current route action (this callback actually), just to be safe.
        $cache = $route->getAction();

        // We'll set the route action to be the "mount" method from the chosen
        // Livewire component, to get the proper implicit bindings.
        $route->uses(get_class($component).'@mount');

        try {
            // This is normally handled in the "SubstituteBindings" middleware, but
            // because that middleware has already ran, we need to run them again.
            $this->container['router']->substituteImplicitBindings($route);

            $mountMethod = new ReflectionMethod($component, 'mount');

            // Only pass route parameters that match the mount method's signature.
            // Without this, unmatched route parameters would be classified as
            // HTML attributes and bleed into child Blade component constructors.
            $mountParamNames = array_map(fn ($p) => $p->getName(), $mountMethod->getParameters());
            $routeParams = array_intersect_key($route->parameters(), array_flip($mountParamNames));

            $parameters = $route->resolveMethodDependencies($routeParams, $mountMethod);

            // Restore the original route action...
            $route->setAction($cache);
        } catch(\Exception $e) {
            // Restore the original route action before an exception is thrown...
            $route->setAction($cache);

            throw $e;
        }

        return new Collection($parameters);
    }

    public function resolveComponentProps(Route $route, Component $component)
    {
        return $this->getPublicPropertyTypes($component)
            ->intersectByKeys($route->parametersWithoutNulls())
            ->sortBy(function ($className, $propName) use ($route) {
                return array_search($propName, array_keys($route->parametersWithoutNulls()));
            })
            ->map(function ($className, $propName) use ($route) {
                // If typed public property, resolve the class
                if ($className) {
                    $resolved = $this->resolveParameter($route, $propName, $className);

                    // We'll also pass the resolved model back to the route
                    // so that it can be used for any depending on bindings
                    $route->setParameter($propName, $resolved);

                    return $resolved;
                }

                // Otherwise, just return the route parameter
                return $route->parameter($propName);
            });
    }

    public function getPublicPropertyTypes($component)
    {
        return collect(Utils::getPublicPropertiesDefinedOnSubclass($component))
            ->map(function ($value, $name) use ($component) {
                return Reflector::getParameterClassName(new \ReflectionProperty($component, $name));
            });
    }

    protected function resolveParameter($route, $parameterName, $parameterClassName)
    {
        $parameterValue = $route->parameter($parameterName);

        if ($parameterValue instanceof UrlRoutable) {
            return $parameterValue;
        }

        if($enumValue = $this->resolveEnumParameter($parameterValue, $parameterClassName)) {
            return $enumValue;
        }

        $instance = $this->container->make($parameterClassName);

        $parent = $route->parentOfParameter($parameterName);

        if ($parent instanceof UrlRoutable && ! $route->preventsScopedBindings() && ($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) {
            $model = $parent->resolveChildRouteBinding($parameterName, $parameterValue, $route->bindingFieldFor($parameterName));
        } else {
            if ($route->allowsTrashedBindings()) {
                $model = $instance->resolveSoftDeletableRouteBinding($parameterValue, $route->bindingFieldFor($parameterName));
            } else {
                $model = $instance->resolveRouteBinding($parameterValue, $route->bindingFieldFor($parameterName));
            }
        }

        if (! $model) {
            throw (new ModelNotFoundException())->setModel(get_class($instance), [$parameterValue]);
        }

        return $model;
    }

    protected function resolveEnumParameter($parameterValue, $parameterClassName)
    {
        if ($parameterValue instanceof BackedEnum) {
            return $parameterValue;
        }

        if ((new ReflectionClass($parameterClassName))->isEnum()) {
            $enumValue = $parameterClassName::tryFrom($parameterValue);

            if (is_null($enumValue)) {
                throw new BackedEnumCaseNotFoundException($parameterClassName, $parameterValue);
            }

            return $enumValue;
        }

        return null;
    }
}
