<?php
namespace nur\sery\file;

use nur\sery\file\csv\csv_flavours;
use nur\sery\NoMoreDataException;
use nur\sery\os\EOFException;
use nur\sery\os\IOException;
use nur\sery\php\iter\AbstractIterator;
use nur\sery\ref\file\csv\ref_csv;
use nur\sery\str;
use nur\sery\ValueException;

/**
 * Class Stream: lecture/écriture générique dans un flux
 */
class Stream extends AbstractIterator implements IReader, IWriter {
  use TStreamFilter;

  protected static function probe_fd($fd, ?bool &$seekable=null, ?bool &$readable=null): void {
    $md = stream_get_meta_data($fd);
    $seekable = $md["seekable"];
    $mode = $md["mode"];
    $readable = strpos($mode, "r") !== false || strpos($mode, "+") !== false;
  }

  protected static function fd_is_seekable($fd): bool {
    self::probe_fd($fd, $seekable);
    return $seekable;
  }

  protected static function fd_is_readable($fd): bool {
    $mode = stream_get_meta_data($fd)["mode"];
    return strpos($mode, "r") !== false || strpos($mode, "+") !== false;
  }

  /** @var bool les opérations de verrouillages sont-elle activées? */
  const USE_LOCKING = false;

  /** @var bool faut-il lancer une exception s'il y a une erreur? */
  const THROW_ON_ERROR = true;

  /** @var resource */
  protected $fd;

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

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

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

  /** @var int nombre d'octets à ignorer au début du fichier lors d'un seek */
  protected $seekOffset = 0;

  /** @var int|null */
  protected $serial;

  /** @var array */
  protected $stat;

  function __construct($fd, bool $close=true, ?bool $throwOnError=null, ?bool $useLocking=null) {
    if ($fd === null) throw ValueException::null("resource");
    $this->fd = $fd;
    $this->close = $close;
    $this->throwOnError = $throwOnError ?? static::THROW_ON_ERROR;
    $this->useLocking = $useLocking ?? static::USE_LOCKING;
  }

  #############################################################################
  # File

  /**
   * @return resource|null retourner la resource associée à ce fichier si cela
   * a du sens
   */
  function getResource() {
    IOException::ensure_open($this->fd === null);
    $this->_streamAppendFilters($this->fd);
    return $this->fd;
  }

  protected function lock(int $operation, ?int &$wouldBlock=null): bool {
    $locked = flock($this->getResource(), $operation, $wouldBlock);
    if ($locked) return true;
    if ($operation & LOCK_NB) return false;
    else throw IOException::error();
  }

  protected function unlock(bool $close=false): void {
    if ($this->fd !== null) {
      flock($this->fd, LOCK_UN);
      if ($close) $this->close();
    }
  }

  function isatty(): bool {
    return stream_isatty($this->getResource());
  }

  /** obtenir des informations sur le fichier */
  function fstat(bool $reload=false): array {
    if ($this->stat === null || $reload) {
      $fd = $this->getResource();
      $this->stat = IOException::ensure_valid(fstat($fd), $this->throwOnError);
    }
    return $this->stat;
  }

  function getSize(?int $seekOffset=null): int {
    if ($seekOffset === null) $seekOffset = $this->seekOffset;
    return $this->fstat()["size"] - $seekOffset;
  }

  /** @throws IOException */
  function ftell(?int $seekOffset=null): int {
    $fd = $this->getResource();
    if ($seekOffset === null) $seekOffset = $this->seekOffset;
    return IOException::ensure_valid(ftell($fd), $this->throwOnError) - $seekOffset;
  }

  /**
   * @return int la position après avoir déplacé le pointeur
   * @throws IOException
   */
  function fseek(int $offset, int $whence=SEEK_SET, ?int $seekOffset=null): int {
    $fd = $this->getResource();
    if ($seekOffset === null) $seekOffset = $this->seekOffset;
    if ($whence === SEEK_SET) $offset += $seekOffset;
    IOException::ensure_valid(fseek($fd, $offset, $whence), $this->throwOnError, -1);
    return $this->ftell($seekOffset);
  }

  function seek(int $offset, int $whence=SEEK_SET): self {
    $this->fseek($offset, $whence);
    return $this;
  }

  /** fermer le fichier si c'est nécessaire */
  function close(bool $close=true, ?int $ifSerial=null): void {
    AbstractIterator::rewind();
    if ($this->fd !== null && $close && $this->close && ($ifSerial === null || $this->serial === $ifSerial)) {
      fclose($this->fd);
      $this->fd = null;
    }
  }

  function copyTo(IWriter $dest, bool $closeWriter=false, bool $closeReader=true): void {
    $srcr = $this->getResource();
    $destr = $dest->getResource();
    if ($srcr !== null && $destr !== null) {
      while (!feof($srcr)) {
        fwrite($destr, fread($srcr, 8192));
      }
    } else {
      $dest->fwrite($this->getContents(false));
    }
    if ($closeWriter) $dest->close();
    if ($closeReader) $this->close();
  }

  const DEFAULT_CSV_FLAVOUR = ref_csv::OO_FLAVOUR;

  /** @var string paramètres pour la lecture et l'écriture de flux au format CSV */
  protected $csvFlavour;

  function setCsvFlavour(?string $flavour): void {
    $this->csvFlavour = csv_flavours::verifix($flavour);
  }

  protected function getCsvParams($fd): array {
    $flavour = $this->csvFlavour;
    if ($flavour === null) {
      self::probe_fd($fd, $seekable, $readable);
      if (!$seekable || !$readable) $fd = null;
      if ($fd === null) {
        # utiliser la valeur par défaut
        $flavour = static::DEFAULT_CSV_FLAVOUR;
      } else {
        # il faut déterminer le type de fichier CSV en lisant la première ligne
        $pos = IOException::ensure_valid(ftell($fd));
        $line = fgets($fd);
        if ($line !== false) $line = strpbrk($line, ",;\t");
        if ($line === false) {
          # aucun séparateur trouvé, prender la valeur par défaut
          $flavour = static::DEFAULT_CSV_FLAVOUR;
        } else {
          $flavour = substr($line, 0, 1);
          $flavour = csv_flavours::verifix($flavour);
        }
        IOException::ensure_valid(fseek($fd, $pos), true, -1);
      }
      $this->csvFlavour = $flavour;
    }
    return csv_flavours::get_params($flavour);
  }

  #############################################################################
  # Reader

  /** @throws IOException */
  function fread(int $length): string {
    $fd = $this->getResource();
    return IOException::ensure_valid(fread($fd, $length), $this->throwOnError);
  }

  /**
   * lire la prochaine ligne. la ligne est retournée avec le caractère de fin
   * de ligne[\r]\n
   *
   * @throws EOFException si plus aucune ligne n'est disponible
   * @throws IOException si une erreur se produit
   */
  function fgets(?int $length=null): string {
    $fd = $this->getResource();
    if ($length === null) $r = fgets($fd);
    else $r = fgets($fd, $length);
    return EOFException::ensure_not_eof($r, $this->throwOnError);
  }

  /** @throws IOException */
  function fpassthru(): int {
    $fd = $this->getResource();
    return IOException::ensure_valid(fpassthru($fd), $this->throwOnError);
  }

  /**
   * retourner la prochaine ligne au format CSV ou null si le fichier est arrivé
   * à sa fin
   */
  function fgetcsv(): ?array {
    $fd = $this->getResource();
    $params = $this->getCsvParams($fd);
    $row = fgetcsv($fd, 0, $params[0], $params[1], $params[2]);
    if ($row === false && feof($fd)) return null;
    return IOException::ensure_valid($row, $this->throwOnError);
  }

  /**
   * lire la prochaine ligne. la ligne est retournée *sans* le caractère de fin
   * de ligne [\r]\n
   *
   * @throws EOFException si plus aucune ligne n'est disponible
   * @throws IOException si une erreur se produit
   */
  function readLine(): ?string {
    return str::strip_nl($this->fgets());
  }

  /** lire et retourner toutes les lignes */
  function readLines(): array {
    return iterator_to_array($this);
  }

  /**
   * verrouiller le fichier en lecture de façon inconditionelle (ignorer la
   * valeur de $useLocking). bloquer jusqu'à ce que le verrou soit disponible
   */
  function lockRead(): void {
    $this->lock(LOCK_SH);
  }

  /**
   * essayer de verrouiller le fichier en lecture. retourner true si l'opération
   * réussit. dans ce cas, il faut appeler {@link getReader()} avec l'argument
   * true
   */
  function canRead(): bool {
    if ($this->useLocking) return $this->lock(LOCK_SH + LOCK_NB);
    else return true;
  }

  /**
   * verrouiller en mode partagé puis retourner un objet permettant de lire le
   * fichier.
   */
  function getReader(bool $alreadyLocked=false): IReader {
    if ($this->useLocking && !$alreadyLocked) $this->lock(LOCK_SH);
    return new class($this->fd, ++$this->serial, $this) extends Stream {
      function __construct($fd, int $serial, Stream $parent) {
        $this->parent = $parent;
        $this->serial = $serial;
        parent::__construct($fd);
      }

      /** @var Stream */
      private $parent;

      function close(bool $close=true, ?int $ifSerial=null): void {
        if ($this->parent !== null && $close) {
          $this->parent->close(true, $this->serial);
          $this->fd = null;
          $this->parent = null;
        }
      }
    };
  }

  /** retourner le contenu du fichier sous forme de chaine */
  function getContents(bool $close=true, bool $alreadyLocked=false): string {
    $useLocking = $this->useLocking;
    if ($useLocking && !$alreadyLocked) $this->lock(LOCK_SH);
    try {
      return IOException::ensure_valid(stream_get_contents($this->fd), $this->throwOnError);
    } finally {
      if ($useLocking) $this->unlock($close);
      elseif ($close) $this->close();
    }
  }

  function unserialize(?array $options=null, bool $close=true, bool $alreadyLocked=false) {
    $args = [$this->getContents($close, $alreadyLocked)];
    if ($options !== null) $args[] = $options;
    return unserialize(...$args);
  }

  function decodeJson(bool $close=true, bool $alreadyLocked=false) {
    $contents = $this->getContents($close, $alreadyLocked);
    return json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
  }

  #############################################################################
  # Iterator

  protected function iter_setup(): void {
  }

  protected function iter_next(&$key) {
    try {
      return $this->fgets();
    } catch (EOFException $e) {
      throw new NoMoreDataException();
    }
  }

  private function _rewindFd(): void {
    self::probe_fd($this->fd, $seekable);
    if ($seekable) $this->fseek(0);
  }

  protected function iter_teardown(): void {
    $this->_rewindFd();
  }

  function rewind(): void {
    # il faut toujours faire un rewind sur la resource, que l'itérateur aie été
    # initialisé ou non
    if ($this->_hasIteratorBeenSetup()) parent::rewind();
    else $this->_rewindFd();
  }

  #############################################################################
  # Writer

  /** @throws IOException */
  function ftruncate(int $size=0, bool $rewind=true): self {
    $fd = $this->getResource();
    IOException::ensure_valid(ftruncate($fd, $size), $this->throwOnError);
    if ($rewind) rewind($fd);
    return $this;
  }

  /** @throws IOException */
  function fwrite(string $data, ?int $length=null): int {
    $fd = $this->getResource();
    if ($length === null) $r = fwrite($fd, $data);
    else $r = fwrite($fd, $data, $length);
    return IOException::ensure_valid($r, $this->throwOnError);
  }

  /** @throws IOException */
  function fputcsv(array $row): void {
    $fd = $this->getResource();
    $params = $this->getCsvParams($fd);
    if (csv_flavours::is_dumb($this->csvFlavour, $sep)) {
      $line = [];
      foreach ($row as $col) {
        $line[] = strval($col);
      }
      $line = implode($sep, $line);
      IOException::ensure_valid(fwrite($fd, "$line\n"), $this->throwOnError);
    } else {
      IOException::ensure_valid(fputcsv($fd, $row, $params[0], $params[1], $params[2]), $this->throwOnError);
    }
  }

  /** @throws IOException */
  function fflush(): self {
    $fd = $this->getResource();
    IOException::ensure_valid(fflush($fd), $this->throwOnError);
    return $this;
  }

  function writeLines(?iterable $lines): IWriter {
    if ($lines !== null) {
      foreach ($lines as $line) {
        $this->fwrite($line);
        $this->fwrite("\n");
      }
    }
    return $this;
  }

  /**
   * verrouiller le fichier en écriture de façon inconditionelle (ignorer la
   * valeur de $useLocking). bloquer jusqu'à ce que le verrou soit disponible
   */
  function lockWrite(): void {
    $this->lock(LOCK_EX);
  }

  /**
   * essayer de verrouiller le fichier en écriture. retourner true si l'opération
   * réussit. dans ce cas, il faut appeler {@link getWriter()} avec l'argument
   * true
   */
  function canWrite(): bool {
    if ($this->useLocking) return $this->lock(LOCK_EX + LOCK_NB);
    else return true;
  }

  /**
   * verrouiller en mode exclusif puis retourner un objet permettant d'écrire
   * dans le fichier
   */
  function getWriter(bool $alreadyLocked=false): IWriter {
    if ($this->useLocking && !$alreadyLocked) $this->lock(LOCK_EX);
    return new class($this->fd, ++$this->serial, $this) extends Stream {
      function __construct($fd, int $serial, Stream $parent) {
        $this->parent = $parent;
        $this->serial = $serial;
        parent::__construct($fd);
      }

      /** @var Stream */
      private $parent;

      function close(bool $close=true, ?int $ifSerial=null): void {
        if ($this->parent !== null && $close) {
          $this->parent->close(true, $this->serial);
          $this->fd = null;
          $this->parent = null;
        }
      }
    };
  }

  function putContents(string $contents, bool $close=true, bool $alreadyLocked=false): void {
    $useLocking = $this->useLocking;
    if ($useLocking && !$alreadyLocked) $this->lock(LOCK_EX);
    try {
      $this->fwrite($contents);
    } finally {
      if ($useLocking) $this->unlock($close);
      elseif ($close) $this->close();
    }
  }

  function serialize($object, bool $close=true, bool $alreadyLocked=false): void {
    $this->putContents(serialize($object), $close, $alreadyLocked);
  }

  function encodeJson($data, bool $close=true, bool $alreadyLocked=false): void {
    $contents = json_encode($data, JSON_UNESCAPED_SLASHES + JSON_UNESCAPED_UNICODE);
    $this->putContents($contents, $close, $alreadyLocked);
  }

  /**
   * annuler une tentative d'écriture commencée avec {@link self::canWrite()}
   */
  function cancelWrite(bool $close=true): void {
    if ($this->useLocking) $this->unlock($close);
    elseif ($close) $this->close();
  }
}
