<?php
/**
 * JsonFile Class
 *
 * @version     1.0.$Revision:$
 * @version     SVN: $Id:$
 * @package     bplan-base/globals
 * @subpackage  Json
 * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
 * @copyright   Copyright (C) 2025, 2026 bplan-solutions GmbH & Co. KG <https://www.bplan-solutions.de/>
 * /Δ\
 */

namespace BplanBase\Globals\Json;


use InvalidArgumentException;
use RuntimeException;


/**
 * JsonFile Class
 *
 * Reads, manipulates and writes arbitrary JSON files.
 * All mutating methods return $this for fluent chaining; call save() to persist.
 *
 * Keys are accessed via dot-notation (e.g. 'scripts.post-update-cmd').
 *
 * @version     1.0.0 / 2026-04-21
 * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
 */
class JsonFile
{


/* +++ TRAITS +++ +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ */


/* +++ CLASS CONSTANTS +++ ++++++++++++++++++++++++++++++++++++++++++++++++++ */


/* +++ OBJECT MEMBERS +++ +++++++++++++++++++++++++++++++++++++++++++++++++++ */


    /**
     * @var     array
     */
    protected array $_data = [];


    /**
     * @var     string
     */
    protected string $_path;


/* +++ CLASS MEMBERS +++ ++++++++++++++++++++++++++++++++++++++++++++++++++++ */


/* +++ OBJECT METHODS +++ +++++++++++++++++++++++++++++++++++++++++++++++++++ */


    /**
     * @param       string $path
     *
     * @param       array  $data
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    protected function __construct(string $path, array $data)
    {
        $this->_data = $data;
        $this->_path = $path;

    } // __construct()


    /**
     * Resolves a dot-notation key against the data array.
     *
     * @param       string $key
     *
     * @return      mixed
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    private function _resolve(string $key): mixed
    {
        $node = $this->_data;

        foreach (explode('.', $key) as $segment) {
            if (!is_array($node) || !array_key_exists($segment, $node)) {
                return null;
            }
            $node = $node[$segment];
        }
        return $node;

    } // _resolve()


    /**
     * Appends a value to an array at the given dot-notation key.
     *
     * Does nothing if the value is already present in the array.
     * Creates the array if the key does not exist yet.
     *
     * @param       string $key
     *
     * @param       mixed  $value
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function addToArray(string $key, mixed $value): self
    {
        $current = $this->_resolve($key);

        if (!is_array($current)) {
            $current = [];
        }
        if (in_array($value, $current, true) === false) {
            $current[] = $value;
            $this->set($key, $current);
        }
        return $this;

    } // addToArray()


    /**
     * Returns a value by dot-notation key.
     *
     * @param       string $key
     *
     * @param       mixed  $default
     *
     * @return      mixed
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function get(string $key, mixed $default = null): mixed
    {
        return $this->_resolve($key) ?? $default;

    } // get()


    /**
     * Returns the full parsed data array.
     *
     * @return      array
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function getData(): array
    {
        return $this->_data;

    } // getData()


    /**
     * Returns the file path this instance is bound to.
     *
     * @return      string
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function getPath(): string
    {
        return $this->_path;

    } // getPath()


    /**
     * Checks whether a dot-notation key exists and is not null.
     *
     * @param       string $key
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function has(string $key): bool
    {
        return $this->_resolve($key) !== null;

    } // has()


    /**
     * Checks whether a value exists in the array at the given dot-notation key.
     *
     * @param       string $key
     *
     * @param       mixed  $value
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function hasInArray(string $key, mixed $value): bool
    {
        $current = $this->_resolve($key);

        if (!is_array($current)) {
            return false;
        }
        return in_array($value, $current, true);

    } // hasInArray()


    /**
     * Removes a dot-notation key from the data.
     *
     * @param       string $key
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function remove(string $key): self
    {
        $segments = explode('.', $key);
        $last     = array_pop($segments);
        $node     = &$this->_data;

        foreach ($segments as $segment) {
            if (!isset($node[$segment]) || !is_array($node[$segment])) {
                return $this;
            }
            $node = &$node[$segment];
        }
        unset($node[$last]);

        return $this;

    } // remove()


    /**
     * Removes a value from the array at the given dot-notation key.
     *
     * Does nothing if the value is not present or the key does not hold an array.
     *
     * @param       string $key
     *
     * @param       mixed  $value
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function removeFromArray(string $key, mixed $value): self
    {
        $current = $this->_resolve($key);

        if (!is_array($current)) {
            return $this;
        }
        $filtered = array_values(array_filter($current, fn($v) => $v !== $value));

        return $this->set($key, $filtered);

    } // removeFromArray()


    /**
     * Writes the current data back to the file.
     *
     * @return      void
     *
     * @throws      RuntimeException
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function save(): void
    {
        $json = json_encode($this->_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL;

        if (file_put_contents($this->_path, $json) === false) {
            throw new RuntimeException('Could not write to '.$this->_path);
        }
    } // save()


    /**
     * Sets a value by dot-notation key.
     *
     * Creates intermediate arrays as needed.
     *
     * @param       string $key
     *
     * @param       mixed  $value
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function set(string $key, mixed $value): self
    {
        $segments = explode('.', $key);
        $node     = &$this->_data;

        foreach ($segments as $i => $segment) {
            if ($i === count($segments) - 1) {
                $node[$segment] = $value;
            } else {
                if (!isset($node[$segment]) || !is_array($node[$segment])) {
                    $node[$segment] = [];
                }
                $node = &$node[$segment];
            }
        }
        return $this;

    } // set()


/* +++ CLASS METHODS +++ ++++++++++++++++++++++++++++++++++++++++++++++++++++ */


    /**
     * Loads an existing JSON file.
     *
     * @param       string $path
     *
     * @return      static
     *
     * @throws      RuntimeException
     *
     * @throws      InvalidArgumentException
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public static function load(string $path): static
    {
        if (file_exists($path) === false) {
            throw new RuntimeException('File not found: '.$path);
        }
        $data = json_decode(file_get_contents($path), true);

        if (!is_array($data)) {
            throw new InvalidArgumentException('Invalid JSON in file: '.$path);
        }
        return new static($path, $data);

    } // load()


    /**
     * Loads an existing file or creates an empty instance bound to the given path.
     *
     * The file is not written until save() is called.
     *
     * @param       string $path
     *
     * @return      static
     *
     * @throws      InvalidArgumentException
     *
     * @version     1.0.0 / 2026-04-21
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public static function loadOrCreate(string $path): static
    {
        if (file_exists($path) === false) {
            return new static($path, []);
        }
        $data = json_decode(file_get_contents($path), true);

        if (!is_array($data)) {
            throw new InvalidArgumentException('Invalid JSON in file: '.$path);
        }
        return new static($path, $data);

    } // loadOrCreate()


} // class JsonFile {}
