<?php
namespace nulib\cache;

use Exception;
use nulib\cv;
use nulib\ext\utils;
use nulib\file;
use nulib\file\SharedFile;
use nulib\os\path;
use nulib\php\func;
use nulib\php\time\DateTime;
use nulib\php\time\Delay;
use nulib\str;
use nulib\ValueException;

class CacheFile extends SharedFile {
  /** @var string|int durée de vie par défaut des données mises en cache  */
  const DURATION = "1D"; // jusqu'au lendemain

  const EXT = ".cache";

  static function with($data, ?string $file=null): self {
    if ($data instanceof self) return $data;
    else return new static($file, $data);
  }

  protected function ensure_source($source): CacheData {
    if ($source instanceof CacheData) return $source;
    if (cv::subclass_of($source, CacheData::class)) return new $source();
    if (func::is_callable($source)) return new CacheData($source);
    throw ValueException::invalid_type($source, CacheData::class);
  }

  function __construct(?string $file, $data=null, ?array $params=null) {
    $file ??= path::join(sys_get_temp_dir(), utils::uuidgen());
    $file = path::ensure_ext($file, self::EXT);
    $this->basedir = path::dirname($file);
    $basename = path::filename($file);
    $this->basename = str::without_suffix(self::EXT, $basename);
    $this->initialDuration = Delay::with($params["duration"] ?? static::DURATION);
    $this->overrideDuration = $params["override_duration"] ?? false;
    $this->readonly = $params["readonly"] ?? false;
    $this->cacheNull = $params["cache_null"] ?? false;
    $data ??= $params["data"] ?? null;
    $this->sources = null;
    if ($data === null) {
      $this->source = null;
    } elseif ($data instanceof CacheData) {
      $this->source = $data;
    } elseif (func::is_callable($data)) {
      $this->source = new CacheData($data);
    } elseif (!is_array($data)) {
      $this->source = self::ensure_source($data);
    } else {
      $sources = [];
      $index = 0;
      foreach ($data as $key => $source) {
        $source = self::ensure_source($source);
        if ($key === $index) {
          $index++;
          $key = $source->getName();
        }
        $sources[$key] = $source;
      }
      $this->sources = $sources;
      $this->source = null;
    }
    parent::__construct($file);
  }

  /** @var string répertoire de base des fichiers de cache */
  protected string $basedir;

  /** @var string nom de base des fichiers de cache */
  protected string $basename;

  protected Delay $initialDuration;

  protected bool $overrideDuration;

  protected bool $readonly;

  protected bool $cacheNull;

  protected ?array $sources;

  protected ?CacheData $source;

  protected function getSource($data): CacheData {
    if ($data === null) {
      return $this->source ??= new CacheData(function() {
        return $this->compute();
      });
    }
    $source = $data;
    if (is_string($source) || is_int($source)) {
      $source = $this->sources[$source] ?? null;
      if ($source === null) throw ValueException::invalid_key($data);
    }
    if ($source instanceof CacheData) return $source;
    throw ValueException::invalid_type($data, CacheData::class);
  }

  /**
   * 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
   */
  function isValid(): bool {
    # considèrer que le fichier est invalide s'il est de taille nulle
    return $this->getSize() > 0;
  }

  protected ?DateTime $start;

  protected ?Delay $duration;

  protected $data;

  protected ?array $datafilenames;

  /** charger les données. le fichier a déjà été verrouillé en lecture */
  protected function loadMetadata(): void {
    if ($this->isValid()) {
      $this->rewind();
      [
        "start" => $start,
        "duration" => $duration,
        "data" => $data,
        "datafiles" => $datafilenames,
      ] = $this->unserialize(null, false, true);
      if ($this->overrideDuration) {
        $duration = Delay::with($this->initialDuration, $start);
      }
      $datafilenames = array_fill_keys($datafilenames, true);
    } else {
      $start = null;
      $duration = null;
      $data = null;
      $datafilenames = [];
    }
    $this->start = $start;
    $this->duration = $duration;
    $this->data = $data;
    $this->datafilenames = $datafilenames;
  }

  /**
   * tester s'il faut mettre les données à jour. le fichier a déjà été
   * verrouillé en lecture
   */
  protected function shouldUpdate(bool $noCache=false): bool {
    if ($this->isValid()) {
      $expired = $this->duration->isElapsed();
    } else {
      $expired = false;
      $noCache = true;
    }
    return $noCache || $expired;
  }

  /** sauvegarder les données. le fichier a déjà été verrouillé en écriture */
  protected function saveMetadata(): void {
    $this->duration ??= $this->initialDuration;
    if ($this->start === null) {
      $this->start = new DateTime();
      $this->duration = Delay::with($this->duration, $this->start);
    }
    $datafilenames = array_keys($this->datafilenames);
    $this->ftruncate();
    $this->serialize([
      "start" => $this->start,
      "duration" => $this->duration,
      "data" => $this->data,
      "datafiles" => $datafilenames,
    ], false, true);
  }

  protected function loadDatafile(string $datafile) {
    return file::reader($datafile)->unserialize();
  }

  protected function saveDatafile(string $datafile, $data): void {
    file::writer($datafile)->serialize($data);
  }

  protected function unlinkDatafile(string $datafilename): void {
    $datafile = path::join($this->basedir, $datafilename);
    @unlink($datafile);
    unset($this->datafilenames[$datafilename]);
  }

  protected function unlinkFiles(bool $datafilesOnly=false): void {
    foreach ($this->datafilenames as $datafilename) {
      $this->unlinkDatafile($datafilename);
    }
    if (!$datafilesOnly) @unlink($this->file);
  }

  /** tester si $value peut être mis en cache */
  protected function shouldCache($value): bool {
    return $this->cacheNull || $value !== null;
  }

  protected ?DateTime $ostart;

  protected ?Delay $oduration;

  protected $odata;

  protected ?array $odatafilenames;

  protected function beforeAction() {
    $this->loadMetadata();
    $this->ostart = cv::clone($this->start);
    $this->oduration = cv::clone($this->duration);
    $this->odata = cv::clone($this->data);
    $this->odatafilenames = cv::clone($this->datafilenames);
  }

  protected function afterAction() {
    $modified = false;
    if ($this->start != $this->ostart) $modified = true;
    $duration = $this->duration;
    $oduration = $this->oduration;
    if ($duration === null || $oduration === null) $modified = true;
    elseif ($duration->getDest() != $oduration->getDest()) $modified = true;
    # égalité stricte uniquement pour $data et $datafiles
    if ($this->data !== $this->odata) $modified = true;
    if ($this->datafilenames !== $this->odatafilenames) $modified = true;
    if ($modified && !$this->readonly) {
      $this->lockWrite();
      $this->saveMetadata();
    }
  }

  protected function action(callable $callback, bool $willWrite=false) {
    if ($willWrite && !$this->readonly) $this->lockWrite();
    else $this->lockRead();
    try {
      $this->beforeAction();
      $result = $callback();
      $this->afterAction();
      return $result;
    } finally {
      $this->ostart = null;
      $this->oduration = null;
      $this->odata = null;
      $this->odatafilenames = null;
      $this->start = null;
      $this->duration = null;
      $this->data = null;
      $this->datafilenames = null;
      $this->unlock(true);
    }
  }

  protected function compute() {
    return null;
  }

  protected function refreshData($data, bool $noCache) {
    $defaultSource = $data === null;
    $source = $this->getSource($data);
    $dataname = $source->getName();
    $datafilename = ".{$this->basename}.{$dataname}".self::EXT;
    $datafile = path::join($this->basedir, $datafilename);

    $updateMetadata = $this->shouldUpdate($noCache);
    if ($defaultSource) $updateData = $this->data === null;
    else $updateData = !array_key_exists($datafilename, $this->datafilenames);
    if (!$this->readonly && ($updateMetadata || $updateData)) {
      $this->lockWrite();
      if ($updateMetadata) {
        # il faut refaire tout le cache
        $this->unlinkFiles(true);
        $this->start = null;
        $this->duration = null;
        $this->data = null;
        $updateData = true;
      }
      if ($defaultSource) {
        if ($updateData) {
          # calculer la valeur
          try {
            $data = $source->get();
          } catch (Exception $e) {
            # le fichier n'est pas mis à jour, mais ce n'est pas gênant: lors
            # des futurs appels, l'exception continuera d'être lancée ou la
            # valeur sera finalement mise à jour
            throw $e;
          }
        } else {
          $data = $this->data;
        }
        if ($this->shouldCache($data)) $this->data = $data;
        else $this->data = $data = null;
      } else {
        if ($updateData) {
          # calculer la valeur
          try {
            $data = $source->get();
          } catch (Exception $e) {
            # ne pas garder le fichier destination en cas d'exception
            $this->unlinkDatafile($datafile);
            throw $e;
          }
        } elseif (file_exists($datafile)) {
          $data = $this->loadDatafile($datafile);
        } else {
          $data = null;
        }
        if ($this->shouldCache($data)) {
          $this->saveDatafile($datafile, $data);
          $this->datafilenames[$datafilename] = true;
        } else {
          # ne pas garder le fichier s'il ne faut pas mettre en cache
          $this->unlinkDatafile($datafile);
        }
      }
    } elseif ($defaultSource) {
      $data = $this->data;
    } elseif (file_exists($datafile)) {
      $data = $this->loadDatafile($datafile);
    } else {
      $data = null;
    }
    return $data;
  }

  /**
   * s'assurer que le cache est à jour avec les données les plus récentes. si
   * les données sont déjà présentes dans le cache et n'ont pas encore expirées
   * cette méthode est un NOP
   */
  function refresh(bool $noCache=false): self {
    $this->action(function() use ($noCache) {
      $source = $this->source;
      if ($source !== null) $datas = [null];
      else $datas = array_keys($this->sources);
      foreach ($datas as $data) {
        $this->refreshData($data, $noCache);
      }
    });
    return $this;
  }

  function get($data=null, bool $noCache=false) {
    return $this->action(function () use ($data, $noCache) {
      return $this->refreshData($data, $noCache);
    });
  }

  function all($data=null, bool $noCache=false): ?iterable {
    $data = $this->get($data, $noCache);
    if ($data !== null && !is_iterable($data)) $data = [$data];
    return $data;
  }

  function delete($data=null): void {
    $source = $this->getSource($data);
    $dataname = $source->getName();
    $datafilename = ".{$this->basename}.{$dataname}".self::EXT;
    $this->unlinkDatafile($datafilename);
  }

  /** obtenir les informations sur le fichier */
  function getInfos(): array {
    return $this->action(function () {
      if (!$this->isValid()) {
        return ["valid" => false];
      }
      $start = $this->start;
      $duration = $this->duration;
      $datafilenames = $this->datafilenames;
      $datafiles = [];
      foreach (array_keys($datafilenames) as $datafilename) {
        $datafile = path::join($this->basedir, $datafilename);
        $name = $datafilename;
        str::del_prefix($name, ".{$this->basename}.");
        str::del_suffix($name, self::EXT);
        if (file_exists($datafile)) {
          $size = filesize($datafile);
        } else {
          $size = null;
        }
        $datafiles[$name] = $size;
      }
      return [
        "valid" => true,
        "start" => $start,
        "duration" => strval($duration),
        "date_start" => $start->format(),
        "date_end" => $duration->getDest()->format(),
        "datafiles" => $datafiles,
      ];
    });
  }

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

  /**
   * mettre à jour la durée de validité du fichier
   *
   * XXX UPDATE_SET n'est pas implémenté
   */
  function updateDuration($nduration, int $action=self::UPDATE_ADD): void {
    if ($this->readonly) return;
    $this->action(function () use ($nduration, $action) {
      if (!$this->isValid()) return;
      $duration = $this->duration;
      if ($action < 0) $duration->subDuration($nduration);
      elseif ($action > 0) $duration->addDuration($nduration);
    }, true);
  }

  /** supprimer les fichiers s'ils ont expiré */
  function deleteExpired(bool $force=false): bool {
    if ($this->readonly) return false;
    return $this->action(function () use ($force) {
      if ($force || $this->shouldUpdate()) {
        $this->unlinkFiles();
        return true;
      }
      return false;
    }, true);
  }
}
