#!/usr/bin/env php
<?php

ini_set('error_reporting', E_ALL);

function td(...$a) { foreach ($a as $v) var_dump($v); die('td()'); }
function tp(...$a) { foreach ($a as $v) var_dump($v); echo 'tp()'; if (count($a)===1) return $a[0]; }

class Config
{
	public $read_quant = 16 * 1024;
	public $total_bytes = null;
	public $terminal_nr_columns;
	public $update_interval = 0.2;

	function totalBytesConfigFromInputs($hin)
	{
		$a = fstat($hin);
		if ($a['size'] > 0)
			$this->total_bytes = $a['size'];
		else if (stream_get_meta_data($hin)['seekable']) {	# fstat() does not give nice size for block devices
			$orig = ftell($hin);
			$v = fseek($hin, 0, SEEK_END);
			if ($v === 0) {
				$v = ftell($hin);
				if ($v === false)
					die_with_error('unexpected failure to ftell(input)');
				$this->total_bytes = $v;
				$v = fseek($hin, $orig);
				if ($v !== 0)
					die_with_error('unexpected failure to fseek(input) back to original position'); } }
	}

	function doEnvironment()
	{
			# doesn't seem to work?
			# the var exists in bash, but doesn't get exported to processes - why?
		$n = getenv('COLUMNS');
		if (!empty($n))
			$this->terminal_nr_columns = (int)$n;
	}

	function doTerminalDetection()
	{
		$tput_n = (int)exec('tput cols');
		if (!empty($tput_n))
			$this->terminal_nr_columns = $tput_n;
	}

	protected
	function interpretBytesSpecifier(string $str) : int
	{
		$n = $m = null;
		sscanf($str, '%d%s', $n, $m);

		switch ($m) {
		case 'T':
		case 't':
			$n *= 1024;
		case 'G':
		case 'g':
			$n *= 1024;
		case 'M':
		case 'm':
			$n *= 1024;
		case 'K':
		case 'k':
			$n *= 1024;
		case null:
			return $n;
		default:
			die_with_error(3, sprintf('unsupported bytes multiplier "%s"', $m)); }
	}

	function doGetopt(array $argv) : array
	{
		$pos = null;
		$a = getopt('i:s:q:v', [ 'interval', 'size:' ], $pos);

		foreach ($a as $opt => $v) {
			switch ($opt) {
			case 's':
			case 'size':
				$this->total_bytes = $this->interpretBytesSpecifier($v);
				break;
			case 'i':
			case 'interval':
				$this->update_interval = (float)$v;
				break;
			default:
				die_with_error(3, sprintf('unsupported option: "%s"', $opt)); } }

		return array_slice($argv, $pos);
	}
}

class State
{
	public $time_start_sec;	# a float from microtime()
	public $processed_bytes;
	public $time_last_update;

	function __construct()
	{
		$this->time_start_sec = $this->timeCurrentSec();
		$this->processed_bytes = 0;
		$this->time_last_update = null;
	}

	function timeCurrentSec() : float
	{
		return microtime($get_as_float = true);
	}

	function timeRunningSec() : float
	{
		return $this->timeCurrentSec() - $this->time_start_sec;
	}

	function time_last_update_check_and_set(float $update_interval) : bool
	{
		$t = $this->timeCurrentSec();
		if ($t >= ($this->time_last_update + $update_interval)) {
			$this->time_last_update = $t;
			return true; }
		else
			return false;
	}

	function time_last_update_elapsed() : float
	{
		return $this->timeCurrentSec() - $this->time_last_update;
	}

	function time_last_update_set()
	{
		$this->time_last_update = $this->timeCurrentSec();
	}
}

function debug_printf(string $fmt, ...$a)
{
	fputs(STDERR, sprintf($fmt, ...$a));
}

function die_with_error(int $code, string $str)
{
	fputs(STDERR, $str .PHP_EOL);
	die($code);
}

function terminal_output(string $str)
{
	$hinfo = STDERR;
	fputs($hinfo, $str);
}

function terminal_enqueue_flush(string $str = null, bool $do_flush = true) : int
{
	static $last_line_len = 0;
	static $buffer;

	if ($str !== null)
		$buffer .= $str;

	if ($do_flush) {
		$last_line_len = strlen($buffer);
		terminal_output($buffer);
		$buffer = ''; }

	return $last_line_len;
}

function terminal_last_line_len() : int
{
	return terminal_enqueue_flush(null, false);
}

function terminal_enqueue(string $str)
{
	terminal_enqueue_flush($str, $do_flush = false);
}

function terminal_flush_line()
{
	terminal_enqueue_flush(null, $do_flush = true);
}

function progress_num(int $pos, Config $Config, State $State) : string
{
	[ $unit, $bs, $fmt ] = pick_iec_unit($State->processed_bytes);
	return sprintf($fmt .'%s', $bs, $unit);
}

function progress_bar(int $pos, Config $Config, State $State) : string
{
	$endpA_marker = '[';
	$endpB_marker = ']';
	$vesegment_maker = '=';
	$vpsegment_marker = '>';
	$bsegment_marker = ' ';

	if ($Config->total_bytes === null) {
		$v = 1.0;
		$perc_msg = '??%'; }
	else {
		$v = 1.0 * $State->processed_bytes / $Config->total_bytes;
		$perc_msg = sprintf(' % 5.1f%%', $v * 100); }

	$perc_msg_width = strlen($perc_msg);
	$fudge = ' ';
	$fudge_width = strlen($fudge);

	$bar_width = $Config->terminal_nr_columns - $pos - $perc_msg_width - $fudge_width;
	$endp_markers_width = strlen($endpA_marker .$endpB_marker);
	$vpsegment_marker_width = strlen($vpsegment_marker);
	$total_segments = $bar_width - $endp_markers_width;
	$total_segments = max(0, $total_segments);
	$total_segments = min($total_segments, $bar_width);

	$vsegments = min(floor($total_segments * $v), $total_segments);
	$vpsegments = ($vsegments > 0);
	$vesegments = $vsegments - ($vpsegments * $vpsegment_marker_width);
	$bsegments = $total_segments - $vsegments;

	return $endpA_marker .str_repeat($vesegment_maker, $vesegments) .str_repeat($vpsegment_marker, $vpsegments)
		.str_repeat($bsegment_marker, $bsegments) .$endpB_marker
		.$perc_msg;
}

function progress_running_time(int $pos, Config $Config, State $State) : string
{
		# force integer division
	$sec = (int)round($State->timeRunningSec());

	return sprintf('%02d:%02d:%02d',
		$sec / 60 / 60,
		$sec / 60 % 60,
		$sec % 60 % 60 );
}

function pick_iec_unit(float $bs, int $width = 4) : array
{
	$u = [
		0 => 'B',
		1 => 'KiB',
		2 => 'MiB',
		3 => 'GiB',
		4 => 'TiB',
		5 => 'PiB', ];

	$se = intval(floor(log($bs, 1024)));
	if (array_key_exists($se, $u))
		;
	else
		$se = 0;

	$unit = $u[$se];
	$xbs = 1.0 * $bs / (1024 ** $se);

	$prec = max(0, $width - 2 - intval(floor(log10(round($xbs, 1)))));
	$fmt = sprintf('%%%d.%df', $width, $prec);

	return [ $unit, $xbs, $fmt ];
}

function calc_overall_bandwidth(Config $Config, State $State) : float # [ bytes / sec ]
{
	return ((float)$State->processed_bytes) / $State->timeRunningSec();
}

function progress_overall_bandwidth_info(int $pos, Config $Config, State $State) : string
{
	[ $unit, $bs, $fmt ] = pick_iec_unit(calc_overall_bandwidth($Config, $State));

	return sprintf('[' .$fmt .'%s/s]', $bs, $unit);
}

function estimate_overall_eta_seconds(Config $Config, State $State) : ?int
{
	if ($Config->total_bytes === null)
		return null;
	$bw = calc_overall_bandwidth($Config, $State);
	if ($bw <= 0)
		return null;
	$remaining_bytes = $Config->total_bytes - $State->processed_bytes;
	if ($remaining_bytes < 0)
		return null;
	$remaining_seconds = $remaining_bytes / $bw;
	return (int)ceil($remaining_seconds);
}

function progress_overall_eta_info(int $pos, Config $Config, State $State)
{
	$sec = estimate_overall_eta_seconds($Config, $State);
	if ($sec === null)
		return sprintf('ETA ?:??:??');

	return sprintf('ETA %d:%02d:%02d',
		$sec / 60 / 60,
		$sec / 60 % 60,
		$sec % 60 % 60 );
}

function show_progress(Config $Config, State $State) {
	$pos = 0;
	$XPOS = function(string $str) use(&$pos) : string
	{
		$pos += strlen($str);
		return $str;
	};

	if (1)
		terminal_op_clear_line();
	else
		terminal_output("\n");

	terminal_enqueue($XPOS(progress_num($pos, $Config, $State)));
	terminal_enqueue($XPOS($separator = ' '));
	terminal_enqueue($XPOS(progress_running_time($pos, $Config, $State)));
	terminal_enqueue($XPOS($separator = ' '));
		# i think the original uses momentary bandwidth...?
	terminal_enqueue($XPOS(progress_overall_bandwidth_info($pos, $Config, $State)));
	terminal_enqueue($XPOS($separator = ' '));
	terminal_enqueue($XPOS(progress_overall_eta_info($pos, $Config, $State)));
	terminal_enqueue($XPOS($separator = ' '));
	terminal_enqueue($XPOS(progress_bar($pos, $Config, $State)));
	terminal_flush_line();
}

function adjust_quant(Config $Config, State $State)
{
	$a = [ $Config->read_quant ];
	if ($State->processed_bytes > 128 * 1024)
		$a[] = 128 * 1024;
#	if ($State->processed_bytes > 1024*1024)
#		$a[] = 1024 * 1024;
	$Config->read_quant = max($a);
}

function terminal_is_lineaddr() : bool
{
	static $ret = null;

	if ($ret === null) {
		switch (getenv('TERM')) {
		default:
		case 'dumb':
			$ret = false;
			break;
		case 'linux':
		case 'xterm':
			$ret = true; } }

	return $ret;
}

function terminal_op_clear_line()
{
	if (terminal_is_lineaddr())
		terminal_lineaddr_op_clear_line();
	else
		terminal_dumb_op_clear_line();
}

function terminal_dumb_op_clear_line()
{
	$cnt = terminal_last_line_len();
	$rubout = "\x08";
	terminal_output(str_repeat($rubout, $cnt));
}

function terminal_lineaddr_op_clear_line()
{
	$str = "\r";
	terminal_output($str);
}

	# dummy run, just to get the $argv2
$argv2 = (new Config())->doGetopt($argv);

switch (count($argv2)) {
case 0:
	$hin = STDIN;
	break;
case 1:
	if ($argv2[0] === '-')
		$hin = STDIN;
	else {
		$hin = fopen($argv2[0], 'rb');
		if ($hin === false)
			die_with_error(2, sprintf('Failed to open input "%s"', $argv2[0])); }
	break;
default:
	throw new Exception('unsupported case: multiple input files'); }

$hout = STDOUT;

$Config = new Config();
$Config->totalBytesConfigFromInputs($hin);
$Config->doTerminalDetection();
$Config->doEnvironment();
$Config->doGetopt($argv);

$State = new State();

$continue_read = true;
$data = null;
while ($continue_read || ($data !== null)) {
	if (feof($hin))
		$continue_read = false;
	if (feof($hout))
		die_with_error(1, 'Output write error');

	$reads = $writes = $exceptions = [];
	if ($continue_read && ($data === null)) {
		$reads[] = $hin;
		$exceptions[] = $hin; }
	if ($data !== null) {
		$writes[] = $hout;
		$exceptions[] = $hout; }

	$v = stream_select($reads, $writes, $excepts, 0, 200*1000);
	if ($v === false)
		throw new RuntimeException('stream_select() failed');

	if ($excepts)
		throw new die_with_error(3, 'unexpected error conditions on I/O');

	if ($reads) {
		$data = fread($hin, $Config->read_quant);
		if ($data === false)
			die_with_error(2, 'Input read error'); }

	if ($writes) {
		$v = fwrite($hout, $data);
		if ($v === false)
			die_with_error(1, 'Output write error');
		$State->processed_bytes += strlen($data);
		$data = null; }

	if ($State->time_last_update_check_and_set($Config->update_interval))
		show_progress($Config, $State);

	adjust_quant($Config, $State);
}

if (fclose($hout) === false)
	die_with_error(1, 'Output write error');

terminal_output("\n");
