<?php
namespace nur\m\pdo;

use nur\A;
use nur\config;
use nur\debug;
use nur\m\base\AbstractConn;
use nur\m\base\QueryException;
use nur\m\IQuery;
use nur\m\IRowIncarnation;
use nur\md;
use PDO;
use PDOException;
use PDOStatement;

class PdoConn extends AbstractConn {
  protected $dbname, $dbuser, $dbpass, $options;

  /** @var PDO */
  protected $pdo;

  /** @var bool sommes-nous dans une transaction? */
  protected $inTransaction = false;

  /** cette base de données supporte-t-elle la fonction lastInsertId() ? */
  protected function HAVE_LAST_INSERT_ID(): bool {
    return static::HAVE_LAST_INSERT_ID;
  } const HAVE_LAST_INSERT_ID = false;

  /** cette base de données supporte-t-elle la clause returning ? */
  protected function HAVE_RETURNING_CLAUSE(): bool {
    return static::HAVE_RETURNING_CLAUSE;
  } const HAVE_RETURNING_CLAUSE = false;

  /** initialiser les options pour la création de l'instance de PDO */
  protected function beforeInitPdo(?array &$options): void {
    $options[PDO::ATTR_PERSISTENT] = true;
  }

  /** initialiser l'instance de PDO après sa création */
  protected function afterInitPdo(PDO $pdo): void {
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER);
  }

  function __construct($dbname, ?string $dbuser=null, ?string $dbpass=null) {
    if (is_array($dbname)) {
      if ($dbuser === null) $dbuser = $dbname["user"];
      if ($dbpass === null) $dbpass = $dbname["pass"];
      $dbname = $dbname["name"];
    }
    $this->beforeInitPdo($options);
    $this->dbname = $dbname;
    $this->dbuser = $dbuser;
    $this->dbpass = $dbpass;
    $this->options = $options;
    try {
      $pdo = new PDO($dbname, $dbuser, $dbpass, $options);
    } catch (PDOException $e) {
      throw new QueryException("unable to create PDO instance", $e);
    }
    $this->afterInitPdo($pdo);
    $this->pdo = $pdo;
  }

  function __destruct() {
    $this->rollback();
  }

  function getInfos(): array {
    return [$this->dbname, $this->dbuser, $this->dbpass, $this->options];
  }

  function beginTransaction(): void {
    if (!$this->inTransaction) {
      try {
        $this->pdo->beginTransaction();
      } catch (PDOException $e) {
        throw new QueryException("cannot begin transaction", $e);
      }
      $this->inTransaction = true;
    }
  }

  function commit(): void {
    if ($this->inTransaction) {
      $this->inTransaction = false;
      try {
        $this->pdo->commit();
      } catch (PDOException $e) {
        throw new QueryException("cannot commit", $e);
      }
    }
  }

  function rollback(): void {
    if ($this->inTransaction) {
      $this->inTransaction = false;
      try {
        $this->pdo->rollBack();
      } catch (PDOException $e) {
        throw new QueryException("cannot rollback", $e);
      }
    }
  }

  private function bindParam(PDOStatement $stmt, string $name, &$value): void {
    try {
      if (is_array($value)) {
        # tableau associatif: on peut spécifier la longueur et le type de la valeur
        $maxlength = A::get($value, "maxlength", -1);
        $type = A::get($value, "type", PDO::PARAM_STR);
        $stmt->bindParam($name, $value["value"], $type, $maxlength);
      } else {
        # sinon, faire un bind simple
        $stmt->bindParam($name, $value);
      }
    } catch (PDOException $e) {
      throw new QueryException("error binding with $name", $e);
    }
  }

  function _execute0(string $sql, ?array &$bindings, array $params): array {
    if ($params["transaction"]) $this->beginTransaction();

    try {
      $stmt = $this->pdo->prepare($sql);
    } catch (PDOException $e) {
      throw new QueryException("preparation error", $e);
    }

    if ($bindings !== null) {
      foreach ($bindings as $name => &$values) {
        # IMPORTANT: il faut binder sur l'adresse de $values
        if (A::is_seq($values)) {
          # liste de valeurs: binder avec des noms générés incrémentalement
          # de la forme $name_$index
          $count = count($values);
          for ($index = 0; $index < $count; $index++) {
            $this->bindParam($stmt, "${name}_${index}", $values[$index]);
          }
        } else {
          # une seule valeur
          $this->bindParam($stmt, $name, $values);
        }
      }; unset($values);
    }

    try {
      $stmt->execute();
    } catch (PDOException $e) {
      throw new QueryException("execute error", $e);
    }

    $r = [];
    if ($params["num_rows"]) {
      $r["num_rows"] = $stmt->rowCount();
    }
    if ($params["last_insert_id"]) {
      $r["insert_id"] = $this->HAVE_LAST_INSERT_ID()? $this->pdo->lastInsertId(): null;
    }
    if ($params["stmt"]) {
      $r["stmt"] = $stmt;
    } else {
      $stmt->closeCursor();
    }
    return $r;
  }

  function _prepareLogger(string $sql, ?array $bindings): array {
    $queryLogger = $this->queryLogger;
    $actualQuery = null;
    $traceSql = config::k("trace_sql", false);
    if ($traceSql || $queryLogger !== null) {
      $actualQuery = PdoQuery::build_actual_query($sql, $bindings);
    }
    if ($traceSql) debug::log("SQL TRACE --", $actualQuery);
    return [$queryLogger, $actualQuery];
  }

  function _execute1(string $sql, ?array &$bindings, array $params): array {
    [$queryLogger, $actualQuery] = $this->_prepareLogger($sql, $bindings);
    $r = $this->_execute0($sql, $bindings, $params);
    if ($queryLogger !== null) $queryLogger->logQuery($actualQuery);
    return $r;
  }

  function _execute(string $sql, ?array &$bindings=null, ?array $params=null): array {
    md::ensure_schema($params, self::EXECUTE_PARAMS_SCHEMA);
    return $this->_execute1($sql, $bindings, $params);
  }

  function _fetchAll(string $sql, ?array &$bindings=null): array {
    ["stmt" => $stmt
    ] = $this->_execute1($sql, $bindings, self::EXECUTE_PARAMS_DQL);
    try {
      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    } catch (PDOException $e) {
      throw new QueryException("fetch error", $e);
    } finally {
      $stmt->closeCursor();
    }
    return $rows;
  }

  function _fetchFirst(string $sql, ?array &$bindings=null): ?array {
    ["stmt" => $stmt
    ] = $this->_execute1($sql, $bindings, self::EXECUTE_PARAMS_DQL);
    try {
      $row = $stmt->fetch(PDO::FETCH_ASSOC);
    } catch (PDOException $e) {
      throw new QueryException("fetch error", $e);
    } finally {
      $stmt->closeCursor();
    }
    return $row !== false? $row: null;
  }

  function _update(string $sql, ?array &$bindings=null): int {
    ["num_rows" => $numRows
    ] = $this->_execute1($sql, $bindings, self::EXECUTE_PARAMS_DML_UPDATE);
    return $numRows;
  }

  function query(?string $sql=null, ?array $filter=null, ?IRowIncarnation $incarnation=null): IQuery {
    return new PdoQuery($this, $sql, $filter, $incarnation);
  }
}
