<?php

/**
 * @package    Grav\Common\Data
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common\Data;

use Grav\Common\Config\Config;
use Grav\Common\Grav;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
use RocketTheme\Toolbox\Blueprints\BlueprintSchema as BlueprintSchemaBase;
use RuntimeException;
use function is_array;
use function is_string;

/**
 * Class BlueprintSchema
 * @package Grav\Common\Data
 */
class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
{
    use Export;

    /** @var array */
    protected $filter = ['validation' => true, 'xss_check' => true];

    /** @var array */
    protected $ignoreFormKeys = [
        'title' => true,
        'help' => true,
        'placeholder' => true,
        'placeholder_key' => true,
        'placeholder_value' => true,
        'fields' => true
    ];

    /**
     * @return array
     */
    public function getTypes()
    {
        return $this->types;
    }

    /**
     * @param string $name
     * @return array
     */
    public function getType($name)
    {
        return $this->types[$name] ?? [];
    }

    /**
     * @param string $name
     * @return array|null
     */
    public function getNestedRules(string $name)
    {
        return $this->getNested($name);
    }

    /**
     * Validate data against blueprints.
     *
     * @param  array $data
     * @param  array $options
     * @return void
     * @throws RuntimeException
     */
    public function validate(array $data, array $options = [])
    {
        try {
            $validation = $this->items['']['form']['validation'] ?? 'loose';
            $messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true);
        } catch (RuntimeException $e) {
            throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages();
        }

        if (!empty($messages)) {
            throw (new ValidationException('', 400))->setMessages($messages);
        }
    }

    /**
     * @param array $data
     * @param array $toggles
     * @return array
     */
    public function processForm(array $data, array $toggles = [])
    {
        return $this->processFormRecursive($data, $toggles, $this->nested) ?? [];
    }

    /**
     * Filter data by using blueprints.
     *
     * @param  array $data                  Incoming data, for example from a form.
     * @param  bool  $missingValuesAsNull   Include missing values as nulls.
     * @param bool   $keepEmptyValues       Include empty values.
     * @return array
     */
    public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false)
    {
        $this->buildIgnoreNested($this->nested);

        return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? [];
    }

    /**
     * Flatten data by using blueprints.
     *
     * @param array $data       Data to be flattened.
     * @param bool $includeAll  True if undefined properties should also be included.
     * @param string $name      Property which will be flattened, useful for flattening repeating data.
     * @return array
     */
    public function flattenData(array $data, bool $includeAll = false, string $name = '')
    {
        $prefix = $name !== '' ? $name . '.' : '';

        $list = [];
        if ($includeAll) {
            $items = $name !== '' ? $this->getProperty($name)['fields'] ?? [] : $this->items;
            foreach ($items as $key => $rules) {
                $type = $rules['type'] ?? '';
                $ignore = (bool) array_filter((array)($rules['validate']['ignore'] ?? [])) ?? false;
                if (!str_starts_with((string) $type, '_') && !str_contains((string) $key, '*') && $ignore !== true) {
                    $list[$prefix . $key] = null;
                }
            }
        }

        $nested = $this->getNestedRules($name);

        return array_replace($list, $this->flattenArray($data, $nested, $prefix));
    }

    /**
     * @param array $data
     * @param array $rules
     * @param string $prefix
     * @return array
     */
    protected function flattenArray(array $data, array $rules, string $prefix)
    {
        $array = [];

        foreach ($data as $key => $field) {
            $val = $rules[$key] ?? $rules['*'] ?? null;
            $rule = is_string($val) ? $this->items[$val] : null;

            if ($rule || isset($val['*'])) {
                // Item has been defined in blueprints.
                $array[$prefix.$key] = $field;
            } elseif (is_array($field) && is_array($val)) {
                // Array has been defined in blueprints.
                $array += $this->flattenArray($field, $val, $prefix . $key . '.');
            } else {
                // Undefined/extra item.
                $array[$prefix.$key] = $field;
            }
        }

        return $array;
    }

    /**
     * @param array $data
     * @param array $rules
     * @param bool $strict
     * @param bool $xss
     * @return array
     * @throws RuntimeException
     */
    protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true)
    {
        $messages = $this->checkRequired($data, $rules);

        foreach ($data as $key => $child) {
            $val = $rules[$key] ?? $rules['*'] ?? null;
            $rule = is_string($val) ? $this->items[$val] : null;
            $checkXss = $xss;

            if ($rule) {
                // Item has been defined in blueprints.
                if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
                    // Skip validation in the ignored field.
                    continue;
                }

                $messages += Validation::validate($child, $rule);

                if (isset($rule['validate']['match']) || isset($rule['validate']['match_exact']) || isset($rule['validate']['match_any'])) {
                    $ruleKey = current(array_intersect(['match', 'match_exact', 'match_any'], array_keys($rule['validate'])));
                    $otherKey = $rule['validate'][$ruleKey] ?? null;
                    $otherVal = $data[$otherKey] ?? null;
                    $otherLabel = $this->items[$otherKey]['label'] ?? $otherKey;
                    $currentVal = $data[$key] ?? null;
                    $currentLabel = $this->items[$key]['label'] ?? $key;

                    // Determine comparison type (loose, strict, substring)
                    // Perform comparison:
                    $isValid = false;
                    if ($ruleKey === 'match') {
                        $isValid = ($currentVal == $otherVal);
                    } elseif ($ruleKey === 'match_exact') {
                        $isValid = ($currentVal === $otherVal);
                    } elseif ($ruleKey === 'match_any') {
                        // If strings:
                        if (is_string($currentVal) && is_string($otherVal)) {
                            $isValid = (strlen($currentVal) && strlen($otherVal) && (str_contains($currentVal,
                                        $otherVal) || str_contains($otherVal, $currentVal)));
                        }
                        // If arrays:
                        if (is_array($currentVal) && is_array($otherVal)) {
                            $common = array_intersect($currentVal, $otherVal);
                            $isValid = !empty($common);
                        }
                    }
                    if (!$isValid) {
                        $messages[$rule['name']][] = sprintf(Grav::instance()['language']->translate('PLUGIN_FORM.VALIDATION_MATCH'), $currentLabel, $otherLabel);
                    }
                }

            } elseif (is_array($child) && is_array($val)) {
                // Array has been defined in blueprints.
                $messages += $this->validateArray($child, $val, $strict);
                $checkXss = false;

            } elseif ($strict) {
                // Undefined/extra item in strict mode.
                /** @var Config $config */
                $config = Grav::instance()['config'];
                if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) {
                    throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400);
                }

                user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED);
            }

            if ($checkXss) {
                $messages += Validation::checkSafety($child, $rule ?: ['name' => $key]);
            }
        }

        return $messages;
    }

    /**
     * @param array $data
     * @param array $rules
     * @param string $parent
     * @param bool  $missingValuesAsNull
     * @param bool $keepEmptyValues
     * @return array|null
     */
    protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues)
    {
        $results = [];

        foreach ($data as $key => $field) {
            $val = $rules[$key] ?? $rules['*'] ?? null;
            $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null;

            if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
                // Skip any data in the ignored field.
                unset($results[$key]);
                continue;
            }

            if (null === $field) {
                if ($missingValuesAsNull) {
                    $results[$key] = null;
                } else {
                    unset($results[$key]);
                }
                continue;
            }

            $isParent = isset($val['*']);
            $type = $rule['type'] ?? null;

            if (!$isParent && $type && $type !== '_parent') {
                $field = Validation::filter($field, $rule);
            } elseif (is_array($field) && is_array($val)) {
                // Array has been defined in blueprints.
                $k = $isParent ? '*' : $key;
                $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues);

                if (null === $field) {
                    // Nested parent has no values.
                    unset($results[$key]);
                    continue;
                }
            } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {
                // Skip any extra data.
                continue;
            }

            if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) {
                $results[$key] = $field;
            }
        }

        return $results ?: null;
    }

    /**
     * @param array $nested
     * @param string $parent
     * @return bool
     */
    protected function buildIgnoreNested(array $nested, $parent = '')
    {
        $ignore = true;
        foreach ($nested as $key => $val) {
            $key = $parent . $key;
            if (is_array($val)) {
                $ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order!
            } else {
                $child = $this->items[$key] ?? null;
                $ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore']));
            }
        }
        if ($ignore) {
            $key = trim($parent, '.');
            $this->items[$key]['validate']['ignore'] = true;
        }

        return $ignore;
    }

    /**
     * @param array|null $data
     * @param array $toggles
     * @param array $nested
     * @return array|null
     */
    protected function processFormRecursive(?array $data, array $toggles, array $nested)
    {
        foreach ($nested as $key => $value) {
            if ($key === '') {
                continue;
            }
            if ($key === '*') {
                // TODO: Add support to collections.
                continue;
            }
            if (is_array($value)) {
                // Special toggle handling for all the nested data.
                $toggle = $toggles[$key] ?? [];
                if (!is_array($toggle)) {
                    if (!$toggle) {
                        $data[$key] = null;

                        continue;
                    }

                    $toggle = [];
                }
                // Recursively fetch the items.
                $childData = $data[$key] ?? null;
                if (null !== $childData && !is_array($childData)) {
                    throw new \RuntimeException(sprintf("Bad form data for field collection '%s': %s used instead of an array", $key, gettype($childData)));
                }
                $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value);
            } else {
                $field = $this->get($value);
                // Do not add the field if:
                if (
                    // Not an input field
                    !$field
                    // Field has been disabled
                    || !empty($field['disabled'])
                    // Field validation is set to be ignored
                    || !empty($field['validate']['ignore'])
                    // Field is overridable and the toggle is turned off
                    || (!empty($field['overridable']) && empty($toggles[$key]))
                ) {
                    continue;
                }
                if (!isset($data[$key])) {
                    $data[$key] = null;
                }
            }
        }

        return $data;
    }

    /**
     * @param array $data
     * @param array $fields
     * @return array
     */
    protected function checkRequired(array $data, array $fields)
    {
        $messages = [];

        foreach ($fields as $name => $field) {
            if (!is_string($field)) {
                continue;
            }

            $field = $this->items[$field];

            // Skip ignored field, it will not be required.
            if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) {
                continue;
            }

            // Skip overridable fields without value.
            // TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good.
            if (!empty($field['overridable']) && !isset($data[$name])) {
                continue;
            }

            // Check if required.
            if (isset($field['validate']['required'])
                && $field['validate']['required'] === true) {
                if (isset($data[$name])) {
                    continue;
                }
                if ($field['type'] === 'file' && isset($data['data']['name'][$name])) { //handle case of file input fields required
                    continue;
                }

                $value = $field['label'] ?? $field['name'];
                $language = Grav::instance()['language'];
                $message  = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value));
                $messages[$field['name']][] = $message;
            }
        }

        return $messages;
    }

    /**
     * @param array $field
     * @param string $property
     * @param array $call
     * @return void
     */
    protected function dynamicConfig(array &$field, $property, array &$call)
    {
        $value = $call['params'];

        $default = $field[$property] ?? null;
        $config = Grav::instance()['config']->get($value, $default);

        if (null !== $config) {
            $field[$property] = $config;
        }
    }
}
