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


use RuntimeException;


/**
 * EnvFile Class
 *
 * Reads, manipulates and writes .env files.
 * All mutating methods return $this for fluent chaining; call save() to persist.
 *
 * Supports named sections delimited by #[SECTION_NAME] markers.
 *
 * @version     1.0.0 / 2026-04-19
 * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
 */
class EnvFile
{


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


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


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


    /**
     * Raw lines of the file, each including its EOL characters.
     *
     * @var     array
     */
    private array $_lines = [];


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


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


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


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

    } // __construct()


    /**
     * Returns the line index of the first line matching the given key, or null.
     *
     * Matches both active and commented-out keys: KEY=, # KEY=, #KEY=
     *
     * @param       string $key
     *
     * @return      int|null
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    private function _findKey(string $key): int|null
    {
        $pattern = '/^#?\s*'.preg_quote($key, '/').'\s*=/';

        foreach ($this->_lines as $i => $line) {
            if (preg_match($pattern, $line) === 1) {
                return $i;
            }
        }
        return null;

    } // _findKey()


    /**
     * Returns the line index of the first line of the next section after
     * $sectionStart, or the total line count if no further section exists.
     *
     * @param       int $sectionStart
     *
     * @return      int
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    private function _findSectionEnd(int $sectionStart): int
    {
        $count = count($this->_lines);

        for ($i = $sectionStart + 1; $i < $count; $i++) {
            if (preg_match('/^#\[.*\]$/', trim($this->_lines[$i])) === 1) {
                return $i;
            }
        }
        return $count;

    } // _findSectionEnd()


    /**
     * Returns the line index of the given section header, or null.
     *
     * @param       string $section     Section name without brackets, e.g. 'CUSTOM'
     *
     * @return      int|null
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    private function _findSectionStart(string $section): int|null
    {
        $header = '#['.$section.']';

        foreach ($this->_lines as $i => $line) {
            if (trim($line) === $header) {
                return $i;
            }
        }
        return null;

    } // _findSectionStart()


    /**
     * Returns the value of a key read directly from the file lines.
     *
     * Bypasses the cached PHP environment so it reflects recent writes.
     * Returns $default when the key is absent.
     *
     * @param       string $key
     *
     * @param       string $default
     *
     * @return      string
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function get(string $key, string $default = ''): string
    {
        $pattern = '/^'.preg_quote($key, '/').'\s*=\s*(.*)$/';

        foreach ($this->_lines as $line) {
            if (preg_match($pattern, $line, $matches) === 1) {
                return trim($matches[1]);
            }
        }
        return $default;

    } // get()


    /**
     * 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()


    /**
     * Returns all key/value pairs within a named section.
     *
     * Only active (non-commented) KEY = VALUE lines are returned.
     * Returns an empty array when the section does not exist.
     *
     * @param       string $section
     *
     * @return      array<string, string>
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function getSection(string $section): array
    {
        $start = $this->_findSectionStart($section);

        if ($start === null) {
            return [];
        }
        $end    = $this->_findSectionEnd($start);
        $result = [];

        for ($i = $start + 1; $i < $end; $i++) {
            if (preg_match('/^([A-Z][A-Z0-9_]+)\s*=\s*(.*)$/', trim($this->_lines[$i]), $m) === 1) {
                $result[$m[1]] = trim($m[2]);
            }
        }
        return $result;

    } // getSection()


    /**
     * Returns whether an active (non-commented) entry for $key exists.
     *
     * @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
    {
        $pattern = '/^'.preg_quote($key, '/').'\s*=/';

        foreach ($this->_lines as $line) {
            if (preg_match($pattern, $line) === 1) {
                return true;
            }
        }
        return false;

    } // has()


    /**
     * Returns whether a named section header exists in the file.
     *
     * @param       string $section
     *
     * @return      bool
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function hasSection(string $section): bool
    {
        return $this->_findSectionStart($section) !== null;

    } // hasSection()


    /**
     * Inserts a KEY = VALUE entry into a named section in alphabetical order.
     *
     * If the section does not exist, the entry is appended at the end of the file.
     * Does nothing if the key is already present anywhere in the file.
     *
     * @param       string $key
     *
     * @param       string $value
     *
     * @param       string $section     Section name without brackets, e.g. 'CUSTOM'
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function insertInSection(string $key, string $value, string $section = 'CUSTOM'): self
    {
        if ($this->has($key) === true) {
            return $this;
        }
        $sectionStart = $this->_findSectionStart($section);
        $sectionEnd   = $sectionStart !== null
            ? $this->_findSectionEnd($sectionStart)
            : count($this->_lines);

        $insertBefore = null;

        if ($sectionStart !== null) {
            for ($i = $sectionStart + 1; $i < $sectionEnd; $i++) {
                if (preg_match('/^([A-Z][A-Z0-9_]+)\s*=/', trim($this->_lines[$i]), $matches) === 1) {
                    if ($matches[1] > $key) {
                        $insertBefore = $i;

                        break;
                    }
                }
            }
        }
        $newLine = $key.' = '.$value."\n";

        if ($insertBefore !== null) {
            array_splice($this->_lines, $insertBefore, 0, [$newLine]);
        } else {
            /*
            **  Append after the last non-empty line within the section (or at EOF). */
            $insertAt = $sectionEnd;

            for ($i = $sectionEnd - 1; $i >= 0; $i--) {
                if (trim($this->_lines[$i]) !== '') {
                    $insertAt = $i + 1;

                    break;
                }
            }
            array_splice($this->_lines, $insertAt, 0, [$newLine]);
        }
        return $this;

    } // insertInSection()


    /**
     * Removes the line for the given key (active or commented-out).
     *
     * Does nothing when the key is not found.
     *
     * @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
    {
        $index = $this->_findKey($key);

        if ($index !== null) {
            array_splice($this->_lines, $index, 1);
        }
        return $this;

    } // remove()


    /**
     * Reorganizes the #[GLOBAL] section.
     *
     * Groups entries by their key prefix, sorts prefixes and keys alphabetically,
     * and regenerates the #[GLOBAL] header.
     * Keys with DB_ and MAIL_ prefixes are uncommented so they can be configured
     * interactively without duplicate entries being appended.
     * Sections beyond #[GLOBAL] are left untouched.
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function reorganize(): self
    {
        $globalEnd = count($this->_lines);

        foreach ($this->_lines as $i => $rawLine) {
            $trimmed = trim($rawLine);

            if (preg_match('/^#\[.*\]$/', $trimmed) === 1 && $trimmed !== '#[GLOBAL]') {
                $globalEnd = $i;

                break;
            }
        }

        $result            = [];
        $uncommentPrefixes = ['DB', 'MAIL'];

        for ($i = 0; $i < $globalEnd; $i++) {
            $line = trim($this->_lines[$i]);

            if ($line === '' || $line === '#[GLOBAL]') {
                continue;
            }
            $comment = false;

            if ($line[0] === '#') {
                $comment = true;
                $line    = trim(substr($line, 1));
            }
            $parts = explode('=', $line);
            $key   = trim($parts[0]);

            if (!isset($parts[1])) {
                $value = false;
            } else {
                $value = trim($parts[1]);
                $quote = $value !== '' && $value[0] === '"';
            }
            $prefix = explode('_', $key, 2)[0];

            if (!isset($result[$prefix])) {
                $result[$prefix] = [];
            }
            $result[$prefix][$key] = [
                'comment' => $comment,
                'key'     => $key,
                'line'    => $line,
                'value'   => $value,
            ];
        }

        ksort($result);

        $filecontents = '#[GLOBAL]'."\n";

        foreach ($result as $prefix => $settings) {
            ksort($settings);

            $separator = '';

            foreach ($settings as $setting) {
                $comment = '';

                if ($setting['comment'] === true && !in_array($prefix, $uncommentPrefixes, true)) {
                    $comment = '# ';
                }
                if ($setting['value'] === false) {
                    $filecontents .= $separator.$comment.$setting['line'];

                    continue;
                }
                $filecontents .= $separator.$comment.rtrim($setting['key'].' = '.$setting['value']);
                $separator     = "\n";
            }
            $filecontents .= "\n\n";
        }

        $newGlobalLines = [];

        foreach (explode("\n", rtrim($filecontents)) as $line) {
            $newGlobalLines[] = $line."\n";
        }
        $newGlobalLines[] = "\n";

        $this->_lines = array_merge($newGlobalLines, array_slice($this->_lines, $globalEnd));

        return $this;

    } // reorganize()


    /**
     * Writes the current lines 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
    {
        if (file_put_contents($this->_path, implode('', $this->_lines)) === false) {
            throw new RuntimeException('Could not write to '.$this->_path);
        }
    } // save()


    /**
     * Replaces or appends a single KEY = VALUE line.
     *
     * Matches both active and commented-out keys so that re-enabling a commented
     * entry does not create a duplicate.
     * Preserves the original line-ending style when replacing.
     *
     * @param       string $key
     *
     * @param       string $value
     *
     * @return      $this
     *
     * @version     1.0.0 / 2026-04-19
     * @author      Wassilios Meletiadis <wassilios.meletiadis@bplan-solutions.de>
     */
    public function set(string $key, string $value): self
    {
        $pattern = '/^#?\s*'.preg_quote($key, '/').'\s*=/';

        foreach ($this->_lines as &$line) {
            if (preg_match($pattern, $line) === 1) {
                $eol  = str_ends_with($line, "\r\n") ? "\r\n" : "\n";
                $line = $key.' = '.$value.$eol;

                unset($line);

                return $this;
            }
        }
        unset($line);

        $this->_lines[] = "\n";
        $this->_lines[] = $key.' = '.$value."\n";

        return $this;

    } // set()


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


    /**
     * Loads an existing .env file.
     *
     * @param       string $path
     *
     * @return      self
     *
     * @throws      RuntimeException
     *
     * @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);
        }
        return new self($path, file($path));

    } // 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
     *
     * @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, []);
        }
        return new self($path, file($path));

    } // loadOrCreate()


} // class EnvFile {}
