<?php
namespace nur\sery\php;

use Closure;
use nur\sery\cl;
use nur\sery\ref\php\ref_func;
use nur\sery\ValueException;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;

/**
 * Class func: outils pour appeler des fonctions et méthodes dynamiquement
 */
class nur_func {
  /**
   * tester si $func est une chaine de la forme "XXX::method" où XXX est une
   * chaine quelconque éventuellement vide, ou un tableau de la forme ["method"]
   * ou [anything, "method", ...]
   * 
   * Avec la forme tableau, "method" ne doit pas contenir le caractère '\', pour
   * pouvoir utiliser conjointement {@link is_class()}
   */
  static final function is_static($func, bool $allowClass=false): bool {
    if (is_string($func)) {
      $pos = strpos($func, "::");
      if ($pos === false) return false;
      return $pos + 2 < strlen($func);
    } elseif (is_array($func) && array_key_exists(0, $func)) {
      $count = count($func);
      if ($count == 1) {
        if (!is_string($func[0]) || strlen($func[0]) == 0) return false;
        if (strpos($func[0], "\\") !== false) return false;
        return true;
      } elseif ($count > 1) {
        if (!array_key_exists(1, $func)) return false;
        if (!is_string($func[1]) || strlen($func[1]) == 0) return false;
        if (strpos($func[1], "\\") !== false) return false;
        return true;
      }
    }
    return false;
  }

  /**
   * si $func est une chaine de la forme "::method" alors la remplacer par la
   * chaine "$class::method"
   *
   * si $func est un tableau de la forme ["method"] ou [null, "method"], alors
   * le remplacer par [$class, "method"]
   *
   * on assume que {@link is_static()}($func) retourne true
   *
   * @return bool true si la correction a été faite
   */
  static final function fix_static(&$func, $class): bool {
    if (is_object($class)) $class = get_class($class);

    if (is_string($func) && substr($func, 0, 2) == "::") {
      $func = "$class$func";
      return true;
    } elseif (is_array($func) && array_key_exists(0, $func)) {
      $count = count($func);
      if ($count == 1) {
        $func = [$class, $func[0]];
        return true;
      } elseif ($count > 1 && $func[0] === null) {
        $func[0] = $class;
        return true;
      }
    }
    return false;
  }

  /** tester si $method est une chaine de la forme "->method" */
  private static function isam($method): bool {
    return is_string($method)
      && strlen($method) > 2
      && substr($method, 0, 2) == "->";
  }

  /**
   * tester si $func est une chaine de la forme "->method" ou un tableau de la
   * forme ["->method", ...] ou [anything, "->method", ...]
   */
  static final function is_method($func): bool {
    if (is_string($func)) {
      return self::isam($func);
    } elseif (is_array($func) && array_key_exists(0, $func)) {
      if (self::isam($func[0])) {
        # ["->method", ...]
        return true;
      }
      if (array_key_exists(1, $func) && self::isam($func[1])) {
        # [anything, "->method", ...]
        return true;
      }
    }
    return false;
  }

  /**
   * si $func est une chaine de la forme "->method" alors la remplacer par le
   * tableau [$object, "method"]
   *
   * si $func est un tableau de la forme ["->method"] ou [anything, "->method"],
   * alors le remplacer par [$object, "method"]
   *
   * @return bool true si la correction a été faite
   */
  static final function fix_method(&$func, $object): bool {
    if (!is_object($object)) return false;

    if (is_string($func)) {
      if (self::isam($func)) {
        $func = [$object, substr($func, 2)];
        return true;
      }
    } elseif (is_array($func) && array_key_exists(0, $func)) {
      if (self::isam($func[0])) $func = array_merge([null], $func);
      if (count($func) > 1 && array_key_exists(1, $func) && self::isam($func[1])) {
        $func[0] = $object;
        $func[1] = substr($func[1], 2);
        return true;
      }
    }
    return false;
  }

  /**
   * si $func est un tableau de plus de 2 éléments, alors déplacer les éléments
   * supplémentaires au début de $args. par exemple:
   * ~~~
   * $func = ["class", "method", "arg1", "arg2"];
   * $args = ["arg3"];
   * func::fix_args($func, $args)
   * # $func === ["class", "method"]
   * # $args === ["arg1", "arg2", "arg3"]
   * ~~~
   *
   * @return bool true si la correction a été faite
   */
  static final function fix_args(&$func, ?array &$args): bool {
    if ($args === null) $args = [];
    if (is_array($func) && count($func) > 2) {
      $prefix_args = array_slice($func, 2);
      $func = array_slice($func, 0, 2);
      $args = array_merge($prefix_args, $args);
      return true;
    }
    return false;
  }

  /**
   * s'assurer que $func est un appel de méthode ou d'une méthode statique;
   * et renseigner le cas échéant les arguments. si $func ne fait pas mention
   * de la classe ou de l'objet, le renseigner avec $class_or_object.
   *
   * @return bool true si c'est une fonction valide. il ne reste plus qu'à
   * l'appeler avec {@link call()}
   */
  static final function check_func(&$func, $class_or_object, &$args=null): bool {
    if ($func instanceof Closure) return true;
    if (self::is_method($func)) {
      # méthode
      self::fix_method($func, $class_or_object);
      self::fix_args($func, $args);
      return true;
    } elseif (self::is_static($func)) {
      # méthode statique
      self::fix_static($func, $class_or_object);
      self::fix_args($func, $args);
      return true;
    }
    return false;
  }

  /**
   * Comme {@link check_func()} mais lance une exception si la fonction est
   * invalide
   *
   * @throws ValueException si $func n'est pas une fonction ou une méthode valide
   */
  static final function ensure_func(&$func, $class_or_object, &$args=null): void {
    if (!self::check_func($func, $class_or_object, $args)) {
      throw ValueException::invalid_type($func, "callable");
    }
  }

  static final function _prepare($func): array {
    $object = null;
    if (is_callable($func)) {
      if (is_array($func)) {
        $rf = new ReflectionMethod(...$func);
        $object = $func[0];
        if (is_string($object)) $object = null;
      } elseif ($func instanceof Closure) {
        $rf = new ReflectionFunction($func);
      } elseif (is_string($func) && strpos($func, "::") === false) {
        $rf = new ReflectionFunction($func);
      } else {
        $rf = new ReflectionMethod($func);
      }
    } elseif ($func instanceof ReflectionMethod) {
      $rf = $func;
    } elseif ($func instanceof ReflectionFunction) {
      $rf = $func;
    } elseif (is_array($func) && count($func) == 2 && isset($func[0]) && isset($func[1])
      && ($func[1] instanceof ReflectionMethod || $func[1] instanceof ReflectionFunction)) {
      $object = $func[0];
      if (is_string($object)) $object = null;
      $rf = $func[1];
    } elseif (is_string($func) && strpos($func, "::") === false) {
      $rf = new ReflectionFunction($func);
    } else {
      throw ValueException::invalid_type($func, "callable");
    }
    $minArgs = $rf->getNumberOfRequiredParameters();
    $maxArgs = $rf->getNumberOfParameters();
    $variadic = $rf->isVariadic();
    return [$rf instanceof ReflectionMethod, $object, $rf, $minArgs, $maxArgs, $variadic];
  }

  static final function _fill(array $context, array &$args): void {
    $minArgs = $context[3];
    $maxArgs = $context[4];
    $variadic = $context[5];
    if (!$variadic) $args = array_slice($args, 0, $maxArgs);
    while (count($args) < $minArgs) $args[] = null;
  }

  static final function _call($context, array $args) {
    self::_fill($context, $args);
    $use_object = $context[0];
    $object = $context[1];
    $method = $context[2];
    if ($use_object) {
      if (count($args) === 0) return $method->invoke($object);
      else return $method->invokeArgs($object, $args);
    } else {
      if (count($args) === 0) return $method->invoke();
      else return $method->invokeArgs($args);
    }
  }

  /**
   * Appeler la fonction spécifiée avec les arguments spécifiés.
   * Adapter $args en fonction du nombre réel d'arguments de $func
   *
   * @param callable|ReflectionFunction|ReflectionMethod $func
   */
  static final function call($func, ...$args) {
    return self::_call(self::_prepare($func), $args);
  }

  /** remplacer $value par $func($value, ...$args) */
  static final function apply(&$value, $func, ...$args): void {
    if ($func !== null) {
      if ($args) $args = array_merge([$value], $args);
      else $args = [$value];
      $value = self::call($func, ...$args);
    }
  }

  const MASK_PS = ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC;
  const MASK_P = ReflectionMethod::IS_PUBLIC;
  const METHOD_PS = ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC;
  const METHOD_P = ReflectionMethod::IS_PUBLIC;

  private static function matches(string $name, array $includes, array $excludes): bool {
    if ($includes) {
      $matches = false;
      foreach ($includes as $include) {
        if (substr($include, 0, 1) == "/") {
          # expression régulière
          if (preg_match($include, $name)) {
            $matches = true;
            break;
          }
        } else {
          # tester la présence de la sous-chaine
          if (strpos($name, $include) !== false) {
            $matches = true;
            break;
          }
        }
      }
      if (!$matches) return false;
    }
    foreach ($excludes as $exclude) {
      if (substr($exclude, 0, 1) == "/") {
        # expression régulière
        if (preg_match($exclude, $name)) return false;
      } else {
        # tester la présence de la sous-chaine
        if (strpos($name, $exclude) !== false) return false;
      }
    }
    return true;
  }

  /** @var Schema */
  private static $call_all_params_schema;

  /**
   * retourner la liste des méthodes de $class_or_object qui correspondent au
   * filtre $options. le filtre doit respecter le schéme {@link CALL_ALL_PARAMS_SCHEMA}
   */
  static function get_all($class_or_object, $params=null): array {
    Schema::nv($paramsv, $params, null
      , self::$call_all_params_schema, ref_func::CALL_ALL_PARAMS_SCHEMA);
    if (is_callable($class_or_object, true) && is_array($class_or_object)) {
      # callable sous forme de tableau
      $class_or_object = $class_or_object[0];
    }
    if (is_string($class_or_object)) {
      # lister les méthodes publiques statiques de la classe
      $mask = self::MASK_PS;
      $expected = self::METHOD_PS;
      $c = new ReflectionClass($class_or_object);
    } elseif (is_object($class_or_object)) {
      # lister les méthodes publiques de la classe
      $c = new ReflectionClass($class_or_object);
      $mask = $params["static_only"]? self::MASK_PS: self::MASK_P;
      $expected = $params["static_only"]? self::METHOD_PS: self::METHOD_P;
    } else {
      throw new ValueException("$class_or_object: vous devez spécifier une classe ou un objet");
    }
    $prefix = $params["prefix"]; $prefixlen = strlen($prefix);
    $args = $params["args"];
    $includes = $params["include"];
    $excludes = $params["exclude"];
    $methods = [];
    foreach ($c->getMethods() as $m) {
      if (($m->getModifiers() & $mask) != $expected) continue;
      $name = $m->getName();
      if (substr($name, 0, $prefixlen) != $prefix) continue;
      if (!self::matches($name, $includes, $excludes)) continue;
      $methods[] = cl::merge([$class_or_object, $name], $args);
    }
    return $methods;
  }

  /**
   * Appeler toutes les méthodes publiques de $object_or_class et retourner un
   * tableau [$method_name => $return_value] des valeurs de retour.
   */
  static final function call_all($class_or_object, $params=null): array {
    $methods = self::get_all($class_or_object, $params);
    $values = [];
    foreach ($methods as $method) {
      self::fix_args($method, $args);
      $values[$method[1]] = self::call($method, ...$args);
    }
    return $values;
  }

  /**
   * tester si $func est une chaine de la forme "XXX" où XXX est une classe
   * valide, ou un tableau de la forme ["XXX", ...]
   *
   * NB: il est possible d'avoir {@link is_static()} et {@link is_class()}
   * vraies pour la même valeur. s'il faut supporter les deux cas, appeler
   * {@link is_static()} d'abord, mais dans ce cas, on ne supporte que les
   * classes qui sont dans un package
   */
  static final function is_class($class): bool {
    if (is_string($class)) {
      return class_exists($class);
    } elseif (is_array($class) && array_key_exists(0, $class)) {
      return class_exists($class[0]);
    }
    return false;
  }

  /**
   * en assumant que {@link is_class()} est vrai, si $class est un tableau de
   * plus de 1 éléments, alors déplacer les éléments supplémentaires au début de
   * $args. par exemple:
   * ~~~
   * $class = ["class", "arg1", "arg2"];
   * $args = ["arg3"];
   * func::fix_class_args($class, $args)
   * # $class === "class"
   * # $args === ["arg1", "arg2", "arg3"]
   * ~~~
   *
   * @return bool true si la correction a été faite
   */
  static final function fix_class_args(&$class, ?array &$args): bool {
    if ($args === null) $args = [];
    if (is_array($class)) {
      if (count($class) > 1) {
        $prefix_args = array_slice($class, 1);
        $class = array_slice($class, 0, 1)[0];
        $args = array_merge($prefix_args, $args);
      } else {
        $class = $class[0];
      }
      return true;
    }
    return false;
  }

  /**
   * s'assurer que $class est une classe et renseigner le cas échéant les
   * arguments.
   *
   * @return bool true si c'est une classe valide. il ne reste plus qu'à
   * l'instancier avec {@link cons()}
   */
  static final function check_class(&$class, &$args=null): bool {
    if (self::is_class($class)) {
      self::fix_class_args($class, $args);
      return true;
    }
    return false;
  }

  /**
   * Comme {@link check_class()} mais lance une exception si la classe est
   * invalide
   *
   * @throws ValueException si $class n'est pas une classe valide
   */
  static final function ensure_class(&$class, &$args=null): void {
    if (!self::check_class($class, $args)) {
      throw ValueException::invalid_type($class, "class");
    }
  }

  /**
   * Instancier la classe avec les arguments spécifiés.
   * Adapter $args en fonction du nombre réel d'arguments du constructeur
   */
  static final function cons(string $class, ...$args) {
    $c = new ReflectionClass($class);
    $rf = $c->getConstructor();
    if ($rf === null) {
      return $c->newInstance();
    } else {
      if (!$rf->isVariadic()) {
        $minArgs = $rf->getNumberOfRequiredParameters();
        $maxArgs = $rf->getNumberOfParameters();
        $args = array_slice($args, 0, $maxArgs);
        while (count($args) < $minArgs) {
          $args[] = null;
        }
      }
      return $c->newInstanceArgs($args);
    }
  }
}
