<?php
namespace nur\sery\wip\app;

use nur\sery\cl;
use nur\sery\file\SharedFile;
use nur\sery\os\path;
use nur\sery\output\msg;
use nur\sery\php\time\DateTime;
use nur\sery\str;

/**
 * Class RunFile: une classe permettant de suivre le fonctionnement d'une
 * application qui tourne en tâche de fond
 */
class RunFile {
  const RUN_EXT = ".run";
  const LOCK_EXT = ".lock";

  const NAME = null;

  function __construct(?string $name, string $file, ?string $logfile=null) {
    $file = path::ensure_ext($file, self::RUN_EXT);
    $this->name = $name ?? static::NAME;
    $this->file = new SharedFile($file);
    $this->logfile = $logfile;
  }

  protected ?string $name;

  protected SharedFile $file;

  protected ?string $logfile;

  function getLogfile(): ?string {
    return $this->logfile;
  }

  protected static function merge(array $data, array $merge): array {
    return cl::merge($data, [
      "serial" => $data["serial"] + 1,
    ], $merge);
  }

  protected function initData(bool $forStart=true): array {
    if ($forStart) {
      $pid = posix_getpid();
      $dateStart = new DateTime();
    } else {
      $pid = $dateStart = null;
    }
    return [
      "name" => $this->name,
      "id" => bin2hex(random_bytes(16)),
      "pg_pid" => null,
      "pid" => $pid,
      "serial" => 0,
      # lock
      "locked" => false,
      "date_lock" => null,
      "date_release" => null,
      # run
      "logfile" => $this->logfile,
      "date_start" => $dateStart,
      "date_stop" => null,
      "exitcode" => null,
      "is_done" => null,
      # action
      "action" => null,
      "action_date_start" => null,
      "action_current_step" => null,
      "action_max_step" => null,
      "action_date_step" => null,
    ];
  }

  function read(): array {
    $data = $this->file->unserialize();
    if (!is_array($data)) $data = $this->initData(false);
    return $data;
  }

  protected function willWrite(): array {
    $file = $this->file;
    $file->lockWrite();
    $data = $file->unserialize(null, false, true);
    if (!is_array($data)) {
      $data = $this->initData(false);
      $file->ftruncate();
      $file->serialize($data, false, true);
    }
    return [$file, $data];
  }

  protected function serialize(SharedFile $file, array $data, ?array $merge=null): void {
    $file->ftruncate();
    $file->serialize(self::merge($data, $merge), true, true);
  }

  protected function update(callable $func): void {
    /** @var SharedFile$file */
    [$file, $data] = $this->willWrite();
    $merge = call_user_func($func, $data);
    if ($merge !== null && $merge !== false) {
      $this->serialize($file, $data, $merge);
    } else {
      $file->cancelWrite();
    }
  }

  function haveWorked(int $serial, ?int &$currentSerial=null, ?array $data=null): bool {
    $data ??= $this->read();
    $currentSerial = $data["serial"];
    return $serial !== $currentSerial;
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # verrouillage par défaut

  function isLocked(?array &$data=null): bool {
    $data = $this->read();
    return $data["locked"];
  }

  function warnIfLocked(?array $data=null): bool {
    $data ??= $this->read();
    if ($data["locked"]) {
      msg::warning("$data[name]: possède le verrou depuis $data[date_lock]");
      return true;
    }
    return false;
  }

  function lock(): bool {
    $this->update(function ($data) use (&$locked) {
      if ($data["locked"]) {
        $locked = false;
        return null;
      } else {
        $locked = true;
        return [
          "locked" => true,
          "date_lock" => new DateTime(),
          "date_release" => null,
        ];
      }
    });
    return $locked;
  }

  function release(): void {
    $this->update(function ($data) {
      return [
        "locked" => false,
        "date_release" => new DateTime(),
      ];
    });
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # cycle de vie de l'application

  /**
   * indiquer que l'application démarre. l'état est entièrement réinitialisé,
   * sauf le PID du leader qui est laissé en l'état
   */
  function wfStart(): void {
    $this->update(function (array $data) {
      return cl::merge($this->initData(), [
        "pg_pid" => $data["pg_pid"],
      ]);
    });
  }

  /** tester si l'application a déjà été démarrée au moins une fois */
  function wasStarted(?array $data=null): bool {
    $data ??= $this->read();
    return $data["date_start"] !== null;
  }

  /** tester si l'application est démarrée et non arrêtée */
  function isStarted(?array $data=null): bool {
    $data ??= $this->read();
    return $data["date_start"] !== null && $data["date_stop"] === null;
  }

  /**
   * vérifier si l'application marquée comme démarrée tourne réellement
   */
  function isRunning(?array $data=null): bool {
    $data ??= $this->read();
    if ($data["date_start"] === null) return false;
    if ($data["date_stop"] !== null) return false;
    if (!posix_kill($data["pid"], 0)) {
      switch (posix_get_last_error()) {
      case 1: #PCNTL_EPERM:
        # process auquel on n'a pas accès?! est-ce un autre process qui a
        # réutilisé le PID?
        return false;
      case 3: #PCNTL_ESRCH:
        # process inexistant
        return false;
      case 22: #PCNTL_EINVAL:
        # ne devrait pas se produire
        return false;
      }
    }
    # process existant auquel on a accès
    return true;
  }

  /** indiquer que l'application s'arrête */
  function wfStop(): void {
    $this->update(function (array $data) {
      return ["date_stop" => new DateTime()];
    });
  }

  /** tester si l'application est déjà été stoppée au moins une fois */
  function wasStopped(?array $data=null): bool {
    $data ??= $this->read();
    return $data["date_stop"] !== null;
  }

  /** tester si l'application a été démarrée puis arrêtée */
  function isStopped(?array $data=null): bool {
    $data ??= $this->read();
    return $data["date_start"] !== null && $data["date_stop"] !== null;
  }

  /** après l'arrêt de l'application, mettre à jour le code de retour */
  function wfStopped(int $exitcode): void {
    $this->update(function (array $data) use ($exitcode) {
      return [
        "pg_pid" => null,
        "date_stop" => $data["date_stop"] ?? new DateTime(),
        "exitcode" => $exitcode,
      ];
    });
  }

  /**
   * comme {@link self::isStopped()} mais ne renvoie true qu'une seule fois si
   * $updateDone==true
   */
  function isDone(?array &$data=null, bool $updateDone=true): bool {
    $done = false;
    $this->update(function (array $ldata) use (&$done, &$data, $updateDone) {
      $data = $ldata;
      if ($data["date_start"] === null || $data["date_stop"] === null || $data["is_done"]) {
        return false;
      }
      $done = true;
      if ($updateDone) return ["is_done" => $done];
      else return null;
    });
    return $done;
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # gestion des actions

  /** indiquer le début d'une action */
  function action(?string $title, ?int $maxSteps=null): void {
    $this->update(function (array $data) use ($title, $maxSteps) {
      return [
        "action" => $title,
        "action_date_start" => new DateTime(),
        "action_max_step" => $maxSteps,
        "action_current_step" => 0,
      ];
    });
    app2::_dispatch_signals();
  }

  /** indiquer qu'une étape est franchie dans l'action en cours */
  function step(int $nbSteps=1): void {
    $this->update(function (array $data) use ($nbSteps) {
      return [
        "action_date_step" => new DateTime(),
        "action_current_step" => $data["action_current_step"] + $nbSteps,
      ];
    });
    app2::_dispatch_signals();
  }

  function getActionDesc(?array $data=null): ?string {
    $data ??= $this->read();
    $action = $data["action"];
    if ($action === null) {
      return "pid $data[pid] [$data[date_start]]";
    }

    $date ??= $data["action_date_step"];
    $date ??= $data["action_date_start"];
    if ($date !== null) $action = "[$date] $action";
    $current = $data["action_current_step"];
    $max = $data["action_max_step"];
    if ($current !== null && $max !== null) {
      $action .= " ($current / $max)";
    } elseif ($current !== null) {
      $action .= " ($current)";
    }
    return "pid $data[pid] $action";
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Divers

  function getLockFile(?string $name=null, ?string $title=null): LockFile {
    $ext = self::LOCK_EXT;
    if ($name !== null) $ext = ".$name$ext";
    $file = path::ensure_ext($this->file->getFile(), $ext, self::RUN_EXT);
    $name = str::join("/", [$this->name, $name]);
    return new LockFile($file, $name, $title);
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Gestionnaire de tâches (tm_*)

  /** démarrer un groupe de process dont le process courant est le leader */
  function tm_startPg(): void {
    $this->update(function (array $data) {
      posix_setsid();
      return [
        "pg_pid" => posix_getpid(),
      ];
    });
  }

  /**
   * vérifier si on est dans le cas où la tâche devrait tourner mais en réalité
   * ce n'est pas le cas
   */
  function tm_isUndead(?int $pid=null): bool {
    $data = $this->read();
    if ($data["date_start"] === null) return false;
    if ($data["date_stop"] !== null) return false;
    $pid ??= $data["pid"];
    if (!posix_kill($pid, 0)) {
      switch (posix_get_last_error()) {
      case 1: #PCNTL_EPERM:
        # process auquel on n'a pas accès?! est-ce un autre process qui a
        # réutilisé le PID?
        return false;
      case 3: #PCNTL_ESRCH:
        # process inexistant
        return true;
      case 22: #PCNTL_EINVAL:
        # ne devrait pas se produire
        return false;
      }
    }
    # process existant auquel on a accès
    return false;
  }

  function tm_isReapable(): bool {
    $data = $this->read();
    return $data["date_stop"] !== null && $data["exitcode"] === null;
  }

  /** marquer la tâche comme terminée */
  function tm_reap(?int $pid=null): void {
    $data = $this->read();
    $pid ??= $data["pid"];
    pcntl_waitpid($pid, $status);
    $exitcode = pcntl_wifexited($status)? pcntl_wexitstatus($status): 127;
    $this->update(function (array $data) use ($exitcode) {
      return [
        "pg_pid" => null,
        "date_stop" => $data["date_stop"] ?? new DateTime(),
        "exitcode" => $data["exitcode"] ?? $exitcode,
      ];
    });
  }
}
