<?php
namespace nur;

use nur\b\ValueException;
use nur\ref\ref_type;
use Traversable;

/**
 * Class md: gestion de métadonnées. cette classe est une version simplifiée de
 * {@link Metadata}
 */
class md {
  /**
   * fonction de support pour tester si une définition de type désigne une valeur
   * nullable. retourner true si la valeur est nullable, false sinon.
   * $type est mis à jour pour enlever le marqueur "?" le cas échéant
   */
  static final function check_nullable(&$type): bool {
    if (is_string($type) && substr($type, 0, 1) === "?") {
      $type = substr($type, 1);
      return true;
    } else {
      return $type === null;
    }
  }

  /**
   * @var array méta-schéma, i.e schéma d'un champ de schéma
   *
   * A propos des types: en temps normal, quand il s'agit de valider les valeurs
   * d'un objet par rapport à un schéma, les valeurs suivantes sont spéciales:
   * - false: indiquer que le champ n'est pas spécifié, il obtient donc la
   * valeur par défaut
   * - null: indiquer que la champ a une valeur et que cette valeur est null
   *
   * Cependant, certains types modifient ce comportement:
   * - bool: la valeur false n'a pas de signification particulière. la valeur
   * null indique que le champ n'est pas spécifié et doit recevoir la valeur
   * par défaut
   * - mixed|?bool: les valeurs false et null n'ont pas de signification
   * particulières elles sont donc reprises telles quelles
   * - array|?array: la valeur est un tableau. le schéma de la valeur est
   * pris en compte s'il est spécifié
   * - array[]|?array[]: la valeur est une *liste* de tableaux. le schéma de
   * chacun des éléments du tabvleau est pris en compte s'il est spécifié.
   *
   * Pour les types bool, int, double, float, string, array (et les variantes
   * ?type qui autorisent la valeur null), la valeur est transformée dans le
   * type demandé
   */
  const MSFIELD_SCHEMA = [
    "type" => [
      "type" => "mixed",
      "default" => null,
      "title" => "type PHP de la valeur",
      "required" => false,
      "key" => "type",
      "header" => "type",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "default" => [
      "type" => "mixed",
      "default" => null,
      "title" => "valeur par défaut du champ",
      "required" => false,
      "key" => "default",
      "header" => "default",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "title" => [
      "type" => "?string",
      "default" => null,
      "title" => "libellé de la valeur utilisé par exemple dans un formulaire",
      "required" => false,
      "key" => "title",
      "header" => "title",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "required" => [
      "type" => "bool",
      "default" => false,
      "title" => "cette valeur est-elle requise?",
      "required" => false,
      "key" => "required",
      "header" => "required",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "key" => [
      "type" => "?string",
      "default" => null,
      "title" => "nom de la clé",
      "required" => false,
      "key" => "key",
      "header" => "key",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "header" => [
      "type" => "?string",
      "default" => null,
      "title" => "nom de l'en-tête s'il fallait présenter cette donnée dans un tableau",
      "required" => false,
      "key" => "header",
      "header" => "header",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "desc" => [
      "type" => "?string",
      "default" => null,
      "title" => "description de la valeur",
      "required" => false,
      "key" => "desc",
      "header" => "desc",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "schema" => [
      "type" => "?array",
      "default" => null,
      "title" => "schema de la donnée",
      "required" => false,
      "key" => "schema",
      "header" => "schema",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
    "composite" => [
      "type" => "bool",
      "default" => false,
      "title" => "ce champ fait-il partie d'une valeur composite?",
      "required" => false,
      "key" => "composite",
      "header" => "composite",
      "desc" => null,
      "schema" => null,
      "composite" => false,
    ],
  ];
  const MSFIELD_INDEXES = ["type" => 0, "default" => 1, "title" => 2, "required" => 3, "key" => 4, "header" => 5, "desc" => 6, "schema" => 7, "composite" => 8];
  const ARRAY_TYPES = ["array", "?array", "array[]", "?array[]"];
  const APPLY2ITEMS_TYPES = ["array[]", "?array[]"];

  static final function normalize_schema(array &$schema, bool $recursive=true): array {
    foreach ($schema as $key => &$msfield) {
      if ($key === "") continue;
      self::_ensure_msfield($msfield, $key);

      $is_array_type = in_array($msfield["type"], self::ARRAY_TYPES);
      if ($msfield["schema"] !== null) {
        if (!$is_array_type) $msfield["schema"] = null;
        elseif ($recursive) self::normalize_schema($msfield["schema"], $recursive);
      }
    }; unset($msfield);
    return array_flip(array_keys($schema));
  }

  /** s'assurer que $msfield est un champ de méta-schéma conforme */
  static final function _ensure_msfield(&$msfield, $key=null): void {
    if (!is_array($msfield)) {
      $msfield = [
        "type" => $msfield, "default" => null, "title" => $key, "required" => false,
        "key" => $key, "header" => $key,
        "desc" => null, "schema" => null, "composite" => false,
      ];
      return;
    }
    if ($key !== null) $msfield["key"] = $key;
    self::_ensure_schema($msfield, self::MSFIELD_SCHEMA, self::MSFIELD_INDEXES, false);
    if ($msfield["title"] === null) $msfield["title"] = $msfield["key"];
    if ($msfield["header"] === null) $msfield["header"] = $msfield["title"];
    if ($msfield["schema"] !== null) {
      foreach ($msfield["schema"] as $key2 => &$msfield2) {
        self::_ensure_msfield($msfield2, $key2);
      }; unset($msfield2);
    }
  }

  /**
   * s'assurer que
   * - $sfield est un tableau
   * - il contient toutes les clés du schéma, en provisionnant les valeurs par
   *   défaut à chaque fois que c'est nécessaire
   * - les valeurs sont du bon type. seules les types standards listés dans
   * {@link KNOWN_TYPES} sont considérés
   *
   * notez que:
   * - les clés ne sont pas réordonnées
   * - la présence de tous les champs requis n'est pas vérifiée par défaut
   * (utiliser {@link check_required()} pour cela)
   * - seuls les types simples sont supportés. notamment, les types composites
   * ne sont pas supportés
   * - les types "éléments de liste" ne sont pas supportés
   */
  static final function ensure_schema(&$item, ?array $schema, $item_key=null, bool $check_required=false): void {
    if ($schema !== null) {
      $indexes = self::normalize_schema($schema);
      self::_ensure_array_item($item, $schema, $item_key);
      self::_ensure_schema_recursive($item, $schema, $indexes);
      if ($check_required) self::check_required($item, $schema);
    } else {
      self::_ensure_array_item($item, $schema, $item_key);
    }
  }

  private static function _ensure_array_item(&$item, ?array $schema, $item_key): void {
    if ($item_key !== null) {
      if (is_array($item)) {
        if ($schema !== null) {
          # n'utiliser item_key que si la première clé du schéma n'existe pas
          $first_key = array_key_first($schema);
          if (!array_key_exists($first_key, $item)) {
            $tmp = [$item_key];
            A::merge3($tmp, $item);
            $item = $tmp;
          }
        }
      } else {
        $item = [$item_key, $item];
      }
    } elseif ($item !== null && !is_array($item)) {
      $item = [$item];
    }
  }

  private static function _ensure_schema_recursive(&$item, array $schema, ?array $indexes=null): void {
    self::_ensure_schema($item, $schema, $indexes);
    foreach ($schema as $key => $sfield) {
      $schema2 = $sfield["schema"];
      if ($schema2 === null) continue;
      switch ($sfield["type"]) {
      case "?array":
        if ($item[$key] === null) continue 2;
      case "array":
        self::_ensure_array_item($item[$key], $schema2, null);
        self::_ensure_schema_recursive($item[$key], $schema2);
        break;
      case "?array[]":
        if ($item[$key] === null) continue 2;
      case "array[]":
        $index2 = 0;
        foreach ($item[$key] as $key2 => &$item2) {
          if ($key2 === $index2) {
            $index2++;
            $key2 = null;
          }
          self::_ensure_array_item($item2, $schema2, $key2);
          self::_ensure_schema_recursive($item2, $schema2);
        }; unset($item2);
        break;
      }
    }
  }

  /**
   * s'assurer que $item est conforme au schéma $schema.
   *
   * on assume que $schema est un schéma normalisé. $indexes est reconstruit si
   * nécessaire
   */
  static final function _ensure_schema(&$item, array $schema, ?array $indexes=null, bool $ensure_type=true): void {
    $keys = array_keys($schema);
    if ($indexes === null) $indexes = array_flip($keys);
    if ($item === null) $item = [];
    elseif (!is_array($item)) $item = [$item];

    $src = $item;
    $dones = array_fill(0, count($keys), false);
    # d'abord les clés associatives
    $inputIndex = 0;
    foreach ($src as $key => $value) {
      if ($key === $inputIndex) {
        # clé séquentielle
        $inputIndex++;
      } else {
        # clé associative
        $is_schema_key = array_key_exists($key, $schema);
        if ($ensure_type && $is_schema_key) {
          $sfield = $schema[$key];
          self::_ensure_type($sfield["type"], $value, $sfield["default"], true);
        }
        $item[$key] = $value;
        if ($is_schema_key) $dones[$indexes[$key]] = true;
      }
    }
    # ensuite les clés séquentielles
    $inputIndex = 0;
    $outputIndex = 0;
    foreach ($src as $index => $value) {
      if ($index === $inputIndex) {
        # clé séquentielle
        $inputIndex++;
        unset($item[$index]);
        $found = false;
        foreach ($keys as $kindex => $key) {
          if (!$dones[$kindex]) {
            $sfield = $schema[$key];
            if ($ensure_type) {
              self::_ensure_type($sfield["type"], $value, $sfield["default"], true);
            }
            $item[$key] = $value;
            $dones[$kindex] = true;
            $found = true;
            break;
          }
        }
        if (!$found) {
          $item[$outputIndex++] = $value;
        }
      }
    }
    # puis mettre les valeurs par défaut des clés qui restent
    foreach ($dones as $dindex => $done) {
      if (!$done) {
        $key = $keys[$dindex];
        $sfield = $schema[$key];
        $value = $sfield["default"];
        if ($ensure_type) {
          self::_ensure_type($sfield["type"], $value, $sfield["default"], false);
        }
        $item[$key] = $value;
      }
    }
  }

  /**
   * fonction de support pour s'assurer que $value est dans le bon type. seuls
   * les types simples sont reconnus. s'il s'agit d'un type complexe, la valeur
   * n'est pas vérifiée ni modifiée
   */
  private static function _ensure_type(?string $type, &$value, $default, bool $exists): void {
    if (self::_check_known_type($type, $value, $default, $exists)) {
      self::_convert_value($type, $value);
    }
  }

  /**
   * fonction de support pour vérifier que $type est un type simple dans lequel
   * on peut convertir la valeur. s'il est possible de convertir la valeur, le
   * faire
   * - retourner false s'il n'est pas nécessaire de faire plus de traitement. la
   * valeur est déjà dans le bon type (ou alors c'est un type complexe et il
   * n'est pas possible ici de convertir la valeur)
   * - retourner true s'il faut convertir la valeur avec {@link _convert_value()}
   */
  static final function _check_known_type(&$type, &$value, $default, bool $exists): bool {
    $nullable = true;
    if (!is_array($type)) $nullable = self::check_nullable($type);
    if ($type === ref_type::ANY) {
      # Le type null est particulier: false correspondant à non existant
      if (!$exists || $value === false) $value = $default;
      return false;
    } elseif ($type === ref_type::MIXED) {
      # Le type mixed est particulier: la valeur est prise en l'état sans aucune
      # modification
      if (!$exists) $value = $default;
      return false;
    }
    if (!is_array($type)) $type = A::get(ref_type::ALIASES, $type, $type);
    if (is_array($type) || !in_array($type, ref_type::KNOWN_TYPES)) {
      if (!$exists) $value = $default;
      return false;
    }

    if ($type === ref_type::BOOL) {
      # avec bool, null fonctionne comme "non présent"
      if (!$exists || (!$nullable && $value === null)) $value = $default;
    } elseif (!$exists || $value === false || (!$nullable && $value === null)) {
      $value = $default;
    }
    if ($value === null && $nullable) return false;

    return true;
  }

  const FALSE_VALUES = ["false", "faux", "non", "no", "f", "n", "0", ""];

  /**
   * fonction de support pour convertir la valeur dans le type simple spécifié
   */
  static final function _convert_value(string $type, &$value): void {
    switch ($type) {
    case ref_type::BOOL:
      if (!is_string($value)) $value = boolval($value);
      else $value = !in_array(strtolower(trim($value)), self::FALSE_VALUES);
      return;
    case ref_type::INT:
      $value = intval($value);
      return;
    case ref_type::FLOAT:
      $value = floatval($value);
      return;
    case ref_type::RAWSTRING:
      if (is_array($value)) $value = str::join3($value);
      elseif (!is_string($value)) $value = strval($value);
      return;
    case ref_type::STRING:
    case ref_type::TEXT:
      if (is_array($value)) $value = str::join3($value);
      elseif (!is_string($value)) $value = strval($value);
      $value = str::norm_nl(trim($value));
      return;
    case ref_type::KEY:
      if (!is_int($value)) {
        if (is_array($value)) $value = str::join3($value, ".");
        $value = strval($value);
        if (preg_match('/^[0-9]+$/', $value)) {
          $value = intval($value);
        }
      }
      return;
    case ref_type::CONTENT:
      $value = c::qnz($value);
      return;
    case ref_type::FILE:
    case ref_type::ARRAY:
      A::ensure_array($value);
      return;
    case ref_type::ARRAY_ARRAY:
      A::ensure_array($value);
      foreach ($value as &$item) {
        A::ensure_array($item);
      }; unset($item);
      return;
    case ref_type::ITERABLE:
      if (!($value instanceof Traversable)) {
        A::ensure_array($value);
      }
      return;
    case ref_type::RESOURCE:
      if (!is_resource($value)) {
        throw ValueException::invalid_value($value, "resource");
      }
    }
  }

  /**
   * vérifier que tous les champs marqués comme requis dans le schéma n'ont pas
   * une valeur null. sinon lancer une exception {@link ValueException}
   *
   * on assume que $item est conforme au schéma
   */
  static final function check_required(array $item, ?array $schema): void {
    if ($schema === null) return;
    self::normalize_schema($schema);
    self::_check_required($item, $schema);
  }

  /** comme {@link check_required()} mais $schema doit être déjà normalisé */
  static final function _check_required(array $item, array $schema, string $key_prefix=""): void {
    foreach ($schema as $key => $sfield) {
      $exists = array_key_exists($key, $item) && $item[$key] !== null;
      if ($sfield["required"] && !$exists) {
        throw new ValueException("$key_prefix$key is required");
      }
      $schema2 = $sfield["schema"];
      if ($schema2 !== null && $exists) {
        #XXX si le type est array[], il faut vérifier sur chacun des éléments!
        self::_check_required($item[$key], $schema2, "$key_prefix$key.");
      }
    }
  }

  /**
   * obtenir la valeur de la clé $key depuis $item en tenant compte des
   * informations du schéma. si la clé n'existe pas, retourner la valeur par
   * défaut: soit $default s'il n'est pas null, soit la valeur par défaut du
   * schéma.
   *
   * $item n'a pas besoin d'être conforme au schéma: il n'est pas nécessaire
   * que toutes les clés soient présentes, et $item peut être un tableau
   * séquentiel
   *
   * XXX si le type n'est pas bool, interpréter false comme "non présent"
   */
  static final function has($item, $key, ?array $schema): bool {
    if ($schema === null) return $key === 0;
    $indexes = self::normalize_schema($schema);
    return self::_has($item, $key, $schema, $indexes);
  }

  /** comme {@link has()} mais $schema doit être déjà normalisé */
  static final function _has($item, $key, array $schema, ?array $indexes=null) {
    if (!array_key_exists($key, $schema)) {
      return is_array($item) && array_key_exists($key, $item);
    }
    if (!is_array($item)) $item = [$item];
    if (array_key_exists($key, $item)) return true;
    if ($indexes === null) $indexes = array_flip(array_keys($schema));
    $index = $indexes[$key];
    return array_key_exists($index, $item);
  }

  /**
   * obtenir la valeur de la clé $key depuis $item en tenant compte des
   * informations du schéma. si la clé n'existe pas, retourner la valeur par
   * défaut: soit $default s'il n'est pas null, soit la valeur par défaut du
   * schéma.
   *
   * $item n'a pas besoin d'être conforme au schéma: il n'est pas nécessaire
   * que toutes les clés soient présentes, et $item peut être un tableau
   * séquentiel
   */
  static final function get($item, $key, ?array $schema, $default=null, bool $ensure_type=true) {
    if ($schema === null) return $key === 0? $item: null;
    $indexes = self::normalize_schema($schema);
    return self::_get($item, $key, $schema, $default, $ensure_type, $indexes);
  }

  /** comme {@link get()} mais $schema doit être déjà normalisé */
  static final function _get($item, $key, array $schema, $default=null, bool $ensure_type=true, ?array $indexes=null) {
    if (!array_key_exists($key, $schema)) {
      if (is_array($item) && array_key_exists($key, $item)) {
        return $item[$key];
      } else {
        return $default;
      }
    }
    $sfield = $schema[$key];

    if (!is_array($item)) $item = [$item];
    if (array_key_exists($key, $item)) {
      $exists = true;
      $value = $item[$key];
    } else {
      if ($indexes === null) $indexes = array_flip(array_keys($schema));
      $index = $indexes[$key];
      if (array_key_exists($index, $item)) {
        $exists = true;
        $value = $item[$index];
      } elseif ($default !== null) {
        $exists = true;
        $value = $default;
      } else {
        $exists = false;
        $value = $sfield["default"];
      }
    }

    if ($ensure_type) {
      self::_ensure_type($sfield["type"], $value, $sfield["default"], $exists);
      $schema2 = $sfield["schema"];
      if ($schema2 !== null) self::_ensure_schema_recursive($value, $schema2);
    }
    return $value;
  }

  /**
   * sélectionner dans $items les valeurs du schéma, et les retourner dans
   * l'ordre du schéma
   *
   * $item doit être un tableau associatif. idéalement, il a été au préalable
   * rendu conforme au schéma
   *
   * si $item n'est pas conforme au schéma, les champs ne sont reconnus que si
   * l'on utilise les clés associatives. il n'est pas nécessaire que toutes les
   * clés du schéma soient présentes. dans ce cas, seuls les clés présentes sont
   * dans le tableau résultat. dans ce cas de figure, $ensure_type==true permet
   * de s'assurer aussi que les valeurs sont dans le bon type
   */
  static final function get_values(?array $item, ?array $schema, bool $ensure_type=false): array {
    if ($item === null || $schema === null) return [];
    self::normalize_schema($schema);
    return self::_get_values($item, $schema, $ensure_type);
  }

  /** comme {@link get_values()} mais $schema doit être déjà normalisé */
  static final function _get_values(array $item, array $schema, bool $ensure_type=false): array {
    $values = [];
    foreach ($schema as $key => $sfield) {
      if (!array_key_exists($key, $item)) continue;
      $value = $item[$key];
      if ($ensure_type) {
        self::_ensure_type($sfield["type"], $value, $sfield["default"], true);
      }
      $values[$key] = $value;
    }
    return $values;
  }

  /**
   * complément de {@link get_values()}: retourner les clés qui n'ont pas été
   * sélectionnées
   */
  static final function get_others(?array $item, ?array $schema): array {
    if ($item === null) return [];
    elseif ($schema === null) return $item;
    self::normalize_schema($schema);
    return self::_get_others($item, $schema);
  }

  /** comme {@link get_others()} mais $schema doit être déjà normalisé */
  static final function _get_others(array $item, array $schema): array {
    $others = [];
    foreach ($item as $key => $value) {
      if (array_key_exists($key, $schema)) continue;
      $others[$key] = $value;
    }
    return $others;
  }
}
