<?php

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

namespace Grav\Installer;

use Grav\Common\Utils;
use Symfony\Component\Yaml\Yaml;
use function array_key_exists;
use function assert;
use function count;
use function is_array;
use function strlen;

/**
 * Grav YAML updater.
 *
 * NOTE: This class can be initialized during upgrade from an older version of Grav. Make sure it runs there!
 */
final class YamlUpdater
{
    /** @var string */
    protected $filename;
    /** @var string[]  */
    protected $lines;
    /** @var array */
    protected $comments;
    /** @var array */
    protected $items;
    /** @var bool */
    protected $updated = false;

    /** @var self[] */
    protected static $instance;

    public static function instance(string $filename): self
    {
        if (!isset(self::$instance[$filename])) {
            self::$instance[$filename] = new self($filename);
        }

        return self::$instance[$filename];
    }

    /**
     * @return bool
     */
    public function save(): bool
    {
        if (!$this->updated) {
            return false;
        }

        try {
            if (!$this->isHandWritten()) {
                $yaml = Yaml::dump($this->items, 5, 2);
            } else {
                $yaml = implode("\n", $this->lines);

                $items = Yaml::parse($yaml);
                if ($items !== $this->items) {
                    // Lines and items are out of sync — fall back to dumping
                    // from items directly.  Loses original formatting but
                    // guarantees the file content matches the intended state.
                    $yaml = Yaml::dump($this->items, 5, 2);
                }
            }

            file_put_contents($this->filename, $yaml);

        } catch (\Exception $e) {
            throw new \RuntimeException('Failed to update ' . basename($this->filename) . ': ' . $e->getMessage());
        }

        return true;
    }

    /**
     * @return bool
     */
    public function isHandWritten(): bool
    {
        return !empty($this->comments);
    }

    /**
     * @return array
     */
    public function getComments(): array
    {
        $comments = [];
        foreach ($this->lines as $i => $line) {
            if ($this->isLineEmpty($line)) {
                $comments[$i+1] = $line;
            } elseif ($comment = $this->getInlineComment($line)) {
                $comments[$i+1] = $comment;
            }
        }

        return $comments;
    }

    /**
     * @param string $variable
     */
    public function define(string $variable, mixed $value): void
    {
        // If variable has already value, we're good.
        if ($this->get($variable) !== null) {
            return;
        }

        // If one of the parents isn't array, we're good, too.
        if (!$this->canDefine($variable)) {
            return;
        }

        $this->set($variable, $value);
        if (!$this->isHandWritten()) {
            return;
        }

        $parts = explode('.', $variable);

        $lineNos = $this->findPath($this->lines, $parts);
        $count = count($lineNos);
        $last = array_key_last($lineNos);

        $value = explode("\n", trim(Yaml::dump([$last => $this->get(implode('.', array_keys($lineNos)))], max(0, 5-$count), 2)));
        $currentLine = array_pop($lineNos) ?: 0;
        $parentLine = array_pop($lineNos);

        if ($parentLine !== null) {
            $c = $this->getLineIndentation($this->lines[$parentLine] ?? '');
            $n = $this->getLineIndentation($this->lines[$parentLine+1] ?? $this->lines[$parentLine] ?? '');
            $indent = $n > $c ? $n : $c + 2;
        } else {
            $indent = 0;
            array_unshift($value, '');
        }
        $spaces = str_repeat(' ', $indent);
        foreach ($value as &$line) {
            $line = $spaces . $line;
        }
        unset($line);

        array_splice($this->lines, abs($currentLine)+1, 0, $value);
    }

    /**
     * Check if a value exists by using dot notation for nested arrays/objects.
     */
    public function exists(string $variable): bool
    {
        $path = explode('.', $variable);
        $current = $this->items;

        foreach ($path as $field) {
            if (is_array($current) && array_key_exists($field, $current)) {
                $current = $current[$field];
                continue;
            }

            return false;
        }

        return true;
    }

    public function undefine(string $variable): void
    {
        // If variable does not have value, we're good.
        if ($this->get($variable) === null) {
            return;
        }

        // If one of the parents isn't array, we're good, too.
        if (!$this->canDefine($variable)) {
            return;
        }

        $this->undef($variable);
        if (!$this->isHandWritten()) {
            return;
        }

        $parts = explode('.', $variable);
        $lineNos = $this->findPath($this->lines, $parts);

        // Get the line number of the target property itself (last in the path).
        $targetLine = end($lineNos);

        // Negative value means the property was not found in the lines.
        if ($targetLine === false || $targetLine < 0) {
            return;
        }

        // Determine how many lines to remove: the target line plus any
        // more-deeply-indented child lines that follow it.
        $targetIndent = $this->getLineIndentation($this->lines[$targetLine]);
        $endLine = $targetLine + 1;
        $total = count($this->lines);

        while ($endLine < $total) {
            $line = $this->lines[$endLine];
            if ($this->isLineEmpty($line)) {
                // Blank/comment lines — tentatively include them; they may
                // belong to the block being removed or to a following sibling.
                $endLine++;
                continue;
            }
            if ($this->getLineIndentation($line) > $targetIndent) {
                // Child line — part of the value being removed.
                $endLine++;
                continue;
            }
            // Reached a sibling or parent — stop.
            break;
        }

        // Don't swallow trailing blank lines that belong to the next section.
        while ($endLine > $targetLine + 1 && $this->isLineBlank($this->lines[$endLine - 1])) {
            $endLine--;
        }

        array_splice($this->lines, $targetLine, $endLine - $targetLine);
    }

    private function __construct(string $filename)
    {
        $content = is_file($filename) ? (string)file_get_contents($filename) : '';
        $content = rtrim(str_replace(["\r\n", "\r"], "\n", $content));

        $this->filename = $filename;
        $this->lines = explode("\n", $content);
        $this->comments = $this->getComments();
        $this->items = $content ? Yaml::parse($content) : [];
    }

    /**
     * Return array of offsets for the parent nodes. Negative value means position, but not found.
     *
     * @param array $lines
     * @param array $parts
     * @return int[]
     */
    private function findPath(array $lines, array $parts)
    {
        $test = true;
        $indent = -1;
        $current = array_shift($parts);

        $j = 1;
        $found = [];
        $space = '';
        foreach ($lines as $i => $line) {
            if ($this->isLineEmpty($line)) {
                if ($this->isLineComment($line) && $this->getLineIndentation($line) > $indent) {
                    $j = $i;
                }
                continue;
            }

            if ($test === true) {
                $test = false;
                $spaces = strlen((string) $line) - strlen(ltrim((string) $line, ' '));
                if ($spaces <= $indent) {
                    $found[$current] = -$j;

                    return $found;
                }

                $indent = $spaces;
                $space = $indent ? str_repeat(' ', $indent) : '';
            }


            if (str_starts_with((string) $line, $space)) {
                $pattern = "/^{$space}(['\"]?){$current}\\1\:/";

                if (preg_match($pattern, (string) $line)) {
                    $found[$current] = $i;
                    $current = array_shift($parts);
                    if ($current === null) {
                        return $found;
                    }
                    $test = true;
                }
            } else {
                $found[$current] = -$j;

                return $found;
            }

            $j = $i;
        }

        $found[$current] = -$j;

        return $found;
    }

    /**
     * Returns true if the current line is blank or if it is a comment line.
     *
     * @param string $line Contents of the line
     * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise
     */
    private function isLineEmpty(string $line): bool
    {
        return $this->isLineBlank($line) || $this->isLineComment($line);
    }

    /**
     * Returns true if the current line is blank.
     *
     * @param string $line Contents of the line
     * @return bool Returns true if the current line is blank, false otherwise
     */
    private function isLineBlank(string $line): bool
    {
        return '' === trim($line, ' ');
    }

    /**
     * Returns true if the current line is a comment line.
     *
     * @param string $line Contents of the line
     * @return bool Returns true if the current line is a comment line, false otherwise
     */
    private function isLineComment(string $line): bool
    {
        //checking explicitly the first char of the trim is faster than loops or strpos
        $ltrimmedLine = ltrim($line, ' ');

        return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0];
    }

    /**
     * @param string $line
     * @return bool
     */
    private function isInlineComment(string $line): bool
    {
        return $this->getInlineComment($line) !== null;
    }

    /**
     * @param string $line
     * @return string|null
     */
    private function getInlineComment(string $line): ?string
    {
        $pos = strpos($line, ' #');
        if (false === $pos) {
            return null;
        }

        $parts = explode(' #', $line);
        $part = '';
        while ($part .= array_shift($parts)) {
            // Remove quoted values.
            $part = preg_replace('/(([\'"])[^\2]*\2)/', '', $part);
            assert(null !== $part);
            $part = preg_split('/[\'"]/', $part, 2);
            assert(false !== $part);
            if (!isset($part[1])) {
                $part = $part[0];
                array_unshift($parts, str_repeat(' ', strlen($part) - strlen(trim($part, ' '))));
                break;
            }
            $part = $part[1];
        }


        return implode(' #', $parts);
    }

    /**
     * Returns the current line indentation.
     *
     * @param string $line
     * @return int The current line indentation
     */
    private function getLineIndentation(string $line): int
    {
        return \strlen($line) - \strlen(ltrim($line, ' '));
    }

    /**
     * Get value by using dot notation for nested arrays/objects.
     *
     * @param string $name Dot separated path to the requested value.
     * @param mixed $default Default value (or null).
     * @return mixed Value.
     */
    public function get(string $name, mixed $default = null)
    {
        $path = explode('.', $name);
        $current = $this->items;

        foreach ($path as $field) {
            if (is_array($current) && isset($current[$field])) {
                $current = $current[$field];
            } else {
                return $default;
            }
        }

        return $current;
    }

    /**
     * Set value by using dot notation for nested arrays/objects.
     *
     * @param string $name Dot separated path to the requested value.
     * @param mixed $value New value.
     */
    public function set(string $name, mixed $value): void
    {
        $path = explode('.', $name);
        $current = &$this->items;

        foreach ($path as $field) {
            // Handle arrays and scalars.
            if (!is_array($current)) {
                $current = [$field => []];
            } elseif (!isset($current[$field])) {
                $current[$field] = [];
            }
            $current = &$current[$field];
        }

        $current = $value;
        $this->updated = true;
    }

    /**
     * Unset value by using dot notation for nested arrays/objects.
     *
     * @param string $name Dot separated path to the requested value.
     */
    private function undef(string $name): void
    {
        $path = $name !== '' ? explode('.', $name) : [];
        if (!$path) {
            return;
        }

        $var = array_pop($path);
        $current = &$this->items;

        foreach ($path as $field) {
            if (!is_array($current) || !isset($current[$field])) {
                return;
            }
            $current = &$current[$field];
        }

        unset($current[$var]);
        $this->updated = true;
    }

    /**
     * Get value by using dot notation for nested arrays/objects.
     *
     * @param string $name Dot separated path to the requested value.
     * @return bool
     */
    private function canDefine(string $name): bool
    {
        $path = explode('.', $name);
        $current = $this->items;

        foreach ($path as $field) {
            if (is_array($current)) {
                if (!isset($current[$field])) {
                    return true;
                }
                $current = $current[$field];
            } else {
                return false;
            }
        }

        return true;
    }
}
