<?php

namespace Grasmash\Expander;

use Dflydev\DotAccessData\Data;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * Class Expander
 * @package Grasmash\Expander
 */
class Expander implements LoggerAwareInterface
{
    /**
     * @var \Grasmash\Expander\StringifierInterface
     */
    protected StringifierInterface $stringifier;
    /**
     * @var \Psr\Log\LoggerInterface
     */
    protected LoggerInterface $logger;

    public function __construct()
    {
        $this->setLogger(new NullLogger());
        $this->setStringifier(new Stringifier());
    }

    /**
     * @return \Grasmash\Expander\StringifierInterface
     */
    public function getStringifier(): StringifierInterface
    {
        return $this->stringifier;
    }

    /**
     * @param \Grasmash\Expander\StringifierInterface $stringifier
     */
    public function setStringifier(StringifierInterface $stringifier)
    {
        $this->stringifier = $stringifier;
    }

    /**
     * @return \Psr\Log\LoggerInterface
     */
    public function getLogger(): LoggerInterface
    {
        return $this->logger;
    }

    /**
     * @param \Psr\Log\LoggerInterface $logger
     */
    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    /**
     * Expands property placeholders in an array.
     *
     * Placeholders should be formatted as ${parent.child}.
     *
     * @param array $array
     *   An array containing properties to expand.
     *
     * @return array
     *   The modified array in which placeholders have been replaced with
     *   values.
     */
    public function expandArrayProperties(array $array, $reference_array = []): array
    {
        $data = new Data($array);
        if ($reference_array) {
            $reference_data = new Data($reference_array);
            $this->doExpandArrayProperties($data, $array, '', $reference_data);
        } else {
            $this->doExpandArrayProperties($data, $array);
        }

        return $data->export();
    }

    /**
     * Performs the actual property expansion.
     *
     * @param Data $data
     *   A data object, containing the $array.
     * @param array $array
     *   The original, unmodified array.
     * @param string $parent_keys
     *   The parent keys of the current key in dot notation. This is used to
     *   track the absolute path to the current key in recursive cases.
     * @param Data|null $reference_data
     *   A reference data object. This is not operated upon but is used as a
     *   reference to provide supplemental values for property expansion.
     */
    protected function doExpandArrayProperties(
        Data   $data,
        array  $array,
        string $parent_keys = '',
        Data $reference_data = null
    ) {
        foreach ($array as $key => $value) {
            // Boundary condition(s).
            if ($value === null || is_bool($value)) {
                continue;
            }
            // Recursive case.
            if (is_array($value)) {
                $this->doExpandArrayProperties($data, $value, $parent_keys . "$key.", $reference_data);
            } else {
                // Base case.
                $this->expandStringProperties($data, $parent_keys, $reference_data, $value, $key);
            }
        }
    }

    /**
     * Expand a single property.
     *
     * @param Data $data
     *   A data object, containing the $array.
     * @param string $parent_keys
     *   The parent keys of the current key in dot notation. This is used to
     *   track the absolute path to the current key in recursive cases.
     * @param Data|null $reference_data
     *   A reference data object. This is not operated upon but is used as a
     *   reference to provide supplemental values for property expansion.
     * @param string $value
     *   The unexpanded property value.
     * @param string $key
     *   The immediate key of the property.
     *
     * @return mixed
     */
    protected function expandStringProperties(
        Data   $data,
        string $parent_keys,
        ?Data  $reference_data,
        string $value,
        string $key
    ): mixed {
        $pattern = '/\$\{([^\$}]+)\}/';
        // We loop through all placeholders in a given string.
        // E.g., '${placeholder1} ${placeholder2}' requires two replacements.
        while (str_contains((string) $value, '${')) {
            $original_value = $value;
            $value = preg_replace_callback(
                $pattern,
                function ($matches) use ($data, $reference_data) {
                    return $this->expandStringPropertiesCallback($matches, $data, $reference_data);
                },
                $value,
                -1,
                $count
            );

            // If the value was just a _single_ property reference, we have the opportunity to preserve the data type.
            if ($count === 1) {
                preg_match($pattern, $original_value, $matches);
                if ($matches[0] === $original_value) {
                    $value = $this->expandStringPropertiesCallback($matches, $data, $reference_data);
                }
            }

            // If no replacement occurred at all, break to prevent
            // infinite loop.
            if ($original_value === $value) {
                break;
            }

            // Set value on $data object.
            if ($parent_keys) {
                $full_key = $parent_keys . "$key";
            } else {
                $full_key = $key;
            }
            $data->set($full_key, $value);
        }
        return $value;
    }

    /**
     * Expansion callback used by preg_replace_callback() in expandProperty().
     *
     * @param array $matches
     *   An array of matches created by preg_replace_callback().
     * @param Data $data
     *   A data object containing the complete array being operated upon.
     * @param Data|null $reference_data
     *   A reference data object. This is not operated upon but is used as a
     *   reference to provide supplemental values for property expansion.
     *
     * @return mixed
     */
    public function expandStringPropertiesCallback(
        array $matches,
        Data  $data,
        Data $reference_data = null
    ): mixed {
        $property_name = $matches[1];
        $unexpanded_value = $matches[0];

        // Use only values within the subject array's data.
        if (!$reference_data) {
            return $this->expandProperty($property_name, $unexpanded_value, $data);
        } else {
            // Search both the subject array's data and the reference data for a value.
            return $this->expandPropertyWithReferenceData(
                $property_name,
                $unexpanded_value,
                $data,
                $reference_data
            );
        }
    }

  /**
   * Searches both the subject data and the reference data for value.
   *
   * @param string $property_name
   *   The name of the value for which to search.
   * @param string $unexpanded_value
   *   The original, unexpanded value, containing the placeholder.
   * @param Data $data
   *   A data object containing the complete array being operated upon.
   * @param Data|null $reference_data
   *   A reference data object. This is not operated upon but is used as a
   *   reference to provide supplemental values for property expansion.
   *
   * @return string|null The expanded string.
   *   The expanded string.
   */
    public function expandPropertyWithReferenceData(
        string $property_name,
        string $unexpanded_value,
        Data   $data,
        ?Data $reference_data
    ): ?string {
        $expanded_value = $this->expandProperty(
            $property_name,
            $unexpanded_value,
            $data
        );
        // If the string was not changed using the subject data, try using
        // the reference data.
        if ($expanded_value === $unexpanded_value) {
            $expanded_value = $this->expandProperty(
                $property_name,
                $unexpanded_value,
                $reference_data
            );
        }

        return $expanded_value;
    }

    /**
     * Searches a data object for a value.
     *
     * @param string $property_name
     *   The name of the value for which to search.
     * @param string $unexpanded_value
     *   The original, unexpanded value, containing the placeholder.
     * @param Data $data
     *   A data object containing possible replacement values.
     *
     * @return mixed
     */
    public function expandProperty(string $property_name, string $unexpanded_value, Data $data): mixed
    {
        if (str_starts_with($property_name, "env.") &&
          !$data->has($property_name)) {
            $env_key = substr($property_name, 4);
            if (isset($_SERVER[$env_key])) {
                $data->set($property_name, $_SERVER[$env_key]);
            } elseif (getenv($env_key)) {
                $data->set($property_name, getenv($env_key));
            }
        }

        if (!$data->has($property_name)) {
            $this->log("Property \${'$property_name'} could not be expanded.");
            return $unexpanded_value;
        } else {
            $expanded_value = $data->get($property_name);
            if (is_array($expanded_value)) {
                return $this->getStringifier()->stringifyArray($expanded_value);
            }
            $this->log("Expanding property \${'$property_name'} => $expanded_value.");
            return $expanded_value;
        }
    }

    /**
     * Logs a message using the logger.
     *
     * @param string $message
     *   The message to log.
     */
    public function log(string $message)
    {
        $this->getLogger()?->debug($message);
    }
}
