<?php
/**
 * @package   Essentials YOOtheme Pro 2.2.16 build 0813.1608
 * @author    ZOOlanders https://www.zoolanders.com
 * @copyright Copyright (C) Joolanders, SL
 * @license   http://www.gnu.org/licenses/gpl.html GNU/GPL
 */

namespace ZOOlanders\YOOessentials\Builder;

use YOOtheme\Arr;
use YOOtheme\Builder\Source\SourceQuery;
use YOOtheme\Builder\Source\SourceTransform;
use YOOtheme\Event;
use ZOOlanders\YOOessentials\Config;

class SourceResolver
{
    public const PARENT = '#parent';
    public const CLOSEST = '~closest';

    protected SourceTransform $sourceTransform;
    protected Config $config;

    public function __construct(Config $config, SourceTransform $sourceTransform)
    {
        $this->sourceTransform = $sourceTransform;
        $this->config = $config;
    }

    public function resolveProps(object $node, array $params = []): object
    {
        $data = $this->_resolveProps((array) ($node->source->props ?? []), $node, $params);

        foreach ($data as $name => $value) {
            $node->props[$name] = $value;
        }

        $data = $this->_resolveProps((array) ($node->source_extended->props ?? []), $node, $params);

        foreach ($data as $name => $value) {
            $node->props[$name] = $value;
        }

        return $node;
    }

    public function resolveComposable(object $prop, object $node, array $params)
    {
        $value = $prop->composed->value ?? null;

        if (!$value) {
            return;
        }

        return $this->resolveComposedSources($prop, $node, $params, $value);
    }

    protected function _resolveProps(array $props, object $node, array $params = []): array
    {
        $result = [];

        foreach ($props as $name => $prop) {
            $prop = (object) $prop;

            if (self::isComposed($prop)) {
                $result[$name] = $this->resolveComposable($prop, $node, $params);

                continue;
            }

            $data = $this->resolveFieldSource($name, $prop, $node, $params);

            if (!is_array($data)) {
                continue;
            }

            $isMulti = isset($data[0]);

            if (self::isAdjacent($node, $prop) && $isMulti) {
                // data must be reconstructed from the transient node
                $data = $data[$params['i'] - 1] ?? $data[0];
            }

            // group the results when multi
            if ($isMulti) {
                $data = [
                    $prop->name => array_map(function ($val) use ($prop) {
                        return Arr::get($val, $prop->name);
                    }, $data),
                ];
            }

            $result[$name] = $this->getPropsValue($prop, ['data' => $data] + $params);
        }

        return $result;
    }

    protected function resolveFieldSource(string $name, object $prop, object $node, array $params): ?array
    {
        if (!isset($prop->query)) {
            return $params['data'] ?? null;
        }

        if (self::isClosest($prop)) {
            $closest = self::findClosestInheritableNode($node, $params);

            return $closest->source->data ?? $params['data'] ?? null;
        }

        // create fake node to resolve single prop
        $clone = new \stdClass;
        $clone->type = 'text';
        $clone->source = (object) [
            'props' => [
                $name => $prop,
            ],
            'query' => (object) $prop->query,
        ];

        return $this->resolveSource($clone, $params);
    }

    protected function resolveSource(object $node, array $params = []): ?array
    {
        if (!($result = $this->querySource($node, $params))) {
            return null;
        }

        $name = 'data';

        // add query name
        if ($node->source->query->name !== SourceQuery::PARENT) {
            $name .= ".{$node->source->query->name}";
        }

        // add field name
        if (isset($node->source->query->field)) {
            $name .= ".{$node->source->query->field->name}";
        }

        // get source data
        $data = Arr::get($result, $name);

        if ($data === null) {
            return null;
        }

        return (array) $data;
    }

    public function resolveNodeAdjacent(object $adjacent, object $node, array $params = []): object
    {
        $node = $this->resolveTransient($node, $params);

        if (!isset($adjacent->source)) {
            $adjacent->source = new \StdClass();
        }

        if (isset($node->source->query)) {
            $adjacent->source->query = $node->source->query;
        }

        if (isset($node->source->data)) {
            $adjacent->source->data = $node->source->data;
        }

        return $this->resolveProps($adjacent, $params);
    }

    protected function resolveTransient(object $node, array $params): object
    {
        if (!isset($node->transient, $params['parent']->children[0])) {
            return $node;
        }

        $node = clone $params['parent']->children[0];
        $node->transient = true;

        return $node;
    }

    protected function querySource(object $node, array $params): ?array
    {
        // allows injecting query results
        if ($result = Event::emit('yooessentials.source.query', $node, $params)) {
            return $result;
        }

        return $this->sourceTransform->querySource($node, $params);
    }

    protected function mapPropsSource(string $name, object $prop, object &$node, array $params)
    {
        $value = $this->getPropsValue($prop, $params);

        $node->props[$name] = $value;
    }

    public static function resolvePropsQueries(array $props, object $node, array $params)
    {
        foreach ($props as $prop) {
            $prop = (object) $prop;

            if (self::isComposed($prop)) {
                self::resolvePropsQueries((array) ($prop->composed->sources ?? []), $node, $params);

                continue;
            }

            if (self::isClosest($prop)) {
                $closest = self::findClosestInheritableNode($node, $params);

                if ($closest) {
                    self::addChildQuery($closest, $prop);
                }

                continue;
            }

            if (!isset($prop->name)) {
                continue;
            }

            if (isset($prop->query->arguments)) {
                foreach ((array) ($prop->query->arguments ?? []) as $arg) {
                    if (!is_array($arg)) {
                        continue;
                    }

                    foreach ($arg as $val) {
                        self::resolvePropsQueries((array) ($val->source_extended->props ?? []), $node, $params);
                    }
                }
            }

            if (self::isAdjacent($node, $prop)) {
                self::addChildQuery($node, $prop);
            }
        }
    }

    public static function addChildQuery(object $node, object $prop)
    {
        $node->source->children[] = json_decode(
            json_encode([
                'source' => [
                    'query' => [
                        'name' => '#parent',
                    ],
                    'props' => [
                        'content' => $prop,
                    ],
                ],
            ])
        );
    }

    public static function isClosest(object $prop): bool
    {
        return ($prop->query->name ?? '') === self::CLOSEST;
    }

    public static function isAdjacent(object $node, object $prop): bool
    {
        return !self::isComposed($prop) && !isset($prop->query) && isset($node->source->query->name);
    }

    public static function isComposed(object $prop): bool
    {
        return isset($prop->composed);
    }

    public static function findClosestInheritableNode(object $node, array $params): ?object
    {
        foreach ($params['path'] ?? [] as $ancestor) {
            if ($ancestor !== $node && (isset($ancestor->source->query->name) || isset($ancestor->source->data))) {
                return $ancestor;
            }
        }

        return null;
    }

    protected function applyFilters(string $value, array $filters, array $params): ?string
    {
        foreach (array_intersect_key($this->sourceTransform->filters, $filters) as $key => $filter) {
            $value = $filter($value, $filters[$key], $filters, $params);
        }

        return $value;
    }

    protected static function toString($str)
    {
        if (is_scalar($str) || is_callable([$str, '__toString'])) {
            return trim((string) $str);
        }

        return '';
    }

    /**
     * @param object $prop
     * @param array $params
     * @return array|\ArrayAccess|string|null
     */
    protected function getPropsValue(object $prop, array $params)
    {
        $value = Arr::get($params['data'], $prop->name);
        $isMulti = is_array($value);
        $implode = $prop->implode->join ?? null;

        if ($isMulti && $implode === 'before') {
            $value = implode($prop->implode->glue ?? '', $value);
            $isMulti = false;
        }

        // apply filters
        $filters = isset($prop->filters) ? (array) $prop->filters : [];

        if ($isMulti) {
            foreach ($value as $i => $val) {
                $value[$i] = $this->applyFilters(self::toString($val), $filters, $params);
            }
        } else {
            $value = $this->applyFilters(self::toString($value), $filters, $params);
        }

        if ($isMulti && $implode === 'after') {
            $value = implode($prop->implode->glue ?? '', $value);
        }

        return $value;
    }

    private function resolveComposedSources(object $prop, object $node, array $params, string $value)
    {
        $sources = (array) ($prop->composed->sources ?? []);

        foreach ($sources as $id => $source) {
            $source = (object) $source;

            if (empty($source) || !isset($source->name)) {
                continue;
            }

            try {
                $data = $this->resolveFieldSource($id, $source, $node, $params);
                $isMulti = isset($data[0]);

                // group the results when multi
                if ($isMulti) {
                    $data = [
                        $source->name => array_map(function ($val) use ($source) {
                            return Arr::get($val, $source->name);
                        }, $data),
                    ];
                }

                $propValue = $this->getPropsValue($source, ['data' => $data] + $params) ?? null;

                if (!is_array($data) || $propValue === null) {
                    continue;
                }

                if (!is_string($propValue)) {
                    throw new \RuntimeException('Resolved data is not a string but: ' . gettype($propValue));
                }
            } catch (\Exception $e) {
                Event::emit('yooessentials.error', [
                    'addon' => 'dynamic',
                    'task' => 'resolve-composable',
                    'source-id' => $id,
                    'node' => Arr::pick((array) $node, ['source', 'source_extended']),
                    'error' => $e->getMessage()
                ]);

                continue;
            }

            $value = str_replace("{{ sources.{$id} }}", $propValue, $value);
        }

        // remove unresolved sources
        if (!$this->config->get('app.isCustomizer')) {
            $value = preg_replace('/\{\{ sources\.(\w{4}) \}\}/', '', $value);
        }

        return $value;
    }
}
