<?php
namespace nur\b\io;

use ArrayAccess;
use Countable;
use Exception;
use Generator;
use nulib\file\Stream;
use nulib\php\time\DateTime;
use nulib\php\time\Delay;
use nur\A;
use nur\b\coll\TBaseArray;
use nur\b\IllegalAccessException;
use nur\b\params\Parametrable;
use nur\b\params\Tparametrable;
use nur\data\types\Tmd;
use nur\file;
use nur\os;

/**
 * Class FileCachedValue: un fichier utilisé pour mettre en cache certaines
 * données. les données sont (re)calculée si la durée de vie du cache est
 * dépassée
 */
abstract class FileCachedValue extends Parametrable implements ArrayAccess, Countable {
  use Tparametrable, TBaseArray, Tmd;

  /** @var string chemin vers le fichier cache par défaut */
  const FILE = null;

  /** @var int durée de vie par défaut du cache */
  const DURATION = "1D"; // jusqu'au lendemain

  /** @var bool faut-il mettre en cache la valeur nulle? */
  const CACHE_NULL = false;

  /** @var array schéma des données supplémentaires */
  const SCHEMA = null;

  function __construct(?array $params=null) {
    self::set_parametrable_params_defaults($params, [
      "file" => static::FILE,
      "duration" => static::DURATION,
      "cache_null" => static::CACHE_NULL,
    ]);
    [$params, $data] = $this->splitParametrableParams($params);
    if ($data) A::merge($params["data"], $data);
    parent::__construct($params);
  }

  const PARAMETRABLE_PARAMS_SCHEMA = [
    "file" => ["?string", null, "chemin vers le fichier"],
    "duration" => [Delay::class, null, "durée de vie du cache"],
    "override_duration" => ["bool", false, "faut-il ignorer la duration inscrite dans le fichier et prendre la valeur locale?"],
    "cache_null" => ["bool", false, "faut-il mettre en cache la valeur null?"],
    "data" => ["array", null, "données supplémentaires"]
  ];

  protected $ppFile;

  function getFile(): string {
    $cacheFile = $this->ppFile;
    if ($cacheFile === null) {
      $rand = bin2hex(random_bytes(8));
      $this->ppFile = $cacheFile = sys_get_temp_dir()."/$rand.cache";
    }
    return $cacheFile;
  }

  /** @var Delay */
  protected $ppDuration;

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

  protected $ppCacheNull;

  function pp_setData($data): void {
    $md = $this->md();
    if ($md !== null) $md->ensureSchema($data);
    $this->data = $data;
  }

  /** calculer la donnée */
  protected abstract function compute();

  /** sérialiser la donnée avant de l'enregistrer */
  protected function serialize($data): string {
    return serialize($data);
  }

  /** enregistrer la donnée dans le fichier spécifié */
  protected function save($data, string $file): void {
    $outf = file::open($file, "wb");
    try {
      $contents = $this->serialize($data);
      $tstart = new DateTime();
      $duration = Delay::with($this->ppDuration, $tstart);
      fwrite($outf, serialize([$tstart, $duration])."\n");
      fwrite($outf, $contents);
    } finally {
      fclose($outf);
    }
  }

  /** désérialiser la données depuis le contenu spécifié */
  protected function unserialize(string $contents) {
    $contents = Stream::nursery_compat_verifix($contents);
    return unserialize($contents);
  }

  /** charger la donnée depuis le fichier spécifié */
  protected function load(string $file) {
    $inf = file::open($file, "rb");
    try {
      fgets($inf); // sauter TSDATA
      $contents = stream_get_contents($inf);
      return $this->unserialize($contents);
    } finally {
      fclose($inf);
    }
  }

  protected $fp;

  protected function _open(): void {
    $file = $this->getFile();
    IOException::ensure_not_false(os::mkdirof($file));
    $this->fp = IOException::ensure_not_false(fopen($file, "c+b"));
  }

  protected function _lock(int $operation): void {
    if (!flock($this->fp, $operation)) {
      switch ($operation) {
      case LOCK_SH: $msg = "sh_lock"; break;
      case LOCK_EX: $msg = "ex_lock"; break;
      }
      throw IllegalAccessException::unexpected_state($msg);
    }
  }

  /**
   * vérifier si le fichier est valide. s'il est invalide, il faut le recréer.
   *
   * on assume que le fichier existe, vu qu'il a été ouvert en c+b
   */
  protected function isValidFile(): bool {
    # on considère que le fichier est invalide s'il est de taille nulle
    return filesize($this->getFile()) > 0;
  }

  protected function loadInfos(): array {
    [$tstart, $duration] = $this->unserialize(fgets($this->fp));
    if (is_int($tstart)) {
      $tstart = new DateTime($tstart);
      $duration = new Delay($duration, $tstart);
    }
    if ($this->ppOverrideDuration) {
      $duration = Delay::with($this->ppDuration, $tstart);
    }
    return [$tstart, $duration];
  }

  protected function shouldUpdate(bool $noCache=false) {
    /** @var Delay $duration */
    $this->_open();
    $cleanup = true;
    try {
      $this->_lock(LOCK_SH);
      if ($this->isValidFile()) {
        [$tstart, $duration] = $this->loadInfos();
        $expired = $duration->isElapsed();
      } else {
        $expired = false;
        $noCache = true;
      }
      if ($noCache || $expired) {
        $this->_lock(LOCK_EX);
        $cleanup = false;
        return true;
      }
    } finally {
      if ($cleanup) $this->cleanup();
    }
    return false;
  }

  /**
   * indiquer si la valeur calculée doit être mise en cache. par défaut, la
   * valeur nulle n'est pas mise en cache.
   */
  protected function shouldCache($value): bool {
    return $this->ppCacheNull || $value !== null;
  }

  protected function cleanup(): void {
    if ($this->fp !== null) {
      fclose($this->fp);
      $this->fp = null;
    }
  }

  /**
   * obtenir la donnée.
   *
   * si la donnée a déjà été mise en cache au préalable, et que la durée de
   * validité du cache n'est pas dépassée, retourner la valeur mise en cache.
   * sinon, (re)calculer la donnée
   *
   * @param bool $noCache ne pas utiliser le cache (forcer le recalcul)
   */
  function get(bool $noCache=false) {
    try {
      if ($this->shouldUpdate($noCache)) {
        try {
          $data = $this->compute();
          if ($data instanceof Generator) $data = iterator_to_array($data);
          if ($this->shouldCache($data)) {
            $this->save($data, $this->ppFile);
          } else {
            # ne pas garder le fichier s'il ne faut pas mettre en cache
            unlink($this->ppFile);
          }
        } catch (Exception $e) {
          // si une erreur se produit, ne pas garder le fichier
          unlink($this->ppFile);
          throw $e;
        }
      } else {
        $data = $this->load($this->ppFile);
      }
      return $data;
    } finally {
      $this->cleanup();
    }
  }

  function getInfos(): array {
    $this->_open();
    try {
      $this->_lock(LOCK_SH);
      if (!$this->isValidFile()) return ["valid" => false];
      $size = filesize($this->ppFile);
      /**
       * @var DateTime $tstart
       * @var Delay $duration
       */
      [$tstart, $duration] = $this->loadInfos();
      return [
        "valid" => true,
        "size" => $size,
        "tstart" => $tstart,
        "duration" => strval($duration),
        "date_start" => $tstart->format(),
        "date_end" => $duration->getDest()->format(),
      ];
    } finally {
      $this->cleanup();
    }
  }

  const UPDATE_SUB = -1, UPDATE_SET = 0, UPDATE_ADD = 1;

  /** mettre à jour la durée de validité du fichier */
  function updateDuration($nduration, int $action=1): void {
    $this->_open();
    try {
      $this->_lock(LOCK_SH);
      if (!$this->isValidFile()) return;
      $this->_lock(LOCK_EX);
      $fp = $this->fp;
      /**
       * @var DateTime $tstart
       * @var Delay $duration
       */
      [$tstart, $duration] = $this->loadInfos();
      $contents = stream_get_contents($fp);
      if ($action < 0) $duration->subDuration($nduration);
      elseif ($action > 0) $duration->addDuration($nduration);
      fseek($fp, 0);
      ftruncate($fp, 0);
      fwrite($fp, serialize([$tstart, $duration])."\n");
      fwrite($fp, $contents);
    } finally {
      $this->cleanup();
    }
  }

  /** supprimer le fichier s'il a expiré */
  function deleteExpired(bool $force=false): bool {
    try {
      if ($force || $this->shouldUpdate()) {
        @unlink($this->ppFile);
        return true;
      }
    } finally {
      $this->cleanup();
    }
    return false;
  }
}
