<?php

namespace PublishPress\Future\Modules\Workflows\Domain\Engine;

use PublishPress\Future\Core\HookableInterface;
use PublishPress\Future\Framework\Logger\LoggerInterface;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\ArrayResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\BooleanResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\DatetimeResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\EmailResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\FutureActionResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\IntegerResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\NodeResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\PostResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\PostTermsResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\SiteResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\UserResolver;
use PublishPress\Future\Modules\Workflows\Domain\Engine\VariableResolvers\WorkflowResolver;
use PublishPress\Future\Modules\Workflows\HooksAbstract;
use PublishPress\Future\Modules\Workflows\Interfaces\ExecutionContextInterface;
use PublishPress\Future\Modules\Workflows\Interfaces\ExecutionContextProcessorRegistryInterface;
use PublishPress\Future\Modules\Workflows\Interfaces\VariableResolverInterface;
use PublishPress\Future\Modules\Workflows\Models\WorkflowModel;

use function wp_json_encode;

class ExecutionContext implements ExecutionContextInterface
{
    private const LOG_PREFIX = '[WorkflowExecutionContext#%d]: ';

    /**
     * @var HookableInterface
     */
    private $hooks;

    /**
     * @var ExecutionContextProcessorRegistryInterface
     */
    private $processorRegistry;

    /**
     * @var array
     */
    private $runtimeVariables = [];

    /**
     * @var array
     */
    private $executionTrace = [];

    /**
     * @var \Closure
     */
    private $expirablePostModelFactory;

    /**
     * Cache for nested variable values (e.g., "post1.meta.title").
     *
     * @var array
     */
    private $nestedVariablesCache = [];

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var int
     */
    private $workflowId = 0;

    /**
     * @var bool
     */
    private $nestedVariablesCacheEnabled;

    public function __construct(
        HookableInterface $hooks,
        ExecutionContextProcessorRegistryInterface $processorRegistry,
        \Closure $expirablePostModelFactory,
        LoggerInterface $logger
    ) {
        $this->hooks = $hooks;
        $this->processorRegistry = $processorRegistry;
        $this->expirablePostModelFactory = $expirablePostModelFactory;
        $this->logger = $logger;

        // We are using a flag for the nested variables cache because it is not fully tested.
        $this->nestedVariablesCacheEnabled = defined('PUBLISHPRESS_FUTURE_VARIABLES_CACHE')
            && constant('PUBLISHPRESS_FUTURE_VARIABLES_CACHE');
    }

    public function setAllVariables(array $runtimeVariables)
    {
        $this->clearNestedVariablesCache();
        $this->runtimeVariables = $runtimeVariables;
    }

    public function getAllVariables(): array
    {
        return $this->runtimeVariables;
    }

    public function getVariable(string $variableName)
    {
        return $this->getVariableValueFromNestedVariable($variableName, $this->runtimeVariables);
    }

    public function setVariable(string $variableName, $variableValue)
    {
        if ($this->isNestedVariable($variableName)) {
            $this->setVariableInNestedArray($variableName, $variableValue, $this->runtimeVariables);
        } else {
            $this->runtimeVariables[$variableName] = $variableValue;
        }
    }

    /**
     * @since 4.3.4
     */
    public function extractExpressionsFromText(string $text): array
    {
        $expressions = [];
        preg_match_all('/{{(.*?)}}/', $text, $expressions);

        return $expressions[1];
    }

    /**
     * @since 4.3.4
     */
    public function resolveExpressionsInText(string $text): string
    {
        $expressions = $this->extractExpressionsFromText($text);

        foreach ($expressions as $expression) {
            // Trim the expression to handle any whitespace issues
            $expression = trim($expression);

            if ($expressionElements = $this->parseHelperFromVariable($expression)) {
                $this->debugWithArgs(
                    'Parsing helper "%s" from variable "%s"',
                    $expressionElements['helper'],
                    $expressionElements['variable']
                );

                $value = $this->getVariable($expressionElements['variable']);
                $value = $this->processorRegistry->process($expressionElements['helper'], $value, $expressionElements['args']);
            } else {
                $value = $this->getVariable($expression);
            }

            // Convert null to empty string for text replacement
            if ($value === null) {
                $value = '';
            }

            if (is_array($value) || is_object($value)) {
                $value = wp_json_encode($value);
            }

            // Ensure value is a string for str_replace
            $value = (string)$value;

            // Use preg_replace for more reliable replacement, escaping special regex characters
            $pattern = '/\{\{' . preg_quote($expression, '/') . '\}\}/';
            $text = preg_replace($pattern, $value, $text);
        }

        return $text;
    }

    /**
     * @since 4.3.4
     */
    public function resolveExpressionsInArray(array $array): array
    {
        if (empty($array)) {
            $this->debugWithArgs(
                'Empty array passed to resolveExpressionsInArray',
                $array
            );

            return $array;
        }

        return array_map(fn ($item) => $this->resolveExpressionsInText($item), $array);
    }

    /**
     * @deprecated 4.3.4 Use extractExpressionsFromText instead.
     */
    public function extractPlaceholdersFromText($text)
    {
        return $this->extractExpressionsFromText($text);
    }

    /**
     * @deprecated 4.3.4 Use extractExpressionsFromText instead.
     */
    public function replacePlaceholdersInText($text)
    {
        return $this->resolveExpressionsInText($text);
    }

    /**
     * Recursively replaces variables in JSON Logic expressions with their values.
     *
     * @since 4.3.4
     */
    public function resolveExpressionsInJsonLogic(array $jsonLogicExpression): array
    {
        $newExpression = [];

        foreach ($jsonLogicExpression as $key => $value) {
            // If it's a {"var": "..."} node, try to resolve it to a real value
            if (is_array($value) && array_key_exists('var', $value)) {
                $originalVar = '{{' . trim($value['var'], '{}') . '}}';

                $resolvedText = $this->resolveExpressionsInText($originalVar);

                // Convert the resolved text back to appropriate type
                // If it's JSON-encoded (arrays/objects), decode it
                if (is_string($resolvedText) && (strpos($resolvedText, '[') === 0 || strpos($resolvedText, '{') === 0)) {
                    $decoded = json_decode($resolvedText, true);
                    if (json_last_error() === JSON_ERROR_NONE) {
                        $resolved = $decoded;
                    } else {
                        $resolved = $resolvedText;
                    }
                } elseif (is_numeric($resolvedText)) {
                    // Preserve numeric types (e.g., timestamps from date helper with output="U")
                    $resolved = strpos($resolvedText, '.') !== false ? (float)$resolvedText : (int)$resolvedText;
                } else {
                    $resolved = $resolvedText;
                }

                // Handle empty string as null for isEmpty/isNotEmpty operations
                if ($resolved === '') {
                    $resolved = null;
                }

                if ($resolved instanceof VariableResolverInterface) {
                    $resolved = $resolved->getValue();
                }

                $newExpression[$key] = $resolved;
                continue;
            }

            if (is_array($value)) {
                $newExpression[$key] = $this->resolveExpressionsInJsonLogic($value);
                continue;
            }

            if (is_string($value) && strpos($value, '{{') !== false) {
                $newExpression[$key] = $this->resolveExpressionsInText($value);
                continue;
            }

            $newExpression[$key] = $value;
        }

        return $newExpression;
    }

    /**
     * Parse the helper from the variable name
     *
     * @param string $variableName
     * @return array|false
     * @since 4.9.4
     */
    private function parseHelperFromVariable(string $variableName)
    {
        $variableName = trim($variableName, '{}');

        $helperRegex = '/^([a-zA-Z0-9_]+)\s+([a-zA-Z0-9_\.]+)\s*(.*)$/';

        $matches = [];
        preg_match($helperRegex, $variableName, $matches);

        if (empty($matches)) {
            return false;
        }

        $arguments = [];

        // Extract arguments if they exist in the third capture group
        if (!empty($matches[3])) {
            $argsString = trim($matches[3]);

            // Regex to match key-value pairs like key="value" or key='value' or key=value
            $argsRegex = '/([a-zA-Z0-9_\-]+)\s*=\s*(?:(?:"([^"]*)")|(?:\'([^\']*)\')|([^\s]+))/';

            $argMatches = [];
            preg_match_all($argsRegex, $argsString, $argMatches, PREG_SET_ORDER);

            foreach ($argMatches as $argMatch) {
                $key = $argMatch[1];
                // Get the value from whichever capturing group matched (double quotes, single quotes, or no quotes)
                $value = $argMatch[2] ?? $argMatch[3] ?? $argMatch[4] ?? '';
                $arguments[$key] = $value;
            }
        }

        return [
            'helper' => $matches[1],
            'variable' => $matches[2],
            'args' => $arguments,
        ];
    }

    private function getVariableValue(string $variableName, $dataSource)
    {
        if (is_array($dataSource) && isset($dataSource[$variableName])) {
            if (
                is_object($dataSource[$variableName]) &&
                $dataSource[$variableName] instanceof VariableResolverInterface
            ) {
                return $dataSource[$variableName]->getValue();
            }

            return $dataSource[$variableName];
        } elseif (is_object($dataSource) && $dataSource instanceof VariableResolverInterface) {
            return $dataSource->getValue($variableName);
        } elseif (is_object($dataSource) && isset($dataSource->{$variableName})) {
            return $dataSource->{$variableName};
        }

        return (string) $variableName;
    }

    private function getVariableValueFromNestedVariable(string $variableName, $dataSource, int $level = 0)
    {
        $variableName = trim($variableName, '{}');

        /**
         * @param string $variableName
         * @param mixed $dataSource
         *
         * @since 4.3.4
         *
         * @return string
         */
        $variableName = $this->hooks->applyFilters(
            HooksAbstract::FILTER_WORKFLOW_ROUTE_VARIABLE,
            $variableName,
            $dataSource
        );

        if ($this->nestedVariablesCacheEnabled) {
            // If the variable is the top level, check the cache.
            if ($level === 0 && $this->hasNestedVariableInCache($variableName)) {
                $this->debugWithArgs(
                    'Variable "%s" is in the cache, returning the value',
                    $variableName
                );

                return $this->getNestedVariableValueFromCache($variableName);
            }
        }

        $variableNameParts = explode('.', $variableName);

        if (count($variableNameParts) === 1) {
            return $this->getVariableValue($variableNameParts[0], $dataSource);
        }

        if (! is_array($dataSource) && ! is_object($dataSource)) {
            $this->debugWithArgs(
                'Variable\'s datasource is not an array or object, returning the variable name: "%s"',
                $variableName
            );

            return $variableName;
        }

        if (is_array($dataSource) && !isset($dataSource[$variableNameParts[0]])) {
            $this->debugWithArgs(
                'Variable\'s datasource is an array but the key "%s" is not set, returning the variable name: "%s"',
                $variableNameParts[0],
                $variableName
            );

            return $variableName;
        }

        if (is_object($dataSource) && !isset($dataSource->{$variableNameParts[0]})) {
            $this->debugWithArgs(
                'Variable\'s datasource is an object but the property "%s" is not set, returning the variable name: "%s"',
                $variableNameParts[0],
                $variableName
            );

            return $variableName;
        }

        $currentVariableSource = is_array($dataSource) ?
            $dataSource[$variableNameParts[0]] :
            $dataSource->{$variableNameParts[0]};

        $variableNameParts = array_slice($variableNameParts, 1);

        $value = $this->getVariableValueFromNestedVariable(
            implode('.', $variableNameParts),
            $currentVariableSource,
            $level + 1
        );

        if ($this->nestedVariablesCacheEnabled) {
            // If the variable is the top level, set the value in the cache.
            if ($level === 0) {
                $this->debugWithArgs(
                    'Setting variable "%s" in the cache',
                    $variableName
                );

                $this->setNestedVariableValueInCache($variableName, $value);
            }
        }

        return $value;
    }

    private function setVariableInNestedArray(string $variableName, $variableValue, &$dataSource, int $level = 0)
    {
        $variableNameParts = explode('.', $variableName);

        if (count($variableNameParts) === 1) {
            if (is_array($dataSource)) {
                $dataSource[$variableNameParts[0]] = $variableValue;
            } elseif (is_object($dataSource) && $dataSource instanceof VariableResolverInterface) {
                $dataSource->setValue($variableNameParts[0], $variableValue);
            } else {
                $dataSource->{$variableNameParts[0]} = $variableValue;
            }
        } else {
            if (!isset($dataSource[$variableNameParts[0]])) {
                $dataSource[$variableNameParts[0]] = [];
            }

            $this->setVariableInNestedArray(
                implode('.', array_slice($variableNameParts, 1)),
                $variableValue,
                $dataSource[$variableNameParts[0]],
                $level + 1
            );

            if ($level === 0) {
                $this->setNestedVariableValueInCache($variableName, $variableValue);
            }
        }
    }

    public function getCompactedRuntimeVariables(): array
    {
        $runtimeVariables = $this->getAllVariables();

        foreach ($runtimeVariables as $context => &$variables) {
            if (is_array($variables)) {
                foreach ($variables as &$variableResolver) {
                    if (is_object($variableResolver)) {
                        $diff = null;
                        // TODO: Each variable resolver should have a method to compact itself and check diff, etc.
                        if ($variableResolver->getType() === 'post') {
                            $diff = $this->getPostDifferences(
                                $variableResolver->getVariable(),
                                get_post($variableResolver->getValue('ID'))
                            );
                        }

                        $variableResolver = $variableResolver->compact();

                        if ($diff) {
                            $variableResolver['diff'] = $diff;
                        }
                    }
                }
            }
        }

        return $runtimeVariables;
    }

    /**
     * @since 4.10.0
     */
    public function getWorkflowId(): int
    {
        return $this->workflowId;
    }

    /**
     * @since 4.10.0
     */
    public function setWorkflowId(int $workflowId): void
    {
        $this->workflowId = $workflowId;
    }

    private function getPostDifferences($post1, $post2)
    {
        $differences = [];

        foreach ($post1 as $key => $value) {
            if (! isset($post2->$key)) {
                $differences[$key] = $value;
            } elseif ($post2->$key !== $value) {
                $differences[$key] = $value;
            }
        }

        return $differences;
    }

    /**
     * Example of the different types of compacted arguments, LEGACY is the format
     * before v3.4.1.
     *
     * ----------------------------------------
     * LEGACY compacted arguments:
     *
     *   ...
     *   "contextVariables": {
     *      "global": {
     *          "workflow": 1417,
     *          "user": 1,
     *          "site": "Future Pro Workflow Dev",
     *          "trigger": "onPostUpdated_ebtrjpm"
     *      },
     *      "onPostUpdated1": {
     *          "postId": 1402,
     *          "postBefore": {
     *              "class": "WP_Post",
     *              "id": 1402,
     *              "diff": {
     *                  "post_title": "Custom Development??",
     *                  "post_status": "draft",
     *                  "post_modified": "2024-06-27 15:10:06",
     *                  "post_modified_gmt": "2024-06-27 18:10:06"
     *              }
     *          },
     *          "postAfter": {
     *              "class": "WP_Post",
     *              "id": 1402,
     *              "diff": []
     *          }
     *      }
     *  }
     *  ...
     * ----------------------------------------
     * NEW compacted arguments:
     *
     *  "runtimeVariables": {
     *    "global": {
     *        "workflow": {
     *            "type": "workflow",
     *            "value": 1417,
     *            "execution_id": "0000-0129-af10-a001",
     *            "execution_trace": "onPostUpdated_ebtrjpm, savePost_1402"
     *        },
     *        "user": {
     *            "type": "user",
     *            "value": 1
     *        },
     *        "site": {
     *            "type": "site",
     *            "value": "Future Pro Workflow Dev"
     *        },
     *        "trigger": {
     *            "type": "node",
     *            "value": {
     *                "id": "onPostUpdated_ebtrjpm",
     *                "name": "trigger\/core.post-updated",
     *                "label": "Post is updated",
     *                "activation_timestamp": "2024-06-27 18:19:24"
     *            }
     *        }
     *    },
     *    "onPostUpdated1": {
     *        "postId": {
     *            "type": "integer",
     *            "value": 1402
     *        },
     *        "postBefore": {
     *            "type": "post",
     *            "value": 1402,
     *            "diff": {
     *                "post_title": "Custom Development??",
     *                "post_status": "draft",
     *                "post_modified": "2024-06-27 15:16:56",
     *                "post_modified_gmt": "2024-06-27 18:16:56"
     *            }
     *        },
     *        "postAfter": {
     *            "type": "post",
     *            "value": 1402
     *        }
     *    }
     *}
     */
    public function expandRuntimeVariables(array $compactedVariables, bool $isLegacyCompact = false): array
    {
        $runtimeVariables = [];

        foreach ($compactedVariables as $context => $variables) {
            foreach ($variables as $variableName => $value) {
                $type = 'unknown';

                if ($isLegacyCompact) {
                    if ($variableName === 'workflow') {
                        $type = 'workflow';
                    } elseif ($variableName === 'user') {
                        $type = 'user';
                    } elseif ($variableName === 'site') {
                        $type = 'site';
                    } elseif ($variableName === 'trigger') {
                        $type = 'node';
                    } elseif (is_array($value)) {
                        $type = 'array';

                        if (isset($value['class'])) {
                            if ($value['class'] === 'WP_Post') {
                                $type = 'post';
                            } elseif ($value['class'] === 'WP_User') {
                                $type = 'user';
                            }
                        }
                    } elseif (is_numeric($value)) {
                        $type = 'integer';
                    } elseif (is_string($value)) {
                        $type = 'string';
                    }
                } else {
                    $type = $value['type'] ?? 'unknown';
                }

                // FIXME: This should be moved to a variable resolver factory
                $resolversMap = [
                    'array' => ArrayResolver::class,
                    'boolean' => BooleanResolver::class,
                    'datetime' => DatetimeResolver::class,
                    'email' => EmailResolver::class,
                    'integer' => IntegerResolver::class,
                    'node' => NodeResolver::class,
                    'post' => PostResolver::class,
                    'terms' => PostTermsResolver::class,
                    'site' => SiteResolver::class,
                    'user' => UserResolver::class,
                    'workflow' => WorkflowResolver::class,
                    'future_action' => FutureActionResolver::class,
                ];

                if (! $isLegacyCompact) {
                    $resolverArgument = $value['value'] ?? null;
                } else {
                    $resolverArgument = $value;
                }

                switch ($type) {
                    case 'post':
                        if ($isLegacyCompact) {
                            $postId = (int)$value['id'];
                        } else {
                            $postId = (int)$value['value'];
                        }

                        $resolverArgument = get_post($postId);

                        break;
                    case 'user':
                        if ($isLegacyCompact) {
                            $userId = (int)$value;
                        } else {
                            $userId = (int)$value['value'];
                        }

                        $resolverArgument = get_user_by('id', $userId);
                        break;
                    case 'workflow':
                        if ($isLegacyCompact) {
                            $workflowId = (int)$value;
                        } else {
                            $workflowId = (int)$value['value'];
                        }

                        $workflowModel = new WorkflowModel();
                        $workflowModel->load($workflowId);
                        $resolverArgument = [
                            'id' => $workflowModel->getId(),
                            'title' => $workflowModel->getTitle(),
                            'description' => $workflowModel->getDescription(),
                            'modified_at' => $workflowModel->getModifiedAt(),
                            'execution_id' => $value['execution_id'] ?? '',
                            'execution_trace' => $value['execution_trace'] ?? '',
                        ];
                        break;
                }

                if (isset($resolversMap[$type])) {
                    $resolverClass = $resolversMap[$type];

                    // TODO: Replace this with a factory
                    if ($type === 'site') {
                        $runtimeVariables[$context][$variableName] = new $resolverClass();
                    } elseif ($type === 'post') {
                        $runtimeVariables[$context][$variableName] = new $resolverClass(
                            $resolverArgument,
                            $this->hooks,
                            '',
                            $this->expirablePostModelFactory
                        );
                    } else {
                        $runtimeVariables[$context][$variableName] = new $resolverClass(
                            $resolverArgument
                        );
                    }
                } else {
                    $runtimeVariables[$context][$variableName] = $value;
                }
            }
        }

        return $runtimeVariables;
    }

    private function hasNestedVariableInCache(string $variableName)
    {
        return isset($this->nestedVariablesCache[$variableName]) && false;
    }

    private function getNestedVariableValueFromCache(string $variableName)
    {
        if ($this->hasNestedVariableInCache($variableName)) {
            return $this->nestedVariablesCache[$variableName];
        }

        return null;
    }

    private function setNestedVariableValueInCache(string $variableName, $variableValue)
    {
        if (! is_array($variableValue) && ! is_object($variableValue)) {
            $this->nestedVariablesCache[$variableName] = $variableValue;
            return;
        }

        if (is_array($variableValue)) {
            $this->setNestedVariableValueInCacheForArray($variableName, $variableValue);
            return;
        }

        $this->setNestedVariableValueInCacheForObject($variableName, $variableValue);
    }

    private function setNestedVariableValueInCacheForArray(string $variableName, $variableValue)
    {
        foreach ($variableValue as $key => $value) {
            $this->setNestedVariableValueInCache($variableName . '.' . $key, $value);
        }
    }

    private function setNestedVariableValueInCacheForObject(string $variableName, $variableValue)
    {
        $objectProperties = get_object_vars($variableValue);

        foreach ($objectProperties as $key => $value) {
            $this->setNestedVariableValueInCache($variableName . '.' . $key, $value);
        }
    }

    private function clearNestedVariablesCache()
    {
        $this->nestedVariablesCache = [];
    }

    private function isNestedVariable(string $variableName)
    {
        return strpos($variableName, '.') !== false;
    }

    private function debugWithArgs(string $message, ...$args): void
    {
        if (! defined('PUBLISHPRESS_FUTURE_DEBUG_EXECUTION_CONTEXT') || ! PUBLISHPRESS_FUTURE_DEBUG_EXECUTION_CONTEXT) {
            return;
        }

        $this->logger->debugWithArgs(
            sprintf(self::LOG_PREFIX, $this->getWorkflowId()) . $message,
            ...$args
        );
    }
}
