<?php

declare(strict_types=1);
namespace Adawolfa\ICMP;
use React;
use RuntimeException, Exception, InvalidArgumentException;

/**
 * ICMP ping for ReactPHP.
 *
 * @author Adam Klvač <adam@klva.cz>
 */
final class Ping
{

	/** @var React\EventLoop\LoopInterface */
	private $loop;

	/** @var React\Dns\Resolver\ResolverInterface */
	private $resolver;

	/** @var float */
	private $precision;

	/** @var resource|null */
	private $socket;

	/** @var React\EventLoop\TimerInterface */
	private $timer;

	/** @var int */
	private $id = 0;

	/** @var array */
	private $stack = [];

	/** @var bool */
	private static $hrTime = null;

	/**
	 * Ping constructor.
	 * @param float $precision response precision - interval between response collections
	 */
	public function __construct(React\EventLoop\LoopInterface $loop, React\Dns\Resolver\ResolverInterface $resolver, float $precision = .1)
	{
		if ($precision < .0) {
			throw new InvalidArgumentException('Precision must be a non-negative number.');
		}

		if (!function_exists('socket_create')) {
			throw new RuntimeException("Missing extension 'sockets'.");
		}

		$this->loop = $loop;
		$this->resolver = $resolver;
		$this->precision = $precision;

		if (self::$hrTime === null) {
			self::$hrTime = function_exists('hrtime');
		}
	}

	/**
	 * Sends a single ICMP echo request and resolves whenever a reply is returned or the time limit hits.
	 *
	 * @param string $host    remote host, either an IP address or a hostname
	 * @param float  $timeout reply time limit
	 */
	public function ping(string $host, float $timeout = 5.0): React\Promise\PromiseInterface
	{
		if ($timeout < .0) {
			throw new InvalidArgumentException('Timeout must be a non-negative number.');
		}

		return new React\Promise\Promise(function(callable $resolve, callable $reject) use($host, $timeout): void {

			$this->resolve($host)->then(function($host) use($timeout, $resolve, $reject): void {
				$this->send($host, $timeout, $resolve, $reject);
			}, $reject);

		});
	}

	/**
	 * Pings a host periodically and whenever a reply is returned or the time limit hits, calls the given function.
	 *
	 * @param string     $host     remote host, either an IP address or a hostname
	 * @param callable   $handler  will be called for every ping with time and an Exception in the second argument, if there's one
	 * @param float|null $interval interval between individual echo requests
	 * @param float      $timeout  reply time limit
	 * @return callable            call this function in order to interrupt the pinging
	 */
	public function periodic(string $host, callable $handler, float $interval = 1.0, float $timeout = 5.0): callable
	{
		if ($interval < .0) {
			throw new InvalidArgumentException('Interval must be a non-negative number.');
		}

		if ($timeout < .0) {
			throw new InvalidArgumentException('Timeout must be a non-negative number.');
		}

		$time = self::microtime();
		$stop = false;

		$ping = function() use(&$time, $host, $timeout, &$callback, &$error): void {
			$time = self::microtime();
			$this->ping($host, $timeout)
				->then($callback, $error);
		};

		$callback = function(float $time, Exception $exception = null) use(&$stop, $handler, $ping, $interval): void {

			if ($stop) {
				return;
			}

			// Ping binary does this, it seems.
			if ($time > $interval) {
				$interval = .0;
			}

			$this->loop->addTimer($interval, $ping);
			$handler($time, $exception);

		};

		$error = function(Exception $exception) use($callback, &$time): void {
			$callback(self::microtime() - $time, $exception);
		};

		$ping();

		return function() use(&$stop, $error): void {
			$error(new Exception('Interrupted.'));
			$stop = true;
		};
	}

	/**
	 * Builds and sends the ICMP packet to the resolved address.
	 */
	private function send(string $host, float $timeout, callable $resolve, callable $reject): void
	{
		$id = $this->id++;

		if ($id === 0x7ffffffe) {
			$this->id = 0; // Unlikely!
		}

		$packet

			// Type, code and 2 zeroed bytes for checksum.
			= chr(0x8) . chr(0x0)
			. chr(0x0) . chr(0x0)

			// Store ID in identifier and sequence field.
			. chr(($id >> (8 * 3)) & 0xff)
			. chr(($id >> (8 * 2)) & 0xff)
			. chr(($id >> (8 * 1)) & 0xff)
			. chr(($id >> (8 * 0)) & 0xff)

			// Data.
			. 'ping';

		self::checksum($packet);

		try {

			// This is where the event loop lag comes from.
			$sent = $this->call(Exception::class, 'socket_sendto', $this->socket(), $packet, strlen($packet), 0, $host, 0);

			if ($sent !== strlen($packet)) {
				throw new Exception('socket_sendto() buffer truncated.');
			}

			$timer = $this->loop->addTimer($timeout, function() use($host, $id): void {
				$this->clear($host, $id, new Exception('Request timed out.'));
			});

			$this->stack[$host][$id] = [self::microtime(), $resolve, $reject, $timer];
			$this->schedule();

		} catch (Exception $exception) {
			$reject($exception);
		}
	}

	/**
	 * Handles ICMP responses.
	 */
	private function collect(): void
	{
		for (;;) {

			// IPv4 + ICMP echo
			$size = @socket_recvfrom($this->socket, $buffer, 20 + 12, 0, $host, $_);

			if ($size === false) {
				break;
			}

			if ($size !== 20 + 12) {
				continue;
			}

			// Strip IPv4 packet header.
			$buffer = substr($buffer, 20);

			$id = ord($buffer[4]) << 24 | ord($buffer[5]) << 16 | ord($buffer[6]) << 8 | ord($buffer[7]);

			$message = $buffer;
			self::checksum($message);

			if ($message !== $buffer) {
				$this->clear($host, $id, new Exception('Response checksum mismatch.'));
				continue;
			}

			if (substr($buffer, 8) !== 'ping') {
				$this->clear($host, $id, new Exception('Data mismatch.'));
				continue;
			}

			$this->clear($host, $id);

		}
	}

	/**
	 * Dispatches the result.
	 */
	private function clear(string $host, int $id, Exception $exception = null): void
	{
		if (empty($this->stack[$host][$id])) {
			return;
		}

		[$started, $resolve, $reject, $timer] = $this->stack[$host][$id];

		unset($this->stack[$host][$id]);

		if (empty($this->stack[$host])) {
			unset($this->stack[$host]);
		}

		$this->loop->cancelTimer($timer);
		$this->schedule();

		if ($exception === null) {
			$resolve(self::microtime() - $started);
		} else {
			$reject($exception);
		}
	}

	/**
	 * Binds and returns the socket.
	 * @return resource
	 */
	private function socket()
	{
		if ($this->socket !== null) {
			return $this->socket;
		}

		$socket = $this->call(
			RuntimeException::class,
			'socket_create',
			AF_INET, SOCK_RAW,
			getprotobyname('icmp')
		);

		$this->call(RuntimeException::class, 'socket_set_nonblock', $socket);

		return $this->socket = $socket;
	}

	/**
	 * Configures timer.
	 */
	private function schedule(): void
	{
		$stop = empty($this->stack);

		if ($stop === ($this->timer === null)) {
			return;
		}

		if ($stop) {
			$this->loop->cancelTimer($this->timer);
			$this->timer = null;
			return;
		}

		$this->timer = $this->loop->addPeriodicTimer($this->precision, function(): void {
			$this->collect();
			$this->schedule();
		});
	}

	/**
	 * Calculates checksum for a message.
	 */
	private static function checksum(string &$message): void
	{
		$message[2] = chr(0);
		$message[3] = chr(0);

		for ($i = 0, $checksum = 0x0; $i < strlen($message); $i += 2) {
			$checksum += ord($message[$i]) << 8 | ord($message[$i + 1] ?? 0);
		}

		$checksum = (~$checksum) & 0xffff;
		$message[2] = chr($checksum >> 8);
		$message[3] = chr($checksum & 0xff);
	}

	/**
	 * Calls a socket function.
	 */
	private function call(string $exception, callable $function, ...$arguments)
	{
		$result = @call_user_func($function, ...$arguments);

		if ($result === false) {
			$errno = $this->socket === null ? socket_last_error() : socket_last_error($this->socket);
			throw new $exception(sprintf('%s() failed: %s', $function, socket_strerror($errno)), $errno);
		}

		return $result;
	}

	/**
	 * Hostname resolution.
	 */
	private function resolve(string $host): React\Promise\PromiseInterface
	{
		if (filter_var($host, FILTER_VALIDATE_IP) === $host) {
			return new React\Promise\FulfilledPromise($host);
		}

		return new React\Promise\Promise(function(callable $resolve, callable $reject) use($host): void {
			$this->resolver->resolve($host)->then($resolve, $reject);
		});
	}

	/**
	 * Returns precise time.
	 */
	private static function microtime(): float
	{
		return self::$hrTime ? hrtime(true) / 1e9 : microtime(true);
	}

	/**
	 * Closes the underlying socket.
	 */
	public function __destruct()
	{
		if ($this->socket !== null) {
			socket_close($this->socket);
		}
	}

}