<?php
namespace nur\b\date;

use nur\b\IllegalAccessException;
use nur\b\ValueException;
use nur\b\values\IValueState;

/**
 * Class Time: une heure allant de 0h à 24h inclus.
 *
 * la seule utilisation autorisée de "24h" est comme borne supérieure pour une
 * plage horaire.
 *
 * Cet objet est considéré comme immutable. les seules méthodes publiques qui
 * permettent de le modifier en place sont {@link Time::wrapStart()},
 * {@link Time::wrapEnd()} et {@link Time::set()}
 */
class Time implements IValueState {
  const UNIT_HOURS = 3600;
  const UNIT_MINUTES = 60;
  const UNIT_SECONDS = 1;

  /** @var string format normalisé pour l'analyse */
  const PATTERN = '/^(\d+):(\d{2}):(\d{2})$/';
  /** @var string format par défaut pour l'affichage */
  const FORMAT = "HH:MM:SS";

  static function parse_time($time, int $unit=self::UNIT_SECONDS): int {
    if ($time instanceof Time) {
      $seconds = $time->getSeconds();
    } elseif ($time === null || is_int($time)) {
      $seconds = $time !== null? $time: 0;
      if ($unit !== null) $seconds *= $unit;
    } elseif (is_array($time)) {
      [$h, $m, $s] = $time;
      $seconds = $h * 3600 + $m * 60 + $s;
    } elseif (is_string($time) && preg_match(self::PATTERN, $time, $ms)) {
      $seconds = intval($ms[1]) * 3600 + intval($ms[2]) * 60 + intval($ms[3]);
    } else {
      throw ValueException::invalid_value($time, "time");
    }
    return $seconds;
  }

  /**
   * @return int la valeur de l'unité en secondes, pour le constructeur et les
   * méthodes {@link newu()}, {@link addu()} et {@link subu()}
   */
  function UNIT(): int {
    return static::UNIT;
  } const UNIT = self::UNIT_SECONDS;

  /** @return bool s'il faut garder les heures dans la plage [0, 24h] */
  protected function WRAP(): bool {
    return static::WRAP;
  } const WRAP = true;

  function __construct($time=null) {
    if ($time !== false) {
      $this->setSeconds(self::parse_time($time, $this->UNIT()));
    }
  }

  /** cet objet est-il l'instance nulle? */
  function isNull(): bool { return false; }

  /** cet objet est-il l'heure indéfinie? */
  function isUndef(): bool { return false; }

  /**
   * wrapper l'heure pour la garder dans la plage [0h, 24h[ ce qui la rend
   * propice à l'utilisation comme borne inférieure d'une période
   */
  function wrapStart(): self {
    $seconds = $this->seconds;
    while ($seconds < 0) $seconds += 86400;
    if ($seconds >= 86400) $seconds %= 86400;
    $this->seconds = $seconds;
    return $this;
  }

  /**
   * wrapper l'heure pour la garder dans la plage [0h, 24h] ce qui la rend
   * propice à l'utilisation comme borne supérieure d'une période
   */
  function wrapEnd(): self {
    $seconds = $this->seconds;
    while ($seconds < 0) $seconds += 86400;
    if ($seconds > 86400) $seconds %= 86400;
    $this->seconds = $seconds;
    return $this;
  }

  protected function afterUpdate(): void {
    if ($this->WRAP()) $this->wrapEnd();
  }

  /** @var int */
  protected $seconds;

  /** @return int le nombre de secondes */
  function getSeconds(): int {
    return $this->seconds;
  }

  /**
   * mettre à jour cet objet avec le nombre de secondes spécifié
   *
   * @return int le nombre de seconde effectif, après correction
   */
  protected function setSeconds(?int $seconds): int {
    if ($seconds === null) {
      $hms = date('His');
      $seconds = intval(substr($hms, 0, 2)) * 3600
        + intval(substr($hms, 2, 2)) * 60
        + intval(substr($hms, 4, 2));
    }
    $adjust = $seconds % $this->UNIT();
    if ($seconds < 0) $adjust = -$adjust;
    $seconds -= $adjust;
    $this->seconds = $seconds;
    $this->afterUpdate();
    return $this->seconds;
  }

  /** formatter cette heure pour affichage */
  function format(?string $format=null): string {
    if ($format === null) $format = static::FORMAT;
    $v = $this->seconds;
    $h = intdiv($v, 3600); $v = $v % 3600;
    $m = intdiv($v, 60);
    $s = $v % 60;
    $searches = [
      "HH", "H",
      "MM", "M",
      "SS", "S",
    ];
    $replaces = [
      sprintf("%02u", $h), strval($h),
      sprintf("%02u", $m), strval($m),
      sprintf("%02u", $s), strval($s),
    ];
    return str_replace($searches, $replaces, $format);
  }

  function __toString(): string {
    return $this->format();
  }

  /** @return int le nombre d'unités */
  function getu(): int {
    return intdiv($this->seconds, $this->UNIT());
  }

  /** créer une nouvelle heure avec le nombre d'unités spécifiées */
  function newu(int $count): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($count * $this->UNIT());
    return $clone;
  }

  /** créer une nouvelle heure en ajoutant à cette heure le nombre d'unités spécifiées */
  function addu(int $count): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($clone->getSeconds() + $count * $this->UNIT());
    return $clone;
  }

  /** créer une nouvelle heure en soustrayant à cette heure le nombre d'unités spécifiées */
  function subu(int $count): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($clone->getSeconds() - $count * $this->UNIT());
    return $clone;
  }

  /** mettre à jour cette heure avec le nombre de secondes d'une autre instance */
  function set(Time $time): self {
    $this->setSeconds($time->getSeconds());
    return $this;
  }

  /** créer une nouvelle heure copie de l'heure spécifiée */
  function new(Time $time): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef() || $time->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($time->getSeconds());
    return $clone;
  }

  /** créer une nouvelle heure en ajoutant à cette heure l'heure spécifiée */
  function add(Time $time): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef() || $time->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($clone->getSeconds() + $time->getSeconds());
    return $clone;
  }

  /** créer une nouvelle heure en soustrayant à cette heure l'heure spécifiée */
  function sub(Time $time): self {
    if ($this->isNull()) return self::null();
    if ($this->isUndef() || $time->isUndef()) return self::undef();
    $clone = clone $this;
    $clone->setSeconds($clone->getSeconds() - $time->getSeconds());
    return $clone;
  }

  /**
   * comparer avec l'heure spécifiée. retourner une valeur négative, égale à
   * zéro ou positive suivant le résultat de la comparaison
   */
  function cmp(Time $time): int {
    return $this->seconds - $time->getSeconds();
  }

  /** tester si cette heure est avant l'heure spécifiée */
  function before(Time $other): bool {
    return $this->seconds <= $other->getSeconds();
  }

  /** tester si cette heure est après l'heure spécifiée */
  function after(Time $other): bool {
    return $this->seconds >= $other->getSeconds();
  }

  #############################################################################

  private static $null;

  /**
   * @return Time une instance immutable représentant la valeur nulle.
   *
   * En général, on utilise directement null pour indiquer qu'il n'y a pas de
   * valeur, mais cette instance peut etre utilisée dans les contextes où null
   * n'est pas autorisé
   */
  static final function null(): self {
    if (self::$null === null) {
      self::$null = new class extends Time {
        function __construct() {
          parent::__construct(false);
          $this->seconds = 0;
        }
        function isNull(): bool { return true; }
        function wrapStart(): Time {
          throw IllegalAccessException::immutable_object();
        }
        function wrapEnd(): Time {
          throw IllegalAccessException::immutable_object();
        }
        function setSeconds(?int $seconds): int {
          throw IllegalAccessException::immutable_object();
        }
        function __toString(): string {
          return "";
        }
      };
    }
    return self::$null;
  }

  private static $undef;

  /**
   * @return Time une instance immutable représentant une heure non définie,
   * (c'est à dire qu'on ne sait pas de quelle heure il s'agit). Cette
   * instance peut etre utilisée s'il faut pouvoir faire la différence entre
   * pas de valeur (null) et une valeur non définie (false)
   */
  static final function undef(): self {
    if (self::$undef === null) {
      self::$undef = new class extends Time {
        function __construct() {
          parent::__construct(false);
          $this->seconds = 0;
        }
        function isUndef(): bool { return true; }
        function wrapStart(): Time {
          throw IllegalAccessException::immutable_object();
        }
        function wrapEnd(): Time {
          throw IllegalAccessException::immutable_object();
        }
        function setSeconds(?int $seconds): int {
          throw IllegalAccessException::immutable_object();
        }
        function __toString(): string {
          return "";
        }
      };
    }
    return self::$undef;
  }
}
