<?php
/**
 * Heavily modified version of
 * Austin Hyde's IniParser
 * @author Fernando Morgado aka Jo Morg <jomorg@>
 */
/**
 * [MIT Licensed](http://www.opensource.org/licenses/mit-license.php)
 * Copyright (c) 2013 Austin Hyde
 *
 * Implements a parser for INI files that supports
 * - Section inheritance
 * - Property nesting
 * - Simple arrays
 *
 * @author Austin Hyde
 * @author Till Klampaeckel <till@php.net>
 */
/**
 * IniParser
 *
 * IniParser is a simple parser for complex INI files,
 * providing a number of extra syntactic features to the built-in INI parsing functions,
 * including section inheritance, property nesting, and array literals.
 *
 * - An Example
 *
 * environment = testing
 *
 * [testing]
 * debug = true
 * database.connection = "mysql:host=127.0.0.1"
 * database.name = test
 * database.username =
 * database.password =
 * secrets = [1,2,3]
 *
 * [staging : testing]
 * database.name = stage
 * database.username = staging
 * database.password = 12345
 *
 * [production : staging]
 * debug = false;
 * database.name = production
 * database.username = root
 *
 * And when parsed with IniParser:
 *
 * $parser = new \IniParser('sample.ini');
 * $config = $parser->parse();
 *
 * You get the following structure:
 *    [
 *      'environment' => 'testing',
 *      'testing'     => [
 *                          'debug'    => '1',
 *                          'database' => [
 *                                          'connection' => 'mysql:host=127.0.0.1',
 *                                          'name'       => 'test',
 *                                          'username'   => '',
 *                                          'password'   => ''
 *                                        ],
 *                        'secrets'  => ['1', '2', '3']
 *                       ],
 *      'staging'     => [
 *                          'debug'    => '1',
 *                          'database' => [
 *                                          'connection' => 'mysql:host=127.0.0.1',
 *                                          'name'       => 'stage',
 *                                          'username'   => 'staging',
 *                                          'password'   => '12345'
 *                                        ],
 *                        'secrets'  => ['1', '2', '3']
 *                        ],
 *      'production'  => [
 *                          'debug'    => '',
 *                          'database' => [
 *                          'connection' => 'mysql:host=127.0.0.1',
 *                          'name'       => 'production',
 *                          'username'   => 'root',
 *                          'password'   => '12345'
 *                        ],
 *                        'secrets'  => ['1', '2', '3']
 *      ]
 *    ];
 *
 *
 * Supported Features
 *
 * - Array Literals
 *
 * You can directly create arrays using the syntax `[a, b, c]` on the right hand side of an assignment. For example:
 *
 * colors = [blue, green, red]
 *
 * *NOTE:** At the moment, quoted strings inside array literals have undefined behavior.
 *
 * - Dictionaries and complex structures
 *
 * Besides arrays, you can create dictionaries and more complex structures using JSON syntax. For example, you can use:
 *
 * people = '{
 *             "boss": {
 *                       "name": "John",
 *                       "age": 42
 *                     },
 *            "staff": [
 *                      {
 *                        "name": "Mark",
 *                        "age": 35
 *                      },
 *                      {
 *                        "name": "Bill",
 *                        "age": 44
 *                      }
 *                     ]
 *          }'
 *
 * This turns into an array like:
 *     [
 *      'boss'  => [
 *                  'name' => 'John',
 *                  'age'  => 42
 *                ],
 *     'staff' => [
 *                  [
 *                    'name' => 'Mark',
 *                    'age'  => 35,
 *                  ],
 *                  [
 *                    'name' => 'Bill',
 *                    'age'  => 44,
 *                  ],
 *                ],
 *    ];
 *
 * **NOTE:**  Remember to wrap the JSON strings in single quotes for a correct analysis.
 *            The JSON names must be enclosed in double quotes and trailing commas are not allowed.
 *
 * - Property Nesting
 *
 * IniParser allows you to treat properties as associative arrays:
 *
 * person.age = 42
 * person.name.first = John
 * person.name.last = Doe
 *
 * This turns into an array like:
 * [ 'person' => [ 'age' => 42,  'name' => [ 'first' => 'John', 'last' => 'Doe'] ] ]
 *
 * - Section Inheritance
 *
 * IniParser allows you to "inherit" from other sections (similar to OOP inheritance),
 * meaning you don't have to continually re-define the same properties over and over again.
 * As you can see in the example above, "production" inherits from "staging",
 * which in turn inherits from "testing".
 *
 * You can even inherit from multiple parents, as in `[child : p1 : p2 : p3]`.
 * The properties of each parent are merged  into the child from left to right,
 * so that the properties in `p1` are overridden by those in `p2`, then by `p3`,
 * then by those in `child` on top of that.
 *
 * During the inheritance process,
 * if a key ends in a `+`,
 * the merge behavior changes from overwriting the parent value
 * to prepending the parent value (or appending the child value - same thing).
 * So the example file
 *
 * [parent]
 * arr = [a,b,c]
 * val = foo
 *
 * [child : parent]
 * arr += [x,y,z]
 * val += bar
 *
 * would be parsed into the following:
 *
 * ['parent' => ['arr' => ['a','b','c'], 'val' => 'foo'],
 *  'child' => [ arr' => ['a','b','c','x','y','z'], 'val' => 'foobar']
 * ]
 *
 * Finally, it is possible to inherit from the special `^` section, representing the top-level or global properties:
 *
 * foo = bar
 *
 * [sect : ^]
 *
 * Parses to:
 *
 * ['foo' => 'bar',
 * 'sect' => ['foo' => 'bar']
 * ]
 *
 * - ArrayObject
 *
 * As an added bonus, IniParser also allows you to access the values OO-style:
 *
 * echo $config->production->database->connection; // output: mysql:host=127.0.0.1
 * echo $config->staging->debug; // output: 1
 */

namespace CMSMSExt\FS;

/**
 * Class Parser
 *
 * @package Ini
 */
class ExtendedIniParser
{
  /**
   * Filename of our .ini file.
   *
   * @var string
   */
  protected $ini_content;
  
  /**
   * Enable/disable property nesting feature
   *
   * @var boolean
   */
  public $property_nesting = TRUE;
  
  /**
   * Enable/disable parametric value parsing
   *
   * @var boolean
   */
  public $parametric_parsing = FALSE;
  
  /**
   * Normal: 0
   * Raw: 1
   * Typed: 2
   */
  public $ini_parse_option = 0;
  
  /**
   * Separator in case of multiple values
   *
   * @var string
   */
  public $multi_value_separator = '|';
  
  /**
   * Use ArrayObject to allow array work as object (true) or use native arrays (false)
   *
   * @var boolean
   */
  public $use_array_object = TRUE;
  
  /**
   * Include original sections (pre-inherit names) on the final output
   *
   * @var boolean
   */
  public $include_original_sections = FALSE;
  
  /**
   * If set to true, it will consider the passed parameter as string
   *
   * @var bool
   */
  public $treat_ini_string = FALSE;
  
  /**
   * Disable array literal parsing
   */
  const NO_PARSE = 0;
  
  /**
   * Parse simple arrays using regex (ex: [a,b,c,...])
   */
  const PARSE_SIMPLE = 1;
  
  /**
   * Parse array literals using JSON, allowing advanced features like
   * dictionaries, array nesting, etc.
   */
  const PARSE_JSON = 2;
  
  /**
   * Normal: 0
   * Raw: 1
   * Typed: 2
   */
  const INI_PARSE_OPTION = 0;
  
  /**
   * Array literals parse mode
   *
   * @var int
   */
  public $array_literals_behavior = self::PARSE_SIMPLE;
  
  /**
   * Parser constructor.
   *
   * @param string $iniContent File path or the ini string
   */
  public function __construct(string $iniContent = NULL)
  {
    if(NULL !== $iniContent)
    {
      $this->setIniContent($iniContent);
    }
  }
  
  /**
   * Parses an INI file
   *
   * @param string $iniContent
   *
   * @return mixed
   * @throws \JsonException
   */
  public function parse(string $iniContent = NULL)
  {
    if(NULL !== $iniContent) { $this->setIniContent($iniContent); }
    
    if(empty($this->ini_content))
    {
      throw new \LogicException('Need ini content to parse.');
    }
    
    if($this->treat_ini_string)
    {
      $simple_parsed = \parse_ini_string($this->ini_content, TRUE, $this->ini_parse_option);
    }
    else
    {
      $simple_parsed = \parse_ini_file($this->ini_content, TRUE, $this->ini_parse_option);
    }
    
    $inheritance_parsed = $this->parseSections($simple_parsed);
    
    return $this->parseKeys($inheritance_parsed);
  }
  
  /**
   * Parses a string with INI contents
   *
   * @param string $src
   *
   * @return array
   * @throws \JsonException
   */
  public function process(string $src)
  {
    $simple_parsed      = \parse_ini_string($src, TRUE, $this->ini_parse_option);
    $inheritance_parsed = $this->parseSections($simple_parsed);
    
    return $this->parseKeys($inheritance_parsed);
  }
  
  /**
   * @param string $ini_content
   *
   * @return \CMSMSExt\FS\ExtendedIniParser
   * @throws \InvalidArgumentException
   */
  public function setIniContent(string $ini_content)
  {
    // If the parsed parameter is to be treated as string instead of file
    if($this->treat_ini_string)
    {
      $this->ini_content = $ini_content;
    }
    else
    {
      if(!\file_exists($ini_content) || !\is_readable($ini_content))
      {
        throw new \InvalidArgumentException("The file '{$ini_content}' cannot be opened.");
      }
      
      $this->ini_content = $ini_content;
    }
    
    return $this;
  }
  
  /**
   * Parse sections and inheritance.
   *
   * @param array $simple_parsed
   *
   * @return array  Parsed sections
   */
  private function parseSections(array $simple_parsed)
  {
    // do an initial pass to gather section names
    $sections = [];
    $globals  = [];
    foreach($simple_parsed as $k => $v)
    {
      if(\is_array($v))
      {
        // $k is a section name
        $sections[$k] = $v;
      }
      else
      {
        $globals[$k] = $v;
      }
    }
    
    // now for each section, see if it uses inheritance
    $output_sections = [];
    foreach($sections as $k => $v)
    {
      $sects = \array_map('\trim', \array_reverse(\explode(':', $k)));
      $root  = \array_pop($sects);
      $arr   = $v;
      
      foreach($sects as $s)
      {
        if('^' === $s)
        {
          $arr = \array_merge($globals, $arr);
        }
        else if(\array_key_exists($s, $output_sections))
        {
          $arr = \array_merge($output_sections[$s], $arr);
        }
        else if(\array_key_exists($s, $sections))
        {
          $arr = \array_merge($sections[$s], $arr);
        }
        else
        {
          throw new \UnexpectedValueException(
            "IniParser: In file '{$this->ini_content}', section '{$root}': Cannot inherit from unknown section '{$s}'"
          );
        }
      }
      
      if($this->include_original_sections)
      {
        $output_sections[$k] = $v;
      }
      
      $output_sections[$root] = $arr;
    }
    
    
    return $globals + $output_sections;
  }
  
  /**
   * @param array $arr
   *
   * @return mixed
   * @throws \JsonException
   */
  private function parseKeys(array $arr)
  {
    $output       = $this->getArrayValue();
    $append_regex = '/\s*\+\s*$/';
    foreach($arr as $k => $v)
    {
      if(\is_array($v) && FALSE === \strpos($k, '.'))
      {
        // this element represents a section; recursively parse the value
        $output[$k] = $this->parseKeys($v);
      }
      else
      {
        // if the key ends in a +, it means we should append to the previous value, if applicable
        $append = FALSE;
        if(\preg_match($append_regex, $k))
        {
          $k      = \preg_replace($append_regex, '', $k);
          $append = TRUE;
        }
        
        // transform "a.b.c = x" into $output[a][b][c] = x
        $current = &$output;
        
        $path = $this->property_nesting ? \explode('.', $k) : [$k];
        while(NULL !== ($current_key = \array_shift($path)))
        {
          if(\is_string($current))
          {
            $current = [$current];
          }
          
          if(\is_array($current) && !\array_key_exists($current_key, $current))
          {
            if(!empty($path))
            {
              $current[$current_key] = $this->getArrayValue();
            }
            else
            {
              $current[$current_key] = NULL;
            }
          }
          
          $current = &$current[$current_key];
        }
        
        // parse value
        $value = $v;
        if(!\is_array($v))
        {
          $value = $this->parseValue($v);
        }
        
        if($append && NULL !== $current)
        {
          if(\is_array($value))
          {
            if(!\is_array($current))
            {
              throw new \LogicException("Cannot append array to inherited value '{$k}'");
            }
            $value = array_merge($current, $value);
            $value = \array_map([$this, 'parseParametricValue'], $value);
          }
          else
          {
            $value = $current . $value;
          }
        }
        
        $current = $this->parseParametricValue($value);
      }
    }
    
    return $output;
  }
  
  /**
   * Parses the parametric value to multiple parameters
   *
   * @param $value
   * // todo is array or string?
   *
   * @return array|string
   */
  protected function parseParametricValue($value)
  {
    // If parametric parsing isn't turned on or value has no parameters
    if(!$this->parametric_parsing || !\is_string($value) || FALSE === \strpos($value, '='))
    {
      return $value;
    }
    
    // As there could be multiple parameters separated by spaces
    $parameters = \preg_split('/\s+/', $value);
    
    $parsedValue = [];
    foreach($parameters as $parameter)
    {
      [$parameterKey, $parameterValue] = \explode('=', $parameter);
      // todo simplify
      $parsedValue[$parameterKey] = FALSE !== \strpos($parameterValue, $this->multi_value_separator) ? \explode(
        $this->multi_value_separator, $parameterValue
      ) : $parameterValue;
    }
    
    return $parsedValue;
  }
  
  /**
   * Parses and formats the value in a key-value pair
   *
   * @param string $value
   *
   * @return mixed
   * @throws \JsonException
   */
  protected function parseValue(string $value)
  {
    switch($this->array_literals_behavior)
    {
      case self::PARSE_JSON:
        if(\in_array($value[0], ['[', '{']) && \in_array(\substr($value, -1), [']', '}']))
        {
          if(\defined('JSON_BIGINT_AS_STRING'))
          {
            $output = \json_decode($value, TRUE, 512, \JSON_THROW_ON_ERROR | \JSON_BIGINT_AS_STRING);
          }
          else
          {
            $output = \json_decode($value, TRUE, 512, \JSON_THROW_ON_ERROR);
          }
          
          if(NULL !== $output) { return $output; }
        }
        
      // fallthrough
      // try regex parser for simple structures not JSON-compatible (ex: colors = [blue, green, red])
      case self::PARSE_SIMPLE:
        // if the value looks like [a,b,c,...], interpret as array
        if(\preg_match('/^\[\s*.*?(?:\s*,\s*.*?)*\s*]$/', \trim($value)))
        {
          return \array_map('\trim', \explode(',', \trim(\trim($value), '[]')));
        }
      break;
    }
    
    return $value;
  }
  
  /**
   * @param array $array
   *
   * @return \ArrayObject|array
   */
  protected function getArrayValue(array $array = [])
  {
    if($this->use_array_object)
    {
      return new \ArrayObject($array, \ArrayObject::ARRAY_AS_PROPS);
    }
  
    return $array;
  }
}
