<?php
namespace nur\config;

use nur\A;
use nur\b\ValueException;
use nur\func;
use nur\md;
use nur\session;
use ReflectionClass;

/**
 * Class ConfigManager: gestion de la configuration de l'application
 */
class ConfigManager implements IConfigManager {
  /** @var array */
  private $configurators = [];

  function addConfigurator($configurators): void {
    $configurators = A::with($configurators);
    A::merge($this->configurators, $configurators);
  }

  /**
   * options par défaut pour filtrer les méthodes appelée dans le cadre de la
   * configuration. cette valeur peut être redéfinie dans une classe dérivée
   */
  const CONFIGURE_OPTIONS = ["prefix" => "configure"];

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

  function configure(?array $options=null): void {
    A::update_nx($options, static::CONFIGURE_OPTIONS);
    $configured =& $this->configured;
    foreach ($this->configurators as $key => $configurator) {
      A::ensure_array($configured[$key]);
      $done =& $configured[$key];
      $methods = func::get_all($configurator, $options);
      foreach ($methods as $method) {
        if (!in_array($method[1], $done)) {
          $args = null;
          func::fix_args($method, $args);
          func::call($method, ...$args);
          $done[] = $method[1];
        }
      }
    }
  }

  function resetConfiguration(): void {
    $this->configured = [];
  }

  #############################################################################

  /** liste d'aliases de classes */
  const CLASS_ALIASES = [
    "ref" => Ref::class,
    "mref" => MergeRef::class,
    "aref" => AppendRef::class,
    "pref" => PrependRef::class,
    "refs" => RefList::class,
  ];

  /**
   * si $class est un alias, retourner le nom effectif de la classe, sinon
   * retourner la valeur inchangée.
   *
   * la constante CLASS_ALIASES, qui peut être redéfinies dans les classes
   * dérivées, est utilisée pour faire le calcul.
   */
  protected function resolveClassAlias(string $class): string {
    return A::get(static::CLASS_ALIASES, $class, $class);
  }

  /** @var string[] liste de classes qui représentent des références */
  const REF_CLASSES = [
    Ref::class,
    MergeRef::class,
    AppendRef::class,
    PrependRef::class,
    RefList::class,
  ];

  protected function isRefClass(string $class): bool {
    $class = $this->resolveClassAlias($class);
    return in_array($class, static::REF_CLASSES);
  }

  /**
   * @var int indiquer que les tableaux ne sont jamais reconnus comme des
   * objets. il faut donc lister les clés de tous les objets dans OBJECT_PKEYS
   * et OBJECT_PKEY_PREFIXES pour qu'ils soient reconnus
   */
  const OBJECT_AUTODEF_FALSE = 0;

  /**
   * @var int indiquer que les tableaux de la forme appropriée sont toujours
   * reconnus comme des objets
   */
  const OBJECT_AUTODEF_TRUE = 1;

  /**
   * @var int indiquer que les tableaux ne sont reconnus comme des objets que
   * s'il s'agit de références. pour les autres objets, il faut lister les clés
   * dans OBJECT_PKEYS et OBJECT_PKEY_PREFIXES pour qu'ils soient reconnus
   */
  const OBJECT_AUTODEF_REF_ONLY = 2;

  /**
   * stratégie pour reconnaitre comme objets les tableaux de la forme appropriée
   */
  protected function OBJECT_AUTODEF(): int {
    return static::OBJECT_AUTODEF;
  } const OBJECT_AUTODEF = self::OBJECT_AUTODEF_REF_ONLY;

  /**
   * liste de chemins de clé à partir desquels il n'est pas nécessaire de
   * tester s'il faut retourner un objet
   */
  protected function NOT_OBJECT_PKEY_PREFIXES(): array {
    return static::NOT_OBJECT_PKEY_PREFIXES;
  } const NOT_OBJECT_PKEY_PREFIXES = [];

  function isNotObject(string $pkey): bool {
    $notObjectPrefixes = $this->NOT_OBJECT_PKEY_PREFIXES();
    foreach ($notObjectPrefixes as $prefix) {
      if ($pkey === $prefix) return true;
      $prefixLength = strlen($prefix) + 1;
      if (substr($pkey, 0, $prefixLength) == "${prefix}.") {
        return true;
      }
    }
    return false;
  }

  /**
   * liste de chemins de clé (sous forme d'associations $pkey => $class) pour
   * lesquels on doit retourner un objet. le chemin doit correspondre exactement
   * pour qu'on objet soit retourné.
   *
   * par exemple, pour le chemin "x.y.z", les chemins "x.y" et "x.y.z.t"
   * ne retournent pas d'objet. par contre "x.y.z" retourne un objet
   *
   * si $class === null, alors la valeur au chemin de clé doit être au format
   * [[$class], ...$args] pour pouvoir être reconnue par {@link newObject()}
   */
  protected function OBJECT_PKEYS(): array {
    return static::OBJECT_PKEYS;
  } const OBJECT_PKEYS = [];

  /**
   * liste de *préfixes* de chemin de clé (sous forme d'associations
   * $pkey_prefix => $class) pour lesquels on doit retourner un objet. le
   * préfixe n'est considéré que pour le niveau immédiatement suivant.
   *
   * par exemple, pour le préfixe "x.y", les chemins de clé "x.y" et "x.y.z.t"
   * ne retournent pas d'objet. par contre "x.y.z" retourne un objet
   *
   * si $class === null, alors la valeur au chemin de clé doit être au format
   * [[$class], ...$args] pour pouvoir être reconnue par {@link newObject()}
   */
  protected function OBJECT_PKEY_PREFIXES(): array {
    return static::OBJECT_PKEY_PREFIXES;
  } const OBJECT_PKEY_PREFIXES = [];

  function isObject($definition, ?string $pkey=null): bool {
    if ($pkey !== null) {
      # tester les exclusions
      if ($this->isNotObject($pkey)) return false;
      # tester l'égalité
      $objectPkeys = $this->OBJECT_PKEYS();
      if (array_key_exists($pkey, $objectPkeys)) return true;
      # tester les préfixes
      $objectPrefixes = $this->OBJECT_PKEY_PREFIXES();
      foreach ($objectPrefixes as $prefix => $class) {
        $prefixLength = strlen($prefix) + 1;
        if (substr($pkey, 0, $prefixLength) == "${prefix}.") {
          if (strpos($pkey, ".", $prefixLength + 1) === false) {
            return true;
          }
        }
      }
    }
    $objectAutodef = $this->OBJECT_AUTODEF();
    if ($objectAutodef == self::OBJECT_AUTODEF_FALSE) return false;
    # sinon, $definition *doit* être un array de la forme [[$class], $args...]
    # ou [[$class, $method], $args...]
    if (is_array($definition) && array_key_exists(0, $definition)) {
      $class = $definition[0];
      if (is_array($class)
        && count($class) == 1
        && array_key_exists(0, $class) && is_string($class[0])) {
        # [$class]
        return $objectAutodef != self::OBJECT_AUTODEF_REF_ONLY
          || $this->isRefClass($class[0]);
      }
      if (is_array($class)
        && count($class) == 2
        && array_key_exists(0, $class) && is_string($class[0])
        && array_key_exists(1, $class) && is_string($class[1])
      ) {
        # [$class, $method]
        return $objectAutodef != self::OBJECT_AUTODEF_REF_ONLY
          || $this->isRefClass($class[0]);
      }
    }
    return false;
  }

  /**
   * cette implémentation ne supporte que les définitions tableau.
   *
   * la syntaxe de définition est un peu plus étendue que celle reconnue par
   * is_object() pour supporter les définitions sélectionnées par un préfixe:
   * - [[$class], $args...] --> instanciation
   * - [$class, $args...] --> instanciation
   * - [[$class, $method], $args...] --> appel de méthode statique
   *
   * XXX ajouter le support d'un marqueur qui indique qu'un tableau N'EST PAS un
   * objet, genre [["NOT-AN-OBJECT"], content...], et cette méthode renvoie le
   * tableau sans le marqueur
   *
   * @throws ConfigException si $definition n'est pas un tableau
   */
  function newObject($definition, ?string $pkey=null) {
    #XXX supporter les définitions de classes de $OBJECT_PKEYS et $OBJECT_PKEY_PREFIXES
    if ($definition === null) {
      throw new ConfigException("definition must not be null");
    } elseif (!is_array($definition)) {
      throw new ConfigException("definition must be an array");
    }
    [$seq, $assoc] = A::split_assoc($definition);
    $class = A::get($seq, 0);
    if ($class === null) {
      throw new ConfigException("definition's class is missing");
    }
    $args = array_slice($seq, 1);
    if ($assoc !== null) $args[] = $assoc;
    if (is_array($class) && is_callable($class, true)) {
      # méthode [$class, $method]
      return func::call($class, ...$args);
    } else {
      # classe [$class] ou $class
      if (is_array($class)) $class = $class[0];
      $class = $this->resolveClassAlias($class);
      return func::cons($class, ...$args);
    }
  }

  #############################################################################

  /** @var string */
  protected $appcode = "NOT-SET";

  function initAppcode(string $appcode): void {
    $this->appcode = $appcode;
  }

  function getAppcode(): string {
    return $this->appcode;
  }

  protected $defaultProfile;

  /**
   * spécifier le profil à sélectionner par défaut si l'utilisateur n'en n'a pas
   * fourni
   */
  function setDefaultProfile(?string $defaultProfile): void {
    $this->defaultProfile = $defaultProfile;
  }

  /**
   * chaque clé de ce tableau est la configuration pour un profil.
   * la clé self::ALL_PROFILE représente tous les profils
   *
   * pour chaque profil, la valeur est une liste d'instances de DynConfig ou de
   * tableaux conformes au schéma CONFIG_SCHEMA
   *
   * @var array
   */
  protected $profileConfigs;

  /**
   * s'assurer que le tableau $configs a la structure appropriée à la liste des
   * profils spécifiés
   */
  private function ensureConfigs(string ...$profiles) {
    $profileConfigs =& $this->profileConfigs;
    if (!A::has($profileConfigs, self::PROFILE_ALL)) {
      # le profil par défaut doit exister
      $profiles[] = self::PROFILE_ALL;
    }
    foreach ($profiles as $profile) {
      A::ensure_array($profileConfigs[$profile]);
    }
  }

  function getProfiles(): array {
    if ($this->profileConfigs === null) {
      # initialiser si nécessaire
      $profiles = A::with(static::DEFAULT_PROFILES);
      # s'assurer que le tableau $configs contiennent les clés correspondant à
      # tous les profils
      $this->ensureConfigs(...$profiles);
    }
    $profiles = array_keys($this->profileConfigs);
    A::del_value($profiles, self::PROFILE_ALL);
    return $profiles;
  }

  const PROFILE_SESSION_KEY = "config:current_profile";
  const PROFILE_ENV_KEY = "APP_PROFILE";

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

  protected function initProfile(): string {
    $profile = getenv(self::PROFILE_ENV_KEY);
    if ($profile === false) $profile = null;
    if ($profile === null) $profile = $this->defaultProfile;
    if ($profile === null) $profile = A::get($this->getProfiles(), 0);
    if ($profile === null) $profile = self::PROD;
    $this->profile = $profile;
    if (session::started()) {
      session::set(self::PROFILE_SESSION_KEY, $profile);
    }
    return $profile;
  }

  function getProfile(bool $ensureNn=true): ?string {
    $profile = $this->profile;
    $update_session = false;
    if (session::started()) {
      $sprofile = session::get(self::PROFILE_SESSION_KEY);
      if ($sprofile !== $profile) $update_session = true;
      if ($sprofile !== null) $profile = $sprofile;
    }
    if ($profile === null) {
      if (!$ensureNn) return null;
      # initProfile() met à jour la session si nécessaire
      $profile = $this->initProfile();
    }
    if ($update_session) session::set(self::PROFILE_SESSION_KEY, $profile);
    return $profile;
  }

  function setProfile(string $profile): void {
    $this->profile = $profile;
    if (session::started()) {
      session::set(self::PROFILE_SESSION_KEY, $profile);
    }
  }

  #############################################################################

  const CONFIG_SCHEMA = [
    "schema" => [null, [], "définition des schémas"],
    "dbs" => [null, [], "définitions des paramètres de connexion"],
    "msgs" => [null, [], "messages de l'application"],
    "mails" => [null, [], "modèles de mail"],
    "app" => [null, [], "variables applicatives"],
    "user" => [null, [], "variables utilisateur"],
  ];

  private $cache = [];

  private final function resetCache(): void {
    $this->cache = [];
  }

  private final function cacheHas(string $pkey, string $profile) {
    $ckey = "$profile.$pkey";
    return array_key_exists($ckey, $this->cache);
  }
  private final function cacheGet(string $pkey, string $profile) {
    $ckey = "$profile.$pkey";
    return A::get($this->cache, $ckey);
  }
  private final function cacheSet(string $pkey, $value, string $profile): void {
    $ckey = "$profile.$pkey";
    $this->cache[$ckey] = $value;
  }

  function addConfig($config, ?array $inProfiles=null): void {
    if (is_string($config)) {
      $c = new ReflectionClass($config);
      if ($c->isSubclassOf(DynConfig::class)) {
        # si c'est une sous-classe de DynConfig, l'instancier
        $config = $c->newInstance();
      } else {
        # sinon, prendre les constantes directement dans la classe
        $config = [];
        foreach (self::CONFIG_SCHEMA as $key => $ignored) {
          $config[$key] = A::with($c->getConstant(strtoupper($key)));
        }
      }
    } elseif ($config instanceof DynConfig) {
      # l'ajouter tel quel
    } elseif (is_array($config)) {
      md::ensure_schema($config, self::CONFIG_SCHEMA, null, false);
    } else {
      throw new ValueException("config must be a class, an array, or an instance of ".DynConfig::class);
    }

    if ($this->profileConfigs === null) $this->getProfiles();
    if ($inProfiles !== null) $this->ensureConfigs(...$inProfiles);

    $profileConfigs =& $this->profileConfigs;
    if (!$inProfiles) $inProfiles = array_keys($profileConfigs);
    if (!$inProfiles) $inProfiles = [self::PROFILE_ALL];
    foreach ($inProfiles as $profile) {
      $profileConfigs[$profile][] = $config;
    }

    // vider le cache, la nouvelle configure peut changer les choses
    $this->resetCache();
  }

  private function resolveObjects(array &$array, string $pkey, string $profile): void {
    if ($this->isNotObject($pkey)) return;
    if ($this->isObject($array, $pkey)) {
      $array = $this->newObject($array, $pkey);
      # mettre en cache
      $this->cacheSet($pkey, $array, $profile);
      return;
    }
    foreach ($array as $key => &$value) {
      if (!is_array($value)) continue;
      $value_pkey = $pkey;
      if ($value_pkey != "") $value_pkey .= ".";
      $value_pkey .= $pkey;
      if ($this->cacheHas($value_pkey, $profile)) {
        $value = $this->cacheGet($value_pkey, $profile);
      } else {
        $this->resolveObjects($value, $value_pkey, $profile);
      }
    }; unset($value);
  }

  private function resolveRef(Ref $ref, string $pkey, string $profile) {
    return $ref->resolve($this, $pkey, $profile);
  }
  private function resolveRefs(array &$array, string $pkey, string $profile): void {
    foreach ($array as $key => &$value) {
      $value_pkey = $pkey;
      if ($value_pkey != "") $value_pkey .= ".";
      $value_pkey .= $pkey;
      if ($value instanceof Ref) {
        $value = $this->resolveRef($value, $value_pkey, $profile);
      } elseif (is_array($value)) {
        $this->resolveRefs($value, $value_pkey, $profile);
      }
    }; unset($value);
  }

  function _getValue(string $pkey, $default, string $inProfile) {
    ## obtenir la valeur brute
    if (strpos($pkey, ".") === false) {
      # pour les clés de premier niveau, il faut merger les valeurs de tous
      # les profils
      if ($inProfile === self::PROFILE_ALL) $profiles = [$inProfile];
      else $profiles = [self::PROFILE_ALL, $inProfile];
      $value = [];
      foreach ($profiles as $profile) {
        $configs = $this->profileConfigs[$profile];
        foreach ($configs as $config) {
          if ($config instanceof DynConfig) {
            # ignorer les configuration dynamiques
            continue;
          }
          A::merge($value, $config[$pkey]);
        }
      }
      $found = true;
    } else {
      # pour les clés à partir du deuxième niveau, prendre la valeur dans la première
      # config qui contient la clé
      if ($inProfile === self::PROFILE_ALL) $profiles = [$inProfile];
      else $profiles = [$inProfile, self::PROFILE_ALL];
      $value = null;
      $found = false;
      foreach ($profiles as $profile) {
        $configs = $this->profileConfigs[$profile];
        $count = count($configs);
        for ($i = $count - 1; $i >= 0; $i--) {
          # parcourir les configurations dans l'ordre inverse
          $config = $configs[$i];
          if ($config instanceof DynConfig) {
            if ($config->has($pkey, $profile)) {
              $found = true;
              $value = $config->get($pkey, $profile);
              break;
            }
          } else {
            if (A::phas_s($config, $pkey)) {
              $found = true;
              $value = A::pget_s($config, $pkey);
              break;
            }
          }
        }
        if ($found) break;
      }
    }

    if ($found) {
      ## résoudre les objets
      if (is_array($value)) $this->resolveObjects($value, $pkey, $inProfile);
      ## et les références
      if ($value instanceof Ref) $value = $this->resolveRef($value, $pkey, $inProfile);
      elseif (is_array($value)) $this->resolveRefs($value, $pkey, $inProfile);
    } else {
      $value = $default;
    }

    return $value;
  }

  function getValue(string $pkey, $default=null, ?string $inProfile=null) {
    if ($inProfile === null) $inProfile = $this->getProfile();

    # la valeur est-elle déjà en cache?
    if ($this->cacheHas($pkey, $inProfile)) {
      return $this->cacheGet($pkey, $inProfile);
    }

    # vérifier la validité du profil
    $profiles = $this->getProfiles();
    if (!in_array($inProfile, $profiles)) {
      # si le profil est invalide, prendre le profil par défaut
      $inProfile = self::PROFILE_ALL;
    }

    if (substr($pkey, 0, 5) === "conf.") {
      $confkey = substr($pkey, 5);
      $value = $this->_getValue("user.$confkey", null, $inProfile);
      if ($value === null) $value = $this->_getValue("app.$confkey", $default, $inProfile);
    } elseif ($pkey === "conf") {
      $value = $this->_getValue("app", null, $inProfile);
      A::merge($value, $this->_getValue("user", null, $inProfile));
    } else {
      $value = $this->_getValue($pkey, $default, $inProfile);
    }

    ## et pour finir, mettre la valeur en cache si nécessaire
    $this->cacheSet($pkey, $value, $inProfile);
    return $value;
  }

  protected $facts = [];

  function setFact(string $fact, $value=true): void {
    $this->facts[$fact] = $value;
  }

  function getFact(string $fact, $default=null) {
    return A::get($this->facts, $fact, $default);
  }

  function isFact(string $fact, $value=true): bool {
    return A::get($this->facts, $fact) === $value;
  }

  function isDebug(): bool {
    $debug = defined("DEBUG")? true: null;
    if ($debug === null) {
      $DEBUG = getenv("DEBUG");
      $debug = $DEBUG !== false? $DEBUG: null;
    }
    if ($debug === null) $debug = $this->getFact("debug");
    if ($debug === null) $debug = $this->getValue("conf.debug");
    return boolval($debug);
  }

  function setDebug(?bool $debug=true): void {
    $this->setFact("debug");
  }
}