<?php
/**
 * ComposerJson Class
 *
 * @version     1.0.$Revision:$
 * @version     SVN: $Id:$
 * @package     bplan-base/globals
 * @subpackage  Composer
 * @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\Composer;


use InvalidArgumentException;
use RuntimeException;


/**
 * ComposerJson Class
 *
 * Reads, manipulates and writes composer.json / composer.local.json files.
 * All mutating methods return $this for fluent chaining; call save() to persist.
 *
 * @version     1.0.0 / 2026-04-19
 * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
 */
class ComposerJson
{


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


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


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


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


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


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


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


    /**
     * @param       string $path
     *
     * @param       array  $data
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    private 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-19
     * @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()


    /**
     * Adds a package to require or require-dev.
     *
     * Does nothing if the package is already present in the target section.
     *
     * @param       string $package
     *
     * @param       string $constraint
     *
     * @param       bool   $dev
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function addPackage(string $package, string $constraint = '*', bool $dev = false): self
    {
        $key = $dev === true ? 'require-dev' : 'require';

        if (!isset($this->_data[$key])) {
            $this->_data[$key] = [];
        }
        if (!isset($this->_data[$key][$package])) {
            $this->_data[$key][$package] = $constraint;
        }
        return $this;

    } // addPackage()


    /**
     * Adds a repository entry.
     *
     * Does nothing if a repository with the same name already exists.
     *
     * @param       string $name
     *
     * @param       string $type
     *
     * @param       string $url
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function addRepository(string $name, string $type, string $url): self
    {
        if (!isset($this->_data['repositories'])) {
            $this->_data['repositories'] = [];
        }
        if (!isset($this->_data['repositories'][$name])) {
            $this->_data['repositories'][$name] = [
                'type' => $type,
                'url'  => $url,
            ];
        }
        return $this;

    } // addRepository()


    /**
     * Adds a command to a composer script event.
     *
     * Does nothing if the command is already present for that event.
     *
     * @param       string $event
     *
     * @param       string $command
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function addScript(string $event, string $command): self
    {
        if (!isset($this->_data['scripts'][$event])) {
            $this->_data['scripts'][$event] = [];
        }
        if (!in_array($command, $this->_data['scripts'][$event], true)) {
            $this->_data['scripts'][$event][] = $command;
        }
        return $this;

    } // addScript()


    /**
     * Returns a value by dot-notation key.
     *
     * @param       string $key
     *
     * @param       mixed  $default
     *
     * @return      mixed
     *
     * @version     1.0.0 / 2026-04-19
     * @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-19
     * @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-19
     * @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-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function has(string $key): bool
    {
        return $this->_resolve($key) !== null;

    } // has()


    /**
     * Checks whether a package exists in require, require-dev, or both.
     *
     * @param       string    $package
     *
     * @param       bool|null $dev
     *              null = both, false = require only, true = require-dev only
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function hasPackage(string $package, bool|null $dev = null): bool
    {
        if ($dev === false || $dev === null) {
            if (isset($this->_data['require'][$package])) {
                return true;
            }
        }
        if ($dev === true || $dev === null) {
            if (isset($this->_data['require-dev'][$package])) {
                return true;
            }
        }
        return false;

    } // hasPackage()


    /**
     * Checks whether a repository with the given name exists.
     *
     * @param       string $name
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function hasRepository(string $name): bool
    {
        return isset($this->_data['repositories'][$name]);

    } // hasRepository()


    /**
     * Checks whether a command is registered for a script event.
     *
     * @param       string $event
     *
     * @param       string $command
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function hasScript(string $event, string $command): bool
    {
        return in_array($command, $this->_data['scripts'][$event] ?? [], true);

    } // hasScript()


    /**
     * Removes a dot-notation key from the data.
     *
     * @param       string $key
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @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 package from require and/or require-dev.
     *
     * @param       string $package
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function removePackage(string $package): self
    {
        unset($this->_data['require'][$package]);
        unset($this->_data['require-dev'][$package]);

        return $this;

    } // removePackage()


    /**
     * Removes a repository entry by name.
     *
     * @param       string $name
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function removeRepository(string $name): self
    {
        unset($this->_data['repositories'][$name]);

        return $this;

    } // removeRepository()


    /**
     * Removes a command from a composer script event.
     *
     * @param       string $event
     *
     * @param       string $command
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function removeScript(string $event, string $command): self
    {
        if (!isset($this->_data['scripts'][$event])) {
            return $this;
        }
        $this->_data['scripts'][$event] = array_values(
            array_filter($this->_data['scripts'][$event], fn($c) => $c !== $command)
        );

        return $this;

    } // removeScript()


    /**
     * Writes the current data back to the file.
     *
     * @return      void
     *
     * @throws      RuntimeException
     *
     * @version     1.0.0 / 2026-04-19
     * @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-19
     * @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 composer JSON file.
     *
     * @param       string $path
     *
     * @return      self
     *
     * @throws      RuntimeException
     *
     * @throws      InvalidArgumentException
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public static function load(string $path): self
    {
        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 self($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      self
     *
     * @throws      InvalidArgumentException
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public static function loadOrCreate(string $path): self
    {
        if (file_exists($path) === false) {
            return new self($path, []);
        }
        $data = json_decode(file_get_contents($path), true);

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

    } // loadOrCreate()


} // class ComposerJson {}
