<?php
namespace nur\ldap;

use ArrayAccess;
use Countable;
use nur\A;
use nur\b\IllegalAccessException;
use nur\ldap\syntaxes\CompositeSyntax;
use nur\ldap\syntaxes\StringSyntax;

/**
 * Class LdapObject: un objet LDAP
 */
class LdapObject implements ArrayAccess, Countable {
  static function with(?string $dn, ?array $entry): ?self {
    if ($entry === null) return null;
    else return (new self())->load($dn, $entry);
  }

  /** @var string[] liste des classes par défaut lors de la création de l'objet */
  const OBJECT_CLASSES = ["top"];
  /** @var string DN dans lequel cet objet est créé par défaut */
  const PARENT_RDN = null;
  /**
   * @var array|string nom des attribut(s) utilisé(s) pour nommer cet objet par
   * défaut
   */
  const DN_NAMES = null;

  function __construct(?string $dn=null, ?array $attrs=null, ?array $initialNames=null, ?LdapConn $conn=null) {
    $this->reset($dn, $attrs, A::with($initialNames), $conn);
  }

  /** @var LdapConn */
  protected $conn;

  function getConn(): LdapConn {
    return $this->conn;
  }

  /** @var array attributs initialement demandés lors de la recherche */
  protected $initialNames;

  protected function initialNames(): array {
    return $this->initialNames;
  }

  /** @var array valeurs originale des attributs avant modification */
  protected $orig;

  /** @var array */
  protected $data;

  /** @var array */
  protected $lkey2names;

  /** @var array liste des attributs utilisés pour nommer l'objet */
  protected $dnNames;

  /**
   * @var LdapAttr[] pour chaque attribut, l'instance de {@link LdapAttr} qui
   * gère les valeurs correspondantes de $data
   */
  protected $attrs;

  protected function resetAttrs(): void {
    # refaire les attributs le cas échéant
    if ($this->attrs === null) return;
    foreach (array_keys($this->data) as $name) {
      if (array_key_exists($name, $this->attrs)) {
        $this->attrs[$name]->reset($this->data[$name]);
      }
    }
  }

  private function n($key): string {
    $lkey = strtolower(strval($key));
    $name = A::get($this->lkey2names, $lkey);
    if ($name === null) {
      # si $key n'existe pas, l'ajouter
      $name = $this->lkey2names[$lkey] = $key;
    }
    return $name;
  }

  function &array(): ?array { return $this->data; }
  function count(): int { return count($this->data); }
  function keys(): array { return array_keys($this->data); }
  function has($name): bool {
    return $this->data !== null && array_key_exists($this->n($name), $this->data);
  }
  function _get(string $name): LdapAttr {
    $name = $this->n($name);
    if ($this->attrs === null || !array_key_exists($name, $this->attrs)) {
      $attribute = A::get(static::SCHEMA(), strtolower($name));
      if ($attribute !== null && $this->conn !== null) {
        ["class" => $class, "flags" => $flags] = $attribute;
        $syntax = $this->conn->getSyntax($class);
      } else {
        $syntax = $flags = null;
      }
      if ($syntax !== null) {
        $attr = $syntax->newAttr($name, $this->data[$name], $flags);
      } else {
        $attr = new LdapAttr($name, $this->data[$name], $syntax, $flags);
      }
      $this->attrs[$name] = $attr;
    }
    return $this->attrs[$name];
  }
  function _del(string $name): void {
    unset($this->data[$this->n($name)]);
  }
  function get($name) { return $this->_get($name)->get(); }
  function first($name) { return $this->_get($name)->first(); }
  function all($name): iterable { return $this->_get($name)->all(); }
  function set($name, $values, bool $unlessNn=false): self { $this->_get($name)->set($values, $unlessNn); return $this; }
  function add($name, $value, bool $unique=true): self { $this->_get($name)->add($value, $unique); return $this; }
  function del($name, $value, int $maxCount=-1, bool $strict=false): self { $this->_get($name)->del($value, $maxCount, $strict); return $this; }
  function ins($name, int $index, $value): self { $this->_get($name)->ins($index, $value); return $this; }
  function unset($name, int $index): self { $this->_get($name)->unset($index); return $this; }
  function merge(?array $attrs): self {
    if ($attrs !== null) {
      foreach ($attrs as $name => $values) {
        $this->set($name, $values);
      }
    }
    return $this;
  }

  function offsetExists($key) { return $this->has($key); }
  function offsetGet($key) { return $this->_get($key)->get(); }
  function offsetSet($key, $value) { $this->_get($key)->set($value); }
  function offsetUnset($key) { $this->_del($key);    }

  function __isset($key) { return $this->has($key); }
  function __get($key) { return $this->_get($key)->get(); }
  function __set($key, $value) { $this->_get($key)->set($value); }
  function __unset($key) { $this->_del($key); }

  /**
   * initialiser cet objet avec des données construites à la volée.
   * - si $dn === null, c'est un nouvel objet
   * - sinon c'est un objet existant déjà dans LDAP
   */
  function reset(?string $dn, ?array $attrs=null, ?array $initialNames=null, ?LdapConn $conn=null): self {
    if ($conn !== null) $this->conn = $conn;
    if ($initialNames !== null) $this->initialNames = $initialNames;
    # attributs demandés
    $lkey2names = ["dn" => "dn"];
    foreach ($this->initialNames() as $name) {
      if ($name == "+" || $name == "*") continue;
      $lkey2names[strtolower($name)] = $name;
    }
    # attributs obtenus effectivement
    A::merge_nn($attrs, [
      "objectClass" => static::OBJECT_CLASSES,
    ]);
    $orig = ["dn" => [$dn]];
    foreach ($attrs as $name => $value) {
      $orig[$name] = $value;
      $lkey2names[strtolower($name)] = $name;
    }
    # ensuite, mettre à null les attributs qui n'ont pas été obtenus
    foreach ($lkey2names as $name) {
      if (!array_key_exists($name, $orig)) {
        $orig[$name] = null;
      }
    }
    # calculer les clés qui composent le DN
    $dnNames = names::get_dn_names($dn, $lkey2names);
    # finaliser le paramétrage
    $this->data = $this->orig = $orig;
    $this->lkey2names = $lkey2names;
    $this->dnNames = $dnNames;
    $this->resetAttrs();
    return $this;
  }

  /** initialiser cet objet avec le résultat d'une recherche */
  function load(string $dn, array $entry): self {
    [$this->orig, $this->lkey2names, $this->dnNames,
    ] = LdapSearch::cook($this->initialNames(), $dn, $entry);
    $this->data = $this->orig;
    $this->resetAttrs();
    return $this;
  }

  /** recharger l'objet depuis le serveur */
  function reload(?LdapConn $conn=null): self {
    if ($conn === null) $conn = $this->conn;
    $dn = $this->data["dn"][0];
    $entry = $conn->_search($dn, [
      "attrs" => $this->initialNames(),
      "scope" => "base",
    ])->first($dn);
    if ($entry === null) {
      throw new IllegalAccessException("object $dn no longer exists");
    }
    return $this->load($dn, $entry);
  }

  function initDn(?string $parentDn=null, $dnNames=null, ?LdapConn $conn=null): void {
    if ($conn === null) $conn = $this->conn;
    if ($parentDn === null) $parentDn = static::PARENT_RDN;
    if ($conn !== null) $parentDn = $conn->ensureDn($parentDn);
    if ($dnNames === null) $dnNames = static::DN_NAMES;
    $rdn = [];
    foreach (A::with($dnNames) as $name) {
      $rdn[$name] = $this->get($name);
    }
    $dn = names::join($rdn, $parentDn);
    $this->data["dn"] = [$dn];
    $this->dnNames = names::get_dn_names($dn, $this->lkey2names);
  }

  function computeAddattrs(array $data): array {
    $attrs = [];
    $first = true;
    foreach ($data as $name => $values) {
      if ($first) {
        # ne pas inclure le DN
        $first = false;
        continue;
      }
      # ne pas inclure les valeurs vides et nulles
      if ($values === null || $values === []) continue;
      # utiliser array_values pour être sûr d'avoir un tableau séquentiel (les
      # valeurs composites sont indexées sur la clé calculée)
      $attrs[$name] = array_values(A::with($values));
    }
    return $attrs;
  }
  function computeModattr(string $name, $orig, $value): array {
    # utiliser array_values pour être sûr d'avoir un tableau séquentiel (les
    # valeurs composites sont indexées sur la clé calculée)
    $orig = array_values(A::with($orig));
    $value = array_values(A::with($value));
    if ($value === $orig) return [];
    if (!$orig) return [["add", $name => $value]];
    elseif (!$value) return [["delete", $name]];
    else return [["replace", $name => $value]];
    #XXX pour certains attributs (comme member), ou si le nombre d'éléments
    # dépasse un certain seuil, remplacer replace par un ensemble de add et/ou
    # delete
  }

  /**
   * retourner true si update() provoquerait une mise à jour du serveur LDAP, en
   * d'autres termes si l'objet est nouveau ou a des modifications
   */
  function willUpdate(): bool {
    $create = $this->orig["dn"][0] === null;
    if ($create) return true;
    foreach ($this->data as $name => $value) {
      $orig = A::get($this->orig, $name);
      $modattr = $this->computeModattr($name, $orig, $value);
      if ($modattr != null) return true;
    }
    return false;
  }

  /**
   * @return bool true si la modification a été faite, false si elle n'était pas
   * nécessaire
   */
  function update($params=null, ?LdapConn $conn=null, ?bool $create=null): bool {
    if ($conn === null) $conn = $this->conn;
    $dn = $this->data["dn"][0];
    if ($create === null) {
      $origDn = $this->orig["dn"][0];
      $create = $origDn === null;
    }
    if ($create) {
      # création de l'objet
      $attrs = $this->computeAddattrs($this->data);
      $conn->add($dn, $attrs, $params);
    } else {
      # mise à jour de l'objet
      $modattrs = [];
      foreach ($this->data as $name => $value) {
        $orig = A::get($this->orig, $name);
        $modattr = $this->computeModattr($name, $orig, $value);
        if ($modattr != null) {
          if (in_array($name, $this->dnNames)) {
            throw IllegalAccessException::not_allowed("modifying DN attrs");
          }
          A::merge($modattrs, $modattr);
        }
      }
      if (!$modattrs) return false;
      $conn->modify($dn, $modattrs);
    }
    # s'il y a des références sur $this->data, alors une simple "copie" fera
    # que $this->orig garde ces références. c'est la raison pour laquelle on
    # doit refaire les attributs
    $this->orig = $this->data;
    $this->attrs = null;
    return true;
  }

  function rename(string $newRdn, $params=null, ?LdapConn $conn=null): void {
    if ($conn === null) $conn = $this->conn;
    $dn = $this->data["dn"][0];
    if (ldap::prepare_rename($dn, $newRdn, $params)) {
      $dn = $conn->rename($dn, $newRdn, $params);
      $this->orig["dn"] = [$dn];
      $this->data["dn"] = [$dn];
      $this->dnNames = names::get_dn_names($dn, $this->lkey2names);
    }
  }

  function delete($params=null, ?LdapConn $conn=null): void {
    if ($conn === null) $conn = $this->conn;
    $conn->delete($this->data["dn"][0], $params);
  }

  /**
   * tester s'il existe un objet nommé $attr=$value dans branche $parent qui
   * vaut par défaut la branche dans laquelle est situé cet objet
   */
  function existsSibling(string $value, ?string $attr=null, ?string $parent=null, ?LdapConn $conn=null): bool {
    if ($conn === null) $conn = $this->conn;
    $dn = $this->data["dn"][0];
    names::split_dn($dn, $myRdn, $myParent);
    if ($attr === null) {
      $myAttrs = names::split_rdn($myRdn);
      $attr = A::first_key($myAttrs);
    }
    if ($parent === null) $parent = $myParent;
    $entry = $conn->_search(null, [
      "scope" => "one",
      "suffix" => $parent,
      "filter" => [$attr => $value],
      "attrs" => ["dn"],
    ])->first();
    return $entry !== null;
  }

  #############################################################################
  static function _AUTOGEN_SCHEMA(): array {
    return scheman::autogen_schema(static::OBJECT_CLASSES);
  }
  static function _AUTOGEN_PROPERTIES(): array {
    return scheman::autogen_properties(self::_AUTOGEN_SCHEMA());
  }
  static function _AUTOGEN_METHODS(): array {
    return scheman::autogen_methods(self::_AUTOGEN_SCHEMA());
  }
  const SCHEMA = null;
  protected static function SCHEMA(): array {
    # il faut au moins la définition qui indique que dn est monovalué
    $schema = static::SCHEMA;
    if ($schema === null) {
      $schema = [
        "dn" => [
          "name" => "dn",
          "class" => StringSyntax::class,
          "flags" => LdapAttr::MONOVALUED,
        ],
      ];
    }
    return $schema;
  }
  function __call(string $name, ?array $args) {
    $schema = static::SCHEMA();
    if (is_array($schema) && array_key_exists(strtolower($name), $schema)) {
      return $this->_get($name);
    }
    throw IllegalAccessException::not_implemented($name);
  }
  ## rajouter ceci dans les classes dérivées
  #const _AUTOGEN_CONSTS = ["SCHEMA"];
  #const _AUTOGEN_PROPERTIES = [[self::class, "_AUTOGEN_PROPERTIES"]];
  #const _AUTOGEN_METHODS = [[self::class, "_AUTOGEN_METHODS"]];
}