<?php
namespace nur\ldap;

use nur\A;
use nur\b\ICloseable;
use nur\b\params\Parametrable;
use nur\b\params\Tparametrable;
use nur\ldap\schemas\LdapSchemaExtractor;
use nur\ldap\schemas\SchemaManager;
use nur\ldap\syntaxes\AbstractSyntax;
use nur\path;
use nur\php\SrcGenerator;
use nur\str;
use nur\writer;

/**
 * Class LdapConn: une connexion à un serveur LDAP
 */
class LdapConn extends Parametrable implements ICloseable {
  use Tparametrable;

  const URI = "ldap://localhost:389";
  const BINDDN = null;
  const PASSWORD = null;
  const CONTROLS = null;

  const PARAMETRABLE_PARAMS_SCHEMA = [
    "uri" => ["string", null, "URI du serveur LDAP"],
    "binddn" => ["?string", null, "DN avec lequel se lier"],
    "password" => ["?string", null, "mot de passe"],
    "controls" => ["array", [], "contrôle de connexion"],
    "protocol" => ["int", 3, "version du protocole"],
    "autoconnect" => ["bool", true, "faut-il se connecter dès la création de l'objet?"],
    # paramètres par défaut
    "suffix" => ["?string", null, "DN de base du serveur"],
    "domain" => ["?string", null, "domaine DNS de l'établissement"],
    "etab" => ["?string", null, "code de l'établissement"],
    "autofill_params" => ["bool", true, "faut-il calculer automatiquement les paramètres par défaut?"],
    # configuration du serveur
    "root_dse" => ["?array", null, "configuration du serveur"],
    "ldap_syntaxes" => ["?array", null, "définition des syntaxes"],
    "attribute_types" => ["?array", null, "définition des attributs"],
    "object_classes" => ["?array", null, "définition des classes d'objets"],
  ];

  function __construct(?array $params=null) {
    self::set_parametrable_params_defaults($params, [
      "uri" => static::URI,
      "binddn" => static::BINDDN,
      "password" => static::PASSWORD,
      "controls" => static::CONTROLS,
    ]);
    parent::__construct($params);
    if ($this->ppAutoconnect) $this->connect();
    if ($this->ppAutofillParams) $this->fillParams();
  }

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

  /** @var ?string */
  protected $ppBinddn;

  /** @var ?string */
  protected $ppPassword;

  /** @var ?array */
  protected $ppControls;

  /** @var int */
  protected $ppProtocol;

  /** @var bool */
  protected $ppAutoconnect;

  /** @var ?string */
  protected $ppSuffix;

  function getSuffix(): ?string {
    return $this->ppSuffix;
  }

  /** @var ?string */
  protected $ppDomain;

  function getDomain(): ?string {
    return $this->ppDomain;
  }

  /** @var ?string */
  protected $ppEtab;

  function getEtab(bool $withPrefix=true): ?string {
    $etab = $this->ppEtab;
    if (!$withPrefix) {
      $etab = preg_replace('/^\{[^}]+}/', "", $etab);
    }
    return $etab;
  }

  /** @var bool */
  protected $ppAutofillParams;

  /**
   * @param resource $conn
   * @throws LdapException
   */
  function tryConnect(?string $binddn=null, ?string $password=null, ?array $controls=null, $conn=null) {
    if ($conn === null) {
      $uri = $this->ppUri;
      $conn = LdapException::check("connect $uri", null
        , ldap_connect($uri));
      $procotol = $this->ppProtocol;
      LdapException::check("set_option protocol=$procotol", $conn
        , ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, $procotol));
    }

    if ($binddn === null) $binddn = $this->ppBinddn;
    if ($password === null) $password = $this->ppPassword;
    if ($controls === null) $controls = $this->ppControls;
    $operation = "bind $binddn";
    $r = LdapException::check($operation, $conn
      , ldap_bind_ext($conn, $binddn, $password, $controls));
    LdapException::check_result($operation, $conn, $r);

    return $conn;
  }

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

  function connect(?string $binddn=null, ?string $password=null, ?array $controls=null): void {
    $this->conn = $this->tryConnect($binddn, $password, $controls, $this->conn);
  }

  /**
   * se reconnecter, mais seulement s'il y a une erreur sur la connection
   *
   * @return true si la reconnexion a effectivement eu lieu
   */
  function reconnect(bool $force=false): bool {
    if (!$force) {
      try {
        $this->_search(null, [
          "attrs" => ["objectClass"],
          "scope" => "base",
          "suffix" => "",
        ])->first();
      } catch (LdapException $e) {
        $force = true;
      }
    }
    if ($force) $this->connect();
    return $force;
  }

  /** @return resource */
  protected function conn() {
    if ($this->conn === null) $this->connect();
    return $this->conn;
  }

  /** retourner un objet vide permettant de construire un objet depuis zéro */
  function empty(?LdapObject $object=null): LdapObject {
    if ($object === null) $object = new LdapObject();
    return $object->reset(null, null, [], $this);
  }

  function _search(?string $searchbase=null, $params=null): LdapSearch {
    LdapSearch::search_md()->ensureSchema($params);
    A::replace_n($params, "searchbase", $searchbase);
    A::replace_n($params, "suffix", $this->ppSuffix);
    return new LdapSearch($this->conn(), $params);
  }

  function search(?string $searchbase=null, $params=null, ?ILdapWalker $walker=null): ILdapWalker {
    if ($walker === null) {
      $walker = new LdapWalker($this);
    } else {
      $walker->close();
      $walker->reset(null, null, null, $this);
    }
    return $walker->resetSearch($this->_search($searchbase, $params));
  }

  function first(?string $searchbase=null, $params=null, ?LdapObject $object=null): ?LdapObject {
    $search = $this->_search($searchbase, $params);
    $entry = $search->first($dn);
    if ($entry === null) return null;
    else return $this->empty($object)->load($dn, $entry);
  }

  function read(string $dn, ?array $params=null, ?LdapObject $object=null): ?LdapObject {
    A::merge($params, [
      "scope" => "base",
      "suffix" => $dn,
    ]);
    return $this->first(null, $params, $object);
  }

  function add(string $dn, array $attrs, $params=null): void {
    ldap::add($this->conn(), $dn, $attrs, $params);
  }

  function modify(string $dn, array $modattrs, $params=null): void {
    ldap::modify($this->conn(), $dn, $modattrs, $params);
  }

  function rename(string $dn, string $newRdn, $params=null): string {
    if (ldap::prepare_rename($dn, $newRdn, $params)) {
      return ldap::rename($this->conn(), $dn, $newRdn, $params);
    } else {
      # renommage non nécessaire
      return $dn;
    }
  }

  function delete(string $dn, $params=null): void {
    ldap::delete($this->conn(), $dn, $params);
  }

  function close(): void {
    if ($this->conn !== null) {
      ldap_unbind($this->conn);
      $this->conn = null;
    }
  }

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

  /**
   * Si $rdn se termine par le suffixe, le retourner tel quel, sinon rajouter
   * le suffixe si ce n'est pas un DN qui est dans un des contextes valides
   */
  function ensureDn(string $rdn): string {
    $suffix = $this->ppSuffix;
    if (names::have_suffix($rdn, $suffix)) return $rdn;
    $rootDse = $this->getRootDseForContexts();
    $namingContexts = $rootDse->get("namingContexts", []);
    foreach ($namingContexts as $namingContext) {
      if (names::have_suffix($rdn, $suffix)) return $rdn;
    }
    return names::join($rdn, $suffix);
  }

  /**
   * Corriger un label de la forme {UAI::XXX} en insérant le code de
   * l'établissement
   */
  function fixLabel(string $labeledValue): string {
    if (!preg_match('/^(\{[A-Za-z0-9:._-]+})(.*)/', $labeledValue, $ms)) {
      return $labeledValue;
    }
    $label = $ms[1];
    $value = $ms[2];
    if (str::del_prefix($label, "{UAI::")) {
      $label = "{UAI:".$this->getEtab(false).":$label";
    } elseif (str::del_prefix($label, "{UAI:}")) {
      $label = "{UAI:".$this->getEtab(false)."}$label";
    }
    return $label.$value;
  }

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

  /** @var SchemaManager */
  protected $scheman;

  protected function scheman(): SchemaManager {
    if ($this->scheman === null) {
      $this->scheman = new SchemaManager($this);
    }
    return $this->scheman;
  }

  function getSyntax($class): AbstractSyntax {
    $syntax = $this->scheman()->getSyntax($class);
    $syntax->initConn($this);
    return $syntax;
  }

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

  protected function loadRootDse(?array $attrs=null): LdapObject {
    if ($attrs === null) $attrs = ["+", "*"];
    $entry = $this->_search(null, [
      "attrs" => $attrs,
      "scope" => "base",
      "suffix" => "",
    ])->first($dn);
    return $this->empty()->load($dn, $entry);
  }

  /** @var LdapObject */
  protected $ppRootDse;

  function pp_setRootDse(array $rootDse) {
    $this->ppRootDse = $this->empty()->reset("", $rootDse);
  }

  function getRootDse(): LdapObject {
    if ($this->ppRootDse === null) $this->ppRootDse = $this->loadRootDse();
    return $this->ppRootDse;
  }

  protected function getRootDseForContexts(): LdapObject {
    $rootDse = $this->ppRootDse;
    if ($rootDse === null) {
      $rootDse = $this->loadRootDse(["defaultNamingContext", "namingContexts"]);
    }
    return $rootDse;
  }

  protected function loadTopObject(?array $attrs=null): LdapObject {
    if ($attrs === null) $attrs = ["+", "*"];
    $entry = $this->_search("", [
      "attrs" => $attrs,
      "scope" => "base",
    ])->first($dn);
    return $this->empty()->load($dn, $entry);
  }

  protected $ppLdapSyntaxes;

  protected $ppAttributeTypes;

  protected $ppObjectClasses;

  function getSchemaInfos(): array {
    $ldapSyntaxes = $this->ppLdapSyntaxes;
    $attributeTypes = $this->ppAttributeTypes;
    $objectClasses = $this->ppObjectClasses;
    if ($ldapSyntaxes === null || $attributeTypes === null || $objectClasses === null) {
      $lse = new LdapSchemaExtractor();
      [
        "ldap_syntaxes" => $ldapSyntaxes,
        "attribute_types" => $attributeTypes,
        "object_classes" => $objectClasses,
      ] = $lse->loadSchema($this);
    }
    return [
      "ldap_syntaxes" => $this->ppLdapSyntaxes = $ldapSyntaxes,
      "attribute_types" => $this->ppAttributeTypes = $attributeTypes,
      "object_classes" => $this->ppObjectClasses = $objectClasses,
    ];
  }

  function saveConfig($output, bool $overwriteShared=false): void {
    $uri = $this->ppUri;
    $sharedname = ldap_config::get_shared_file($uri);
    if (is_string($output)) {
      # corriger éventuellement le nom du fichier
      $output = ldap_config::get_file($output);
      # calculer le chemin vers fichier partagé
      $shared = path::join(path::dirname($output), $sharedname);
      # écrire la configuration partagée
      if ($overwriteShared) {
        # forcer le recalcul
        $this->ppRootDse = null;
        $this->ppLdapSyntaxes = null;
        $this->ppAttributeTypes = null;
        $this->ppObjectClasses = null;
      }
      if (!file_exists($shared) || $overwriteShared) {
        $rootDse = $this->getRootDse()->array();
        [
          "ldap_syntaxes" => $ldapSyntaxes,
          "attribute_types" => $attributeTypes,
          "object_classes" => $objectClasses,
        ] = $this->getSchemaInfos();
        $config = [
          "uri" => $uri,
          "controls" => $this->ppControls,
          "protocol" => $this->ppProtocol,
          "suffix" => $this->ppSuffix,
          "domain" => $this->ppDomain,
          "etab" => $this->ppEtab,
          "root_dse" => $rootDse,
          "ldap_syntaxes" => $ldapSyntaxes,
          "attribute_types" => $attributeTypes,
          "object_classes" => $objectClasses,
        ];
        $src = new SrcGenerator();
        $literals = [];
        foreach (consts::LDAP_CONTROL_CONSTANTS as $constant) {
          if (defined($constant)) {
            $literals[] = [constant($constant), $constant];
          }
        }
        A::merge($literals, consts::ROOT_DSE_LITERALS);
        $src
          ->genSof()
          ->genLiteral("# shared configuration for $uri")
          ->genReturn($config, null, $literals);
        writer::with($shared, "wb")->writeLines($src->getLines())->close();
      }
    }
    # écrire la configuration
    $config = [
      "binddn" => $this->ppBinddn,
      "password" => $this->ppPassword,
    ];
    $src = new SrcGenerator();
    $src
      ->genSof()
      ->genLiteral("return array_merge(require __DIR__.'/$sharedname',")
      ->addValue($config)
      ->genLiteral(");");
    writer::with($output, "wb")->writeLines($src->getLines())->close();
  }

  /**
   * calculer automatiquement les paramètres par défaut s'ils ne sont pas
   * spécifiés, tels que:
   * - suffix
   * - domain
   * - etab
   */
  function fillParams(): void {
    if ($this->ppSuffix === null) {
      $rootDse = $this->getRootDseForContexts();
      $suffix = $rootDse->get("defaultNamingContext");
      if ($suffix === null) {
        $namingContexts = $rootDse->get("namingContexts", []);
        foreach ($namingContexts as $namingContext) {
          if (str::_starts_with("dc=", strtolower($namingContext))) {
            $suffix = $namingContext;
            break;
          }
        }
        if ($suffix === null) $suffix = $namingContexts[0];
      }
      $this->ppSuffix = $suffix;
    }
    if ($this->ppDomain === null) {
      $parts = ldap_explode_dn($this->ppSuffix, 1);
      unset($parts["count"]);
      $this->ppDomain = implode(".", $parts);
    }
    if ($this->ppEtab === null) {
      $topObject = $this->loadTopObject();
      $this->ppEtab = $topObject->first("supannEtablissement");
    }
  }
}