<?php

declare(strict_types=1);

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

namespace Grav\Framework\File\Formatter;

use Grav\Framework\File\Interfaces\FileFormatterInterface;
use RuntimeException;
use Symfony\Component\Yaml\Exception\DumpException;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml as YamlParser;
use RocketTheme\Toolbox\Compat\Yaml\Parser as FallbackYamlParser;
use function array_key_exists;
use function function_exists;
use function is_array;

/**
 * Class YamlFormatter
 * @package Grav\Framework\File\Formatter
 */
class YamlFormatter extends AbstractFormatter
{
    /**
     * YamlFormatter constructor.
     * @param array $config
     */
    public function __construct(array $config = [])
    {
        $config += [
            'file_extension' => '.yaml',
            'inline' => 5,
            'indent' => 2,
            'native' => true,
            'compat' => true
        ];

        parent::__construct($config);
    }

    /**
     * @return int
     */
    public function getInlineOption(): int
    {
        return $this->getConfig('inline');
    }

    /**
     * @return int
     */
    public function getIndentOption(): int
    {
        return $this->getConfig('indent');
    }

    /**
     * @return bool
     */
    public function useNativeDecoder(): bool
    {
        return $this->getConfig('native');
    }

    /**
     * @return bool
     */
    public function useCompatibleDecoder(): bool
    {
        return $this->getConfig('compat');
    }

    /**
     * @param array $data
     * @param int|null $inline
     * @param int|null $indent
     * @return string
     * @see FileFormatterInterface::encode()
     */
    public function encode($data, $inline = null, $indent = null): string
    {
        static::assertNoMergeKeys($data);

        try {
            return YamlParser::dump(
                $data,
                $inline ? (int) $inline : $this->getInlineOption(),
                $indent ? (int) $indent : $this->getIndentOption(),
                YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE
            );
        } catch (DumpException $e) {
            throw new RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e);
        }
    }

    /**
     * Make sure that the data does not use `<<` as a key.
     *
     * Symfony YAML decoders read `<<` as the merge key, quoted or not, and move its value one level up:
     * `['<<' => ['access' => ...]]` comes back as `['access' => ...]`. There is no way to write the key so that it
     * is read back as it was, which means that data checked as an array (blueprint `security@` fields, privilege
     * guards) would load differently. Call this before writing YAML with any other encoder.
     *
     * @param mixed $data
     * @return void
     * @throws RuntimeException
     */
    public static function assertNoMergeKeys($data): void
    {
        if (!is_array($data)) {
            return;
        }

        if (array_key_exists('<<', $data)) {
            throw new RuntimeException("Encoding YAML failed: '<<' cannot be used as a key");
        }

        foreach ($data as $value) {
            static::assertNoMergeKeys($value);
        }
    }

    /**
     * {@inheritdoc}
     * @see FileFormatterInterface::decode()
     */
    public function decode($data): array
    {
        // Try native PECL YAML PHP extension first if available.
        if (function_exists('yaml_parse') && $this->useNativeDecoder()) {
            // Safely decode YAML.
            $saved = @ini_get('yaml.decode_php');
            @ini_set('yaml.decode_php', '0');
            $decoded = @yaml_parse($data);
            if ($saved !== false) {
                @ini_set('yaml.decode_php', $saved);
            }

            if ($decoded !== false) {
                return (array) $decoded;
            }
        }

        try {
            return (array) YamlParser::parse($data);
        } catch (ParseException $e) {
            if ($this->useCompatibleDecoder()) {
                // Call the parser itself: the static Compat\Yaml\Yaml::parse() treats a string without a
                // line break as a file name and reads that file when it exists, but $data is always YAML.
                return (array) (new FallbackYamlParser())->parse($data);
            }

            throw new RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e);
        }
    }
}
