<?php
namespace nur\sery\wip\os\proc;

use nur\sery\php\coll\AutoArray;

/**
 * Class ManagedTask: une tâche de fond
 *
 * --autogen-properties-and-methods--
 */
class ManagedTask extends AutoArray {
  const LOCK = "task";

  const DEFINITION_SCHEMA = [
    "id" => ["string", null, "identifiant de la tâche"],
    "serial" => ["int", null, "numéro de série permettant de distinguer deux occurrences de la tâche"],
    "title" => ["?string", null, "description de la tâche"],
    "valid" => ["bool", false, "la tâche est-elle valide?"],
    "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"],
    "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"],
    "owner_page" => ["?string", null, "page qui a créé cette tâche"],
    "owner_params" => ["?array", null, "paramètres à passer à la page"],
    "cmd" => [null, null, "commande à lancer"],
    "logfile" => ["?string", null, "sortie de la commande"],
  ];

  const SCHEMA = [
    "definition" => [
      "?array", null, "définition de la tâche",
      "schema" => self::DEFINITION_SCHEMA,
    ],
    "state" => [
      "?array", null, "instance de la tâche",
      "schema" => [
        "definition" => [
          "array", null, "copie de la définition de la tâche",
          "schema" => self::DEFINITION_SCHEMA,
        ],
        "started" => ["bool", false, "la tâche a-t-elle été démarrée?"],
        "date_start" => ["?datetime", null, "date du démarrage de la tâche"],
        "pid" => ["?int", null, "PID du process contrôleur"],
        "status" => ["?string", null, "Message de statut indiqué par la tâche"],
        "stopped" => ["bool", false, "la tâche est-elle terminée?"],
        "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"],
        "retcode" => ["?int", null, "code de retour de la commande"],
        "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"],
      ],
    ],
    "" => [
      "auto_properties" => [
        "id" => "definition.id",
        "serial" => "definition.serial",
        "title" => "definition.title",
        "valid" => "definition.valid",
        "owner_login" => "definition.owner_login",
        "owner_name" => "definition.owner_name",
        "owner_page" => "definition.owner_page",
        "owner_params" => "definition.owner_params",
        "cmd" => "definition.cmd",
        "logfile" => "definition.logfile",
      ]
    ],
  ];

  const _AUTO_PROPERTIES = self::SCHEMA[""]["auto_properties"];

  const _SCHEMA = [
    "id" => ["string", null, "identifiant de la tâche"],
    "serial" => ["string", null, "numéro de série permettant de distinguer deux occurrences de la tâche"],
    "title" => ["?string", null, "description de la tâche"],
    "valid" => ["bool", false, "la tâche est-elle valide?"],
    "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"],
    "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"],
    "page" => ["?array", null, "page qui a créé cette tâche et paramètres à passer à la page"],
    "cmd" => [null, null, "commande à lancer"],
    "logfile" => ["?string", null, "sortie de commande"],
    "started" => ["bool", false, "la tâche a-t-elle été démarrée?"],
    "date_start" => ["?datetime", null, "date du démarrage de la tâche"],
    "pid" => ["?int", null, "PID du process contrôleur"],
    "stopped" => ["bool", false, "la tâche est-elle terminée?"],
    "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"],
    "retcode" => ["?int", null, "code de retour de la commande"],
    "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"],
  ];

  function __construct(string $id, bool $autoUpdate=false, ?callable $init=null) {
    # ne pas appeler parent::__construct()
    if (file_exists($id)) {
      $file = $id;
    } else {
      $authz = authz::get();
      $this->data = [
        "id" => $id,
        "serial" => 0,
        "owner_login" => $authz->getUsername(),
        "owner_name" => $authz->getDisplayName(),
      ];
      $file = tasks::pf("$id.task");
    }
    $this->init = $init;
    $this->file = $file;
    $this->ensureTask($autoUpdate);
  }

  /** @var ?callable */
  protected $init;

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

  private function ensureTask(bool $autoUpdate): void {
    lock::exlusive(self::LOCK);
    try {
      if (is_file($this->file)) {
        $this->_reload();
      } else {
        $this->_reset();
        $this->_save();
      }
      $logfile = $this->getLogfile();
      if ($logfile !== null) os::mkdirof($logfile);
    } finally {
      lock::release(self::LOCK);
    }
    if ($autoUpdate) $this->update();
  }

  private function _init(): bool {
    $init = $this->init;
    if ($init !== null) {
      func::call($init, $this);
      return true;
    }
    return false;
  }

  function init(): void {
    if ($this->init !== null) {
      lock::exlusive(self::LOCK);
      try {
        $this->_init();
        $this->_save();
      } finally {
        lock::release(self::LOCK);
      }
    }
  }

  private function _reset(): void {
    $authz = authz::get();
    $id = $this->data["id"];
    $serial = A::get($this->data, "serial", 0);
    $this->data = $this->ensureData([
      "id" => $id,
      "serial" => $serial + 1,
      "owner_login" => $authz->getUsername(),
      "owner_name" => $authz->getDisplayName(),
      "logfile" => logs::pf("$id/latest.log"),
    ]);
    $this->_init();
  }

  function reset(): void {
    lock::exlusive(self::LOCK);
    try {
      $this->_reset();
      $this->_save();
    } finally {
      lock::release(self::LOCK);
    }
  }

  private function _reload(): void {
    $this->data = unserialize(file_get_contents($this->file));
  }

  function reload(): void {
    lock::exlusive(self::LOCK);
    try {
      $this->_reload();
    } finally {
      lock::release(self::LOCK);
    }
  }

  private function _save(): void {
    os::mkdirof($this->file);
    $outf = fopen($this->file, "w+");
    fwrite($outf, serialize($this->data));
    fclose($outf);
  }

  function save(): void {
    lock::exlusive(self::LOCK);
    try {
      $this->_save();
    } finally {
      lock::release(self::LOCK);
    }
  }

  /** vérifier que l'objet est bien initialisé */
  function validate(): void {
    if (!$this->isValid()) {
      if ($this->getCmd() === null) {
        throw new ValueException("cmd is required");
      }
      $this->setValid(true);
      $this->save();
    }
  }

  function _launch(): void {
    $args = [
      __DIR__.'/../../lib/launch_task.php',
      "--envname", envs::get(),
      $this->getId(),
    ];
    $logfile = $this->getLogfile();
    if ($logfile !== null) A::merge($args, ["--logfile", $logfile]);
    $cmd = new Cmd($args);
    $cmd->addRedir("null");
    $cmd->passthru();
    $this->reload();
  }

  function isLaunchable(): bool {
    return $this->isStarted();
  }

  function launch(): void {
    if (!$this->isStartable()) return;
    if ($this->isDone()) $this->reset();
    $this->_launch();
  }

  function isUpdatable(): bool {
    return $this->isLaunchable() && !$this->isDone();
  }

  function update(): void {
    if ($this->isUpdatable()) $this->_launch();
    else $this->init();
  }

  function kill(): void {
    if (!$this->isStarted() || $this->isStopped()) return;
    $args = [
      __DIR__.'/../../lib/launch_task.php',
      "-e", envs::get(),
      "--kill",
      $this->getId(),
    ];
    $logfile = $this->getLogfile();
    if ($logfile !== null) A::merge($args, ["-L", $logfile]);
    $cmd = new Cmd($args);
    $cmd->addRedir("null");
    $cmd->passthru();
    $this->reload();
  }

  function isStartable(): bool {
    return !$this->isStarted() || $this->isDone();
  }

  /**
   * démarrer la commande. doit être lancé depuis launch_task.php
   */
  function ltStart(?string $logfile): void {
    $pid = pcntl_fork();
    if ($pid == -1) {
      # parent, impossible de forker
      throw new IllegalAccessException("unable to fork");
    } elseif ($pid) {
      # parent, fork ok
      $this->setStarted(true);
      $this->setDateStart(date::datetime());
      $this->setPid($pid);
      $this->save();
    } else {
      ## child, fork ok
      # Créer un groupe de process, pour pouvoir les tuer toutes en même temps
      posix_setsid();
      msg::push($oldMsg, null, [
        "output" => $logfile,
      ]);
      $retcode = -776;
      try {
        # tout d'abord synchroniser les fichiers le cas échéant
        $command = $this->get("command");
        $append = false;
        if ($command !== null) {
          $files = $command["files"];
          $forceSync = $this->get("force_sync");
          files::sync($files, $forceSync, $logfile, "wb");
          $append = true;
        }
        # puis lancer la commande
        $cmd = Cmd::with($this->getCmd());
        if ($logfile !== null) $cmd->addRedir("both", $logfile, $append);
        $cmd->fork_exec($retcode);
      } catch (Exception $e) {
        msg::error($e);
      } finally {
        $this->reload();
        $this->setStopped(true);
        $this->setDateStop(date::datetime());
        $this->setRetcode($retcode);
        $this->save();
        msg::pop($oldMsg);
      }
    }
  }

  /** arrêter la commande. doit être lancé depuis launch_task.php */
  function ltKill(?string $logfile): void {
    msg::push($oldMsg, null, $logfile);
    try {
      $id = $this->getId();
      $pid = $this->getPid();
      msg::action("$id: $pid");
      if (!posix_kill(-$pid, SIGKILL)) {
        switch (posix_get_last_error()) {
        case PCNTL_ESRCH:
          msg::afailure("process inexistant");
          break;
        case PCNTL_EPERM:
          msg::afailure("process non accessible");
          break;
        case PCNTL_EINVAL:
          msg::afailure("signal invalide");
          break;
        }
        return;
      }
      $timeout = 10;
      while ($this->ltIsUndead()) {
        sleep(1);
        if (--$timeout == 0) {
          msg::afailure("tentative d'arrêt de la tâche");
          return;
        }
      }
      msg::asuccess("tâche arrêtée");
      $this->setStopped(true);
      $this->setDateStop(date::datetime());
      $this->setRetcode(-787);
      $this->setDone(true);
      $this->save();
    } finally {
      msg::pop($oldMsg);
    }
  }

  function isReapable(): bool {
    return $this->isStopped() && !$this->isDone();
  }

  /**
   * marquer la commande comme terminée. doit être lancé depuis launch_task.php
   */
  function ltReap(): void {
    pcntl_waitpid($this->getPid(), $status);
    $this->setDone(true);
    $this->save();
  }

  /**
   * vérifier si on est dans le cas où la tâche est censée tourner mais en
   * réalité ce n'est pas le cas. doit être lancé depuis launch_task.php
   */
  function ltIsUndead(): bool {
    if (!posix_kill($this->getPid(), 0)) {
      switch (posix_get_last_error()) {
      case PCNTL_ESRCH:
        # process inexistant
        return true;
      case PCNTL_EPERM:
        # process auquel on n'a pas accès: ce doit être un autre process qui a
        # réutilisé le PID
        return true;
      case PCNTL_EINVAL:
        # ne devrait pas se produire
        return false;
      }
    }
    # process existant
    return false;
  }

  /**
   * marquer la tâche comme terminée avec un code d'erreur si elle n'existe
   * plus. doit être lancé depuis launch_task.php
   */
  function ltCleanUndead(): void {
    if (!$this->isStopped()) {
      $this->setStopped(true);
      $this->setDateStop(date::datetime());
      $this->setRetcode(-777);
    }
    $this->setDone(true);
    $this->save();
  }

  function getIdTitle(): string {
    $idTitle = $this->getId();
    $title = $this->getTitle();
    if ($title) $idTitle .= " -- $title";
    return $idTitle;
  }

  function getNameOrLogin(): string {
    $nameOrLogin = $this->getOwnerName();
    if ($nameOrLogin === null) $nameOrLogin = $this->getOwnerLogin();
    if ($nameOrLogin === null) $nameOrLogin = "(unknown)";
    return $nameOrLogin;
  }

  const MAX_LOG_SIZE = 256 * 1024;
  const CACTION_NONE = "n";
  const CACTION_REPLACE = "r";
  const CACTION_UPDATE = "u";

  function export(?int $serial=null, ?int $cs=null, ?int $ce=null): array {
    $task = $this->array();
    $dateStart = new Datetime($this->getDateStart());
    $dateStop = new Datetime($this->getDateStop());
    # $ca = action à faire par le client: replace ou update
    # ls = local start, le = local end    (local === server en l'occurrence)
    # cs = client start, ce = client end  (pour CACTION_REPLACE)
    # $ps = plus start, $pe = plus end    (pour CACTION_UPDATE)
    # $rs = read start, $re = read end
    if ($serial !== null && $cs !== null && $ce !== null && $this->isStarted()) {
      lock::exlusive(self::LOCK);
      $inf = false;
      try {
        $logfile = $this->getLogfile();
        if (!file_exists($logfile)) {
          # s'assurer que le fichier existe (il peut avoir été nettoyé entre temps)
          f::close(f::open($logfile, "cb"));
        }
        $inf = f::open($logfile, "rb");
        $le = f::seek($inf, 0, SEEK_END);
        $ls = $le - self::MAX_LOG_SIZE;
        if ($ls <= 0) {
          $ls = 0;
        } else {
          # trouver le premier saut de ligne
          $ls = f::find_nl($inf, $ls);
        }

        if ($serial != $this->getSerial()) {
          # nouvelle tâche, on recommence tout
          $rs = $cs = $ls;
          $re = $ce = $le;
          $ca = self::CACTION_REPLACE;
        } elseif ($ls <= $cs) {
          # cas courant, on rajoute du contenu, mais pas plus que MAX_LOG_SIZE
          $ls = $cs;
          $ps = 0;
          $pe = $le - $ce;
          $rs = $ce;
          $re = $le;
          $ca = self::CACTION_UPDATE;
          $cs = $ps;
          $ce = $pe;
        } elseif ($ls <= $ce) {
          # on a dépassé MAX_LOG_SIZE, il faut recalculer
          # garder une partie des logs précédents
          $ps = $ls - $cs;
          $pe = $le - $ce;
          $rs = $ce;
          $re = $le;
          $ca = self::CACTION_UPDATE;
          $cs = $ps;
          $ce = $pe;
        } else {
          # ne rien garder des logs précédents
          $rs = $cs = $ls;
          $re = $ce = $le;
          $ca = self::CACTION_REPLACE;
        }

        $logSize = $re - $rs;
        if ($logSize > 0) {
          f::seek($inf, $rs, SEEK_SET);
          $log = f::read($inf, $logSize);
          $lf = new BaseF(); #XXX
          $lf->formatContent($log);
        } elseif ($ca == self::CACTION_REPLACE) {
          $log = "";
        } else {
          $log = false;
        }
      } finally {
        if ($inf) f::close($inf);
        lock::release(self::LOCK);
      }
    } else {
      $cs = $ce = false;
      $ca = self::CACTION_NONE;
      $log = false;
    }
    $page = $this->getPage();
    if ($page !== null) {
      $dest = A::get($page, 0);
      $params = A::get($page, 1);
      $pageUrl = page::bu($dest, $params);
    } else {
      $pageUrl = false;
    }
    A::merge($task, [
      "id_title" => $this->getIdTitle(),
      "name_or_login" => $this->getNameOrLogin(),
      "page_url" => $pageUrl,
      "elapsed_start" => $dateStart->getElapsed()->formatAt(),
      "elapsed_stop" => $dateStop->getElapsed()->formatSince(),
      "elapsed_total" => $dateStart->getElapsed($dateStop)->formatDelay(),
      "launchable" => $this->isLaunchable(),
      "updatable" => $this->isUpdatable(),
      "startable" => $this->isStartable(),
      "reapable" => $this->isReapable(),
      "working" => $this->isStarted() && !$this->isDone(),
      "ok" => $this->isDone() && $this->getRetcode() == 0,
      "ko" => $this->isDone() && $this->getRetcode() != 0,
      "log" => $log,
    ]);
    return [$task, $ca, $cs, $ce];
  }

  #############################################################################
  const _AUTOGEN_CONSTS = [
    "_AUTO_GETTERS" => [Autogen::class, "auto_getters", self::SCHEMA],
    "_AUTO_SETTERS" => [Autogen::class, "auto_setters", self::SCHEMA],
  ];
  const _AUTOGEN_METHODS = [
    [Autogen::class, "auto_getters_methods", self::SCHEMA],
    [Autogen::class, "auto_setters_methods", self::SCHEMA],
  ];
  const _AUTO_GETTERS = /*autogen*/[
    'getId' => 'id',
    'getSerial' => 'serial',
    'getTitle' => 'title',
    'isValid' => 'valid',
    'getOwnerLogin' => 'owner_login',
    'getOwnerName' => 'owner_name',
    'getPage' => 'page',
    'getCmd' => 'cmd',
    'getLogfile' => 'logfile',
    'isStarted' => 'started',
    'getDateStart' => 'date_start',
    'getPid' => 'pid',
    'isStopped' => 'stopped',
    'getDateStop' => 'date_stop',
    'getRetcode' => 'retcode',
    'isDone' => 'done',
  ];
  const _AUTO_SETTERS = /*autogen*/[
    'setId' => 'id',
    'setSerial' => 'serial',
    'setTitle' => 'title',
    'setValid' => 'valid',
    'setOwnerLogin' => 'owner_login',
    'setOwnerName' => 'owner_name',
    'setPage' => 'page',
    'setCmd' => 'cmd',
    'setLogfile' => 'logfile',
    'setStarted' => 'started',
    'setDateStart' => 'date_start',
    'setPid' => 'pid',
    'setStopped' => 'stopped',
    'setDateStop' => 'date_stop',
    'setRetcode' => 'retcode',
    'setDone' => 'done',
  ];
}
