<?php

namespace Livewire\Mechanisms\HandleComponents;

use function Livewire\{ invade, store, trigger, wrap };
use ReflectionUnionType;
use Livewire\Mechanisms\Mechanism;
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Livewire\Exceptions\MethodNotFoundException;
use Livewire\Drawer\Utils;
use Illuminate\Support\Facades\View;

class HandleComponents extends Mechanism
{
    protected $propertySynthesizers = [
        Synthesizers\CarbonSynth::class,
        Synthesizers\CollectionSynth::class,
        Synthesizers\StringableSynth::class,
        Synthesizers\EnumSynth::class,
        Synthesizers\StdClassSynth::class,
        Synthesizers\ArraySynth::class,
        Synthesizers\IntSynth::class,
        Synthesizers\FloatSynth::class
    ];

    public static $renderStack = [];
    public static $componentStack = [];

    public function registerPropertySynthesizer($synth)
    {
        foreach ((array) $synth as $class) {
            array_unshift($this->propertySynthesizers, $class);
        }
    }

    public function mount($name, $params = [], $key = null)
    {
        $parent = app('livewire')->current();

        if ($html = $this->shortCircuitMount($name, $params, $key, $parent)) return $html;

        $component = app('livewire')->new($name);

        $this->pushOntoComponentStack($component);

        $context = new ComponentContext($component, mounting: true);

        if (config('app.debug')) $start = microtime(true);
        $finish = trigger('mount', $component, $params, $key, $parent);
        if (config('app.debug')) trigger('profile', 'mount', $component->getId(), [$start, microtime(true)]);

        if (config('app.debug')) $start = microtime(true);
        $html = $this->render($component, '<div></div>');
        if (config('app.debug')) trigger('profile', 'render', $component->getId(), [$start, microtime(true)]);

        if (config('app.debug')) $start = microtime(true);
        trigger('dehydrate', $component, $context);

        $snapshot = $this->snapshot($component, $context);
        if (config('app.debug')) trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]);

        trigger('destroy', $component, $context);

        $html = Utils::insertAttributesIntoHtmlRoot($html, [
            'wire:snapshot' => $snapshot,
            'wire:effects' => $context->effects,
        ]);

        $this->popOffComponentStack();

        return $finish($html, $snapshot);
    }

    protected function shortCircuitMount($name, $params, $key, $parent)
    {
        $newHtml = null;

        trigger('pre-mount', $name, $params, $key, $parent, function ($html) use (&$newHtml) {
            $newHtml = $html;
        });

        return $newHtml;
    }

    public function update($snapshot, $updates, $calls)
    {
        $data = $snapshot['data'];
        $memo = $snapshot['memo'];

        if (config('app.debug')) $start = microtime(true);
        [ $component, $context ] = $this->fromSnapshot($snapshot);

        $this->pushOntoComponentStack($component);

        trigger('hydrate', $component, $memo, $context);

        $this->updateProperties($component, $updates, $data, $context);
        if (config('app.debug')) trigger('profile', 'hydrate', $component->getId(), [$start, microtime(true)]);

        $this->callMethods($component, $calls, $context);

        if (config('app.debug')) $start = microtime(true);
        if ($html = $this->render($component)) {
            $context->addEffect('html', $html);
            if (config('app.debug')) trigger('profile', 'render', $component->getId(), [$start, microtime(true)]);
        }

        if (config('app.debug')) $start = microtime(true);
        trigger('dehydrate', $component, $context);

        $snapshot = $this->snapshot($component, $context);
        if (config('app.debug')) trigger('profile', 'dehydrate', $component->getId(), [$start, microtime(true)]);

        trigger('destroy', $component, $context);

        $this->popOffComponentStack();

        return [ $snapshot, $context->effects ];
    }

    public function fromSnapshot($snapshot)
    {
        Checksum::verify($snapshot);

        trigger('snapshot-verified', $snapshot);

        $data = $snapshot['data'];
        $name = $snapshot['memo']['name'];
        $id   = $snapshot['memo']['id'];

        $component = app('livewire')->new($name, id: $id);

        $context = new ComponentContext($component);

        $this->hydrateProperties($component, $data, $context);

        return [ $component, $context ];
    }

    public function snapshot($component, $context = null)
    {
        $context ??= new ComponentContext($component);

        $data = $this->dehydrateProperties($component, $context);

        $snapshot = [
            'data' => $data,
            'memo' => [
                'id' => $component->getId(),
                'name' => $component->getName(),
                ...$context->memo,
            ],
        ];

        $snapshot['checksum'] = Checksum::generate($snapshot);

        return $snapshot;
    }

    protected function dehydrateProperties($component, $context)
    {
        $data = Utils::getPublicPropertiesDefinedOnSubclass($component);

        foreach ($data as $key => $value) {
            $data[$key] = $this->dehydrate($value, $context, $key);
        }

        return $data;
    }

    protected function dehydrate($target, $context, $path)
    {
        if (Utils::isAPrimitive($target)) return $target;

        $synth = $this->propertySynth($target, $context, $path);

        [ $data, $meta ] = $synth->dehydrate($target, function ($name, $child) use ($context, $path) {
            return $this->dehydrate($child, $context, "{$path}.{$name}");
        });

        $meta['s'] = $synth::getKey();

        return [ $data, $meta ];
    }

    protected function hydrateProperties($component, $data, $context)
    {
        foreach ($data as $key => $value) {
            if (! property_exists($component, $key)) continue;

            $child = $this->hydrate($value, $context, $key);

            // Typed properties shouldn't be set back to "null". It will throw an error...
            if ((new \ReflectionProperty($component, $key))->getType() && is_null($child)) continue;

            $component->$key = $child;
        }
    }

    protected function hydrate($valueOrTuple, $context, $path)
    {
        if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value;

        [$value, $meta] = $tuple;

        // Nested properties get set as `__rm__` when they are removed. We don't want to hydrate these.
        if ($this->isRemoval($value) && str($path)->contains('.')) {
            return $value;
        }

        $synth = $this->propertySynth($meta['s'], $context, $path);

        return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path) {
            return $this->hydrate($child, $context, "{$path}.{$name}");
        });
    }

    protected function render($component, $default = null)
    {
        if ($html = store($component)->get('skipRender', false)) {
            $html = value(is_string($html) ? $html : $default);

            if (! $html) return;

            return Utils::insertAttributesIntoHtmlRoot($html, [
                'wire:id' => $component->getId(),
            ]);
        }

        [ $view, $properties ] = $this->getView($component);

        return $this->trackInRenderStack($component, function () use ($component, $view, $properties) {
            $finish = trigger('render', $component, $view, $properties);

            $revertA = Utils::shareWithViews('__livewire', $component);
            $revertB = Utils::shareWithViews('_instance', $component); // @deprecated

            $viewContext = new ViewContext;

            $html = $view->render(function ($view) use ($viewContext) {
                // Extract leftover slots, sections, and pushes before they get flushed...
                $viewContext->extractFromEnvironment($view->getFactory());
            });

            $revertA(); $revertB();

            $html = Utils::insertAttributesIntoHtmlRoot($html, [
                'wire:id' => $component->getId(),
            ]);

            $replaceHtml = function ($newHtml) use (&$html) {
                $html = $newHtml;
            };

            $html = $finish($html, $replaceHtml, $viewContext);

            return $html;
        });
    }

    protected function getView($component)
    {
        $viewPath = config('livewire.view_path', resource_path('views/livewire'));

        $dotName = $component->getName();

        $fileName = str($dotName)->replace('.', '/')->__toString();

        $viewOrString = method_exists($component, 'render')
            ? wrap($component)->render()
            : View::file($viewPath . '/' . $fileName . '.blade.php');

        $properties = Utils::getPublicPropertiesDefinedOnSubclass($component);

        $view = Utils::generateBladeView($viewOrString, $properties);

        return [ $view, $properties ];
    }

    protected function trackInRenderStack($component, $callback)
    {
        array_push(static::$renderStack, $component);

        return tap($callback(), function () {
            array_pop(static::$renderStack);
        });
    }

    protected function updateProperties($component, $updates, $data, $context)
    {
        $finishes = [];

        foreach ($updates as $path => $value) {
            $value = $this->hydrateForUpdate($data, $path, $value, $context);

            // We only want to run "updated" hooks after all properties have
            // been updated so that each individual hook has the ability
            // to overwrite the updated states of other properties...
            $finishes[] = $this->updateProperty($component, $path, $value, $context);
        }

        foreach ($finishes as $finish) {
            $finish();
        }
    }

    public function updateProperty($component, $path, $value, $context)
    {
        $segments = explode('.', $path);

        $property = array_shift($segments);

        $finish = trigger('update', $component, $path, $value);

        // Ensure that it's a public property, not on the base class first...
        if (! in_array($property, array_keys(Utils::getPublicPropertiesDefinedOnSubclass($component)))) {
            throw new PublicPropertyNotFoundException($property, $component->getName());
        }

        // If this isn't a "deep" set, set it directly, otherwise we have to
        // recursively get up and set down the value through the synths...
        if (empty($segments)) {
            $this->setComponentPropertyAwareOfTypes($component, $property, $value);
        } else {
            $propertyValue = $component->$property;

            $this->setComponentPropertyAwareOfTypes($component, $property,
                $this->recursivelySetValue($property, $propertyValue, $value, $segments, 0, $context)
            );
        }

        return $finish;
    }

    protected function hydrateForUpdate($raw, $path, $value, $context)
    {
        $meta = $this->getMetaForPath($raw, $path);

        // If we have meta data already for this property, let's use that to get a synth...
        if ($meta) {
            return $this->hydrate([$value, $meta], $context, $path);
        }

        // If we don't, let's check to see if it's a typed property and fetch the synth that way...
        $parent = str($path)->contains('.')
            ? data_get($context->component, str($path)->beforeLast('.')->toString())
            : $context->component;

        $childKey = str($path)->afterLast('.');

        if ($parent && is_object($parent) && property_exists($parent, $childKey) && Utils::propertyIsTyped($parent, $childKey)) {
            $type = Utils::getProperty($parent, $childKey)->getType();

            $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];

            foreach ($types as $type) {
                $synth = $this->getSynthesizerByType($type->getName(), $context, $path);

                if ($synth) return $synth->hydrateFromType($type->getName(), $value);
            }
        }

        return $value;
    }

    protected function getMetaForPath($raw, $path)
    {
        $segments = explode('.', $path);

        $first = array_shift($segments);

        [$data, $meta] = Utils::isSyntheticTuple($raw) ? $raw : [$raw, null];

        if ($path !== '') {
            $value = $data[$first] ?? null;

            return $this->getMetaForPath($value, implode('.', $segments));
        }

        return $meta;
    }

    protected function recursivelySetValue($baseProperty, $target, $leafValue, $segments, $index = 0, $context = null)
    {
        $isLastSegment = count($segments) === $index + 1;

        $property = $segments[$index];

        $path = implode('.', array_slice($segments, 0, $index + 1));

        $synth = $this->propertySynth($target, $context, $path);

        if ($isLastSegment) {
            $toSet = $leafValue;
        } else {
            $propertyTarget = $synth->get($target, $property);

            // "$path" is a dot-notated key. This means we may need to drill
            // down and set a value on a deeply nested object. That object
            // may not exist, so let's find the first one that does...

            // Here's we've determined we're trying to set a deeply nested
            // value on an object/array that doesn't exist, so we need
            // to build up that non-existant nesting structure first.
            if ($propertyTarget === null) $propertyTarget = [];

            $toSet = $this->recursivelySetValue($baseProperty, $propertyTarget, $leafValue, $segments, $index + 1, $context);
        }

        $method = ($this->isRemoval($leafValue) && $isLastSegment) ? 'unset' : 'set';

        $pathThusFar = collect([$baseProperty, ...$segments])->slice(0, $index + 1)->join('.');
        $fullPath = collect([$baseProperty, ...$segments])->join('.');

        $synth->$method($target, $property, $toSet, $pathThusFar, $fullPath);

        return $target;
    }

    protected function setComponentPropertyAwareOfTypes($component, $property, $value)
    {
        try {
           $component->$property = $value;
        } catch (\TypeError $e) {
            // If an "int" is being set to empty string, unset the property (making it null).
            // This is common in the case of `wire:model`ing an int to a text field...
            // If a value is being set to "null", do the same...
            if ($value === '' || $value === null) {
                unset($component->$property);
            } else {
                throw $e;
            }
        }
    }

    protected function callMethods($root, $calls, $context)
    {
        $returns = [];

        foreach ($calls as $idx => $call) {
            $method = $call['method'];
            $params = $call['params'];


            $earlyReturnCalled = false;
            $earlyReturn = null;
            $returnEarly = function ($return = null) use (&$earlyReturnCalled, &$earlyReturn) {
                $earlyReturnCalled = true;
                $earlyReturn = $return;
            };

            $finish = trigger('call', $root, $method, $params, $context, $returnEarly);

            if ($earlyReturnCalled) {
                $returns[] = $finish($earlyReturn);

                continue;
            }

            $methods = Utils::getPublicMethodsDefinedBySubClass($root);

            // Also remove "render" from the list...
            $methods =  array_values(array_diff($methods, ['render']));

            // @todo: put this in a better place:
            $methods[] = '__dispatch';

            if (! in_array($method, $methods)) {
                throw new MethodNotFoundException($method);
            }

            if (config('app.debug')) $start = microtime(true);
            $return = wrap($root)->{$method}(...$params);
            if (config('app.debug')) trigger('profile', 'call'.$idx, $root->getId(), [$start, microtime(true)]);

            $returns[] = $finish($return);
        }

        $context->addEffect('returns', $returns);
    }

    protected function propertySynth($keyOrTarget, $context, $path): Synth
    {
        return is_string($keyOrTarget)
            ? $this->getSynthesizerByKey($keyOrTarget, $context, $path)
            : $this->getSynthesizerByTarget($keyOrTarget, $context, $path);
    }

    protected function getSynthesizerByKey($key, $context, $path)
    {
        foreach ($this->propertySynthesizers as $synth) {
            if ($synth::getKey() === $key) {
                return new $synth($context, $path);
            }
        }

        throw new \Exception('No synthesizer found for key: "'.$key.'"');
    }

    protected function getSynthesizerByTarget($target, $context, $path)
    {
        foreach ($this->propertySynthesizers as $synth) {
            if ($synth::match($target)) {
                return new $synth($context, $path);
            }
        }

        throw new \Exception('Property type not supported in Livewire for property: ['.json_encode($target).']');
    }

    protected function getSynthesizerByType($type, $context, $path)
    {
        foreach ($this->propertySynthesizers as $synth) {
            if ($synth::matchByType($type)) {
                return new $synth($context, $path);
            }
        }

        return null;
    }

    protected function pushOntoComponentStack($component)
    {
        array_push($this::$componentStack, $component);
    }

    protected function popOffComponentStack()
    {
        array_pop($this::$componentStack);
    }

    protected function isRemoval($value) {
        return $value === '__rm__';
    }
}
