<?php
namespace nur\sery\db\sqlite;

use Generator;
use nur\sery\cl;
use nur\sery\db\IDatabase;
use nur\sery\db\ITransactor;
use nur\sery\php\nur_func;
use nur\sery\ValueException;
use SQLite3;
use SQLite3Result;
use SQLite3Stmt;

/**
 * Class Sqlite: frontend vers une base de données sqlite3
 */
class Sqlite implements IDatabase {
  static function with($sqlite, ?array $params=null): self {
    if ($sqlite instanceof static) {
      return $sqlite;
    } elseif ($sqlite instanceof self) {
      # recréer avec les mêmes paramètres
      return new static(null, cl::merge([
        "file" => $sqlite->file,
        "flags" => $sqlite->flags,
        "encryption_key" => $sqlite->encryptionKey,
        "allow_wal" => $sqlite->allowWal,
        "config" => $sqlite->config,
        "migrate" => $sqlite->migration,
      ], $params));
    } elseif (is_array($sqlite)) {
      return new static(null, cl::merge($sqlite, $params));
    } else {
      return new static($sqlite, $params);
    }
  }

  static function config_enableExceptions(self $sqlite): void {
    $sqlite->db->enableExceptions(true);
  }
  const CONFIG_enableExceptions = [self::class, "config_enableExceptions"];

  /**
   * @var int temps maximum à attendre que la base soit accessible si elle est
   * verrouillée
   */
  protected const BUSY_TIMEOUT = 30 * 1000;

  static function config_busyTimeout(self $sqlite): void {
    $sqlite->db->busyTimeout(static::BUSY_TIMEOUT);
  }
  const CONFIG_busyTimeout = [self::class, "config_busyTimeout"];

  static function config_enableWalIfAllowed(self $sqlite): void {
    if ($sqlite->isWalAllowed()) {
      $sqlite->db->exec("PRAGMA journal_mode=WAL");
    }
  }
  const CONFIG_enableWalIfAllowed = [self::class, "config_enableWalIfAllowed"];

  const ALLOW_WAL = null;

  const DEFAULT_CONFIG = [
    self::CONFIG_enableExceptions,
    self::CONFIG_busyTimeout,
    self::CONFIG_enableWalIfAllowed,
  ];

  const CONFIG = null;

  const MIGRATE = null;

  const params_SCHEMA = [
    "file" => ["string", ""],
    "flags" => ["int", SQLITE3_OPEN_READWRITE + SQLITE3_OPEN_CREATE],
    "encryption_key" => ["string", ""],
    "allow_wal" => ["?bool"],
    "replace_config" => ["?array|callable"],
    "config" => ["?array|callable"],
    "migrate" => ["?array|string|callable"],
    "auto_open" => ["bool", true],
  ];

  function __construct(?string $file=null, ?array $params=null) {
    if ($file !== null) $params["file"] = $file;
    ##schéma
    $defaultFile = self::params_SCHEMA["file"][1];
    $this->file = $file = strval($params["file"] ?? $defaultFile);
    $inMemory = $file === ":memory:" || $file === "";
    #
    $defaultFlags = self::params_SCHEMA["flags"][1];
    $this->flags = intval($params["flags"] ?? $defaultFlags);
    #
    $defaultEncryptionKey = self::params_SCHEMA["encryption_key"][1];
    $this->encryptionKey = strval($params["encryption_key"] ?? $defaultEncryptionKey);
    #
    $defaultAllowWal = static::ALLOW_WAL ?? !$inMemory;
    $this->allowWal = $params["allow_wal"] ?? $defaultAllowWal;
    # configuration
    $config = $params["replace_config"] ?? null;
    if ($config === null) {
      $config = $params["config"] ?? static::CONFIG;
      if (is_callable($config)) $config = [$config];
      $config = cl::merge(static::DEFAULT_CONFIG, $config);
    }
    $this->config = $config;
    # migrations
    $this->migration = $params["migrate"] ?? static::MIGRATE;
    #
    $defaultAutoOpen = self::params_SCHEMA["auto_open"][1];
    $this->inTransaction = false;
    if ($params["auto_open"] ?? $defaultAutoOpen) {
      $this->open();
    }
  }

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

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

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

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

  /** vérifier s'il est autorisé de configurer le mode WAL */
  function isWalAllowed(): bool {
    return $this->allowWal;
  }

  /** @var array|string|callable */
  protected $config;

  /** @var array|string|callable */
  protected $migration;

  /** @var SQLite3 */
  protected $db;

  protected bool $inTransaction;

  function open(): self {
    if ($this->db === null) {
      $this->db = new SQLite3($this->file, $this->flags, $this->encryptionKey);
      _config::with($this->config)->configure($this);
      _migration::with($this->migration)->migrate($this);
      $this->inTransaction = false;
    }
    return $this;
  }

  function close(): void {
    if ($this->db !== null) {
      $this->db->close();
      $this->db = null;
      $this->inTransaction = false;
    }
  }

  protected function checkStmt($stmt): SQLite3Stmt {
    return SqliteException::check($this->db, $stmt);
  }

  protected function checkResult($result): SQLite3Result {
    return SqliteException::check($this->db, $result);
  }

  protected function db(): SQLite3 {
    $this->open();
    return $this->db;
  }

  function _exec(string $query): bool {
    return $this->db()->exec($query);
  }

  private static function is_insert(?string $sql): bool {
    if ($sql === null) return false;
    return preg_match('/^\s*insert\b/i', $sql);
  }

  function exec($query, ?array $params=null) {
    $db = $this->db();
    $query = new _query_base($query, $params);
    if ($query->useStmt($db, $stmt, $sql)) {
      try {
        $result = $stmt->execute();
        if ($result === false) return false;
        $result->finalize();
        if ($query->isInsert()) return $db->lastInsertRowID();
        else return $db->changes();
      } finally {
        $stmt->close();
      }
    } else {
      $result = $db->exec($sql);
      if ($result === false) return false;
      if (self::is_insert($sql)) return $db->lastInsertRowID();
      else return $db->changes();
    }
  }

  /** @var ITransactor[] */
  protected ?array $transactors = null;

  function willUpdate(...$transactors): self {
    foreach ($transactors as $transactor) {
      if ($transactor instanceof ITransactor) {
        $this->transactors[] = $transactor;
        $transactor->willUpdate();
      } else {
        throw ValueException::invalid_type($transactor, ITransactor::class);
      }
    }
    return $this;
  }

  function inTransaction(): bool {
    #XXX très imparfait, mais y'a rien de mieux pour le moment :-(
    return $this->inTransaction;
  }

  function beginTransaction(?callable $func=null, bool $commit=true): void {
    $this->db()->exec("begin");
    $this->inTransaction = true;
    if ($this->transactors !== null) {
      foreach ($this->transactors as $transactor) {
        $transactor->beginTransaction();
      }
    }
    if ($func !== null) {
      $commited = false;
      try {
        nur_func::call($func, $this);
        if ($commit) {
          $this->commit();
          $commited = true;
        }
      } finally {
        if ($commit && !$commited) $this->rollback();
      }
    }
  }

  function commit(): void {
    $this->inTransaction = false;
    $this->db()->exec("commit");
    if ($this->transactors !== null) {
      foreach ($this->transactors as $transactor) {
        $transactor->commit();
      }
    }
  }

  function rollback(): void {
    $this->inTransaction = false;
    $this->db()->exec("rollback");
    if ($this->transactors !== null) {
      foreach ($this->transactors as $transactor) {
        $transactor->rollback();
      }
    }
  }

  function _get(string $query, bool $entireRow=false) {
    return $this->db()->querySingle($query, $entireRow);
  }

  function get($query, ?array $params=null, bool $entireRow=false) {
    $db = $this->db();
    $query = new _query_base($query, $params);
    if ($query->useStmt($db, $stmt, $sql)) {
      try {
        $result = $this->checkResult($stmt->execute());
        try {
          $row = $result->fetchArray(SQLITE3_ASSOC);
          if ($row === false) return null;
          elseif ($entireRow) return $row;
          else return cl::first($row);
        } finally {
          $result->finalize();
        }
      } finally {
        $stmt->close();
      }
    } else {
      return $db->querySingle($sql, $entireRow);
    }
  }

  function one($query, ?array $params=null): ?array {
    return $this->get($query, $params, true);
  }

  protected function _fetchResult(SQLite3Result $result, ?SQLite3Stmt $stmt=null, $primaryKeys=null): Generator {
    if ($primaryKeys !== null) $primaryKeys = cl::with($primaryKeys);
    try {
      while (($row = $result->fetchArray(SQLITE3_ASSOC)) !== false) {
        if ($primaryKeys !== null) {
          $key = implode("-", cl::select($row, $primaryKeys));
          yield $key => $row;
        } else {
          yield $row;
        }
      }
    } finally {
      $result->finalize();
      if ($stmt !== null) $stmt->close();
    }
  }

  /**
   * si $primaryKeys est fourni, le résultat est indexé sur la(es) colonne(s)
   * spécifiée(s)
   */
  function all($query, ?array $params=null, $primaryKeys=null): iterable {
    $db = $this->db();
    $query = new _query_base($query, $params);
    if ($query->useStmt($db, $stmt, $sql)) {
      $result = $this->checkResult($stmt->execute());
      return $this->_fetchResult($result, $stmt, $primaryKeys);
    } else {
      $result = $this->checkResult($db->query($sql));
      return $this->_fetchResult($result, null, $primaryKeys);
    }
  }
}
