<?php

namespace MightyPork\Utils;

use Collator;
use Illuminate\Database\Events\QueryExecuted;
use MightyPork\Exceptions\ArgumentException;
use Illuminate\Log\Events\MessageLogged;
use Log;
use MightyPork\Exceptions\FormatException;
use MongoDB\BSON\ObjectID;
use Monolog\Formatter\LineFormatter;
use ReflectionClass;
use ReflectionMethod;

/**
 * Miscellaneous utilities, mostly from FB2
 */
class Utils
{
	/** @var Collator */
	private static $collator = null;

	/**
	 * Clamp to an array with lower and upper bounds
	 *
	 * @param float $value
	 * @param float[] $arr
	 * @return float
	 */
	public static function arrClamp($value, $arr)
	{
		if (!$arr) return $value;
		if ($arr[0] !== null) $value = max($arr[0], $value);
		if ($arr[1] !== null) $value = min($arr[1], $value);

		return $value;
	}

	/**
	 * Convert possible "boolean" values to 1/0
	 *
	 * TODO unit test
	 *
	 * @param mixed $value boolean, integer or string
	 * @return int 1 or 0
	 */
	public static function parseBool01($value)
	{
		// already boolean - to 0 / 1
		if (is_bool($value)) {
			return $value ? 1 : 0;
		}

		if ($value === null) return false;

		// convert to string if needed (ie. SimpleXMLElement)
		// small overhead for numbers (negligible)
		$value = (string) $value;

		// Number or numeric string
		if (is_numeric($value)) {
			return ((int) $value) != 0 ? 1 : 0;
		}

		// match string constants
		switch (strtolower("$value")) {
			case 'on':
			case 'true':
			case 'yes':
				return 1;

			case 'off':
			case 'false':
			case 'no':
			case 'null':
			case '': // empty string
				return 0;

			default:
				Log::warning("Could not convert \"$value\" to boolean.");
				return 0;
		}
	}

	/**
	 * Convert possible "boolean" values to true/false
	 *
	 * @param mixed $value boolean, integer or string
	 * @return bool
	 */
	public static function parseBool($value)
	{
		return (bool) self::parseBool01($value);
	}

	/**
	 * Get string representation of object type.
	 *
	 * Aliased as global helper "typeof"
	 *
	 * TODO unit test
	 *
	 * @param mixed $var inspected variable
	 * @return string
	 */
	public static function getType($var)
	{
		if (is_object($var)) return get_class($var);
		if (is_null($var)) return 'null';
		if (is_string($var)) return 'string';
		if (is_array($var)) return 'array';
		if (is_int($var)) return 'integer';
		if (is_bool($var)) return 'boolean';
		if (is_float($var)) return 'float';
		if (is_resource($var)) return 'resource';

		return 'unknown';
	}

	/**
	 * provide a Java style exception trace
	 *
	 * @param \Throwable $e
	 * @return array stack trace as array of lines
	 * from comment at: http://php.net/manual/en/class.exception.php
	 */
	public static function getStackTrace($e)
	{
		return explode("\n", self::getStackTraceAsString($e));
	}

	/**
	 * provide a Java style exception trace
	 *
	 * @param \Throwable $e
	 * @param array|null $seen internal for recursion
	 * @return string stack trace as string with multiple lines
	 */
	public static function getStackTraceAsString($e, $seen = null)
	{
		if (!config('fb.pretty_stack_trace')) {
			return $e->getTraceAsString();
		}

		$starter = $seen ? 'Caused by: ' : '';
		$result = [];
		if (!$seen) $seen = [];
		$trace = $e->getTrace();
		$prev = $e->getPrevious();
		$result[] = sprintf('%s%s: %s', $starter, get_class($e), $e->getMessage());
		$file = $e->getFile();
		$line = $e->getLine();
		while (true) {
			$current = "$file:$line";
			if (is_array($seen) && in_array($current, $seen)) {
				$result[] = sprintf("    ... %d more", count($trace) + 1);
				break;
			}
			$resline = sprintf("    at %s%s%s(%s%s%s)",
				count($trace) && array_key_exists('class', $trace[0]) ? $trace[0]['class'] : '',
				count($trace) && array_key_exists('class', $trace[0]) && array_key_exists('function', $trace[0]) ? '->' : '',
				count($trace) && array_key_exists('function', $trace[0]) ? $trace[0]['function'] : '(main)',
				$line === null ? $file : basename($file),
				$line === null ? '' : ':',
				$line === null ? '' : $line);

			$result[] = $resline;

			if (
				false !== strpos($resline, "Illuminate\\Console\\Command->execute") ||
				false !== strpos($resline, "Illuminate\\Routing\\ControllerDispatcher->dispatch") ||
				false !== strpos($resline, "Unknown Source") ||
				false !== strpos($resline, "eval(SandboxController.php")
			) {
				$result[] = sprintf("    ... %d more", count($trace) + 1);
				break;
			}

			if (is_array($seen)) {
				$seen[] = "$file:$line";
			}

			if (!count($trace))	break;

			$file = array_key_exists('file', $trace[0]) ? $trace[0]['file'] : 'Unknown Source';
			$line = array_key_exists('file', $trace[0]) && array_key_exists('line', $trace[0]) && $trace[0]['line'] ? $trace[0]['line'] : null;
			array_shift($trace);
		}

		$result = join("\n", $result);
		if ($prev) {
			$result .= "\n" . self::getStackTraceAsString($prev, $seen);
		}

		return $result;
	}

	/**
	 * Diff arrays of objects by one of their properties.
	 *
	 * TODO unit test
	 *
	 * @param array  $array1
	 * @param array  $array2
	 * @param string $prop property name (accessed using the arrow operator)
	 * @return array of (first - second) array.
	 */
	public static function arrayDiffProp($array1, $array2, $prop)
	{
		return array_udiff($array1, $array2, function ($a, $b) use ($prop) {
			return $a->$prop - $b->$prop;
		});
	}

	/**
	 * Sort array by field of each item
	 *
	 * TODO unit test
	 *
	 * @param array           $array the array to sort
	 * @param string|string[] $prop  property or properties
	 */
	public static function arraySortProp(&$array, $prop)
	{
		if (!is_array($prop)) $prop = [$prop];

		@usort($array, function ($a, $b) use ($prop) {

			// compare by each property
			foreach ($prop as $pr) {
				$cmp = strcasecmp($a->$pr, $b->$pr);
				if ($cmp != 0) return $cmp;
			}

			return 0;
		});
	}

	/**
	 * Group an array using a callback or each element's property/index
	 *
	 * TODO unit test
	 *
	 * @param array           $arr     array to group
	 * @param callable|string $grouper hashing function, or property/index of each item to group by
	 * @return array grouped
	 */
	public static function groupBy(array $arr, $grouper)
	{
		$first_el = reset($arr);
		$is_array = is_array($first_el) || ($first_el instanceof \ArrayAccess);

		// get real grouper func
		$func = is_callable($grouper) ? $grouper : function ($x) use ($is_array, $grouper) {
			if ($is_array) {
				return $x[$grouper];
			} else {
				return $x->$grouper;
			}
		};

		$grouped = [];

		foreach ($arr as $item) {
			$hash = $func($item);
			if (!isset($grouped[$hash])) {
				$grouped[$hash] = [];
			}

			$grouped[$hash][] = $item;
		}

		return $grouped;
	}

	/**
	 * Get union of two arrays, preserving order if possible.
	 *
	 * @param array $ar1
	 * @param array $ar2
	 * @return array merged
	 */
	public static function arrayUnion(array $ar1, array $ar2)
	{
		return array_unique(array_merge($ar1, $ar2));
	}

	/**
	 * Create an array with keys from given array and all the same values.
	 *
	 * It's array_combine() combined with array_fill()
	 *
	 * @param array $keys
	 * @param mixed $fill the value for all keys
	 * @return array
	 */
	public static function arrayKeysFill(array $keys, $fill)
	{
		return array_combine($keys, array_fill(0, count($keys), $fill));
	}

	/**
	 * Extract n-th bit from an int array.
	 *
	 * Bits are numbered from LSB to MSB, and from array index 0 upwards
	 *
	 * TODO unit test
	 *
	 * @param array $ints     array of ints
	 * @param int   $bit      index of a bit to retrieve
	 * @param int   $itemSize number of bits per array item, default 16
	 * @return int the bit, 0/1
	 */
	public static function bitFromIntArray(array $ints, $bit, $itemSize = 16)
	{
		$nth = (int) floor($bit / $itemSize);
		$bit_n = $bit - ($itemSize * $nth);

		if ($nth > count($ints)) return 0;

		return (int) (0 != ($ints[$nth] & (1 << $bit_n)));
	}

	/**
	 * Set socket timeout in MS
	 *
	 * @param resource $socket     socket
	 * @param int      $SO_xxTIMEO timeout type constant
	 * @param int      $timeout_ms timeout (millis)
	 */
	public static function setSocketTimeout(&$socket, $SO_xxTIMEO, $timeout_ms)
	{
		$to_sec = floor($timeout_ms / 1000);
		$to_usec = ($timeout_ms - $to_sec * 1000) * 1000;
		$timeout = ['sec' => $to_sec, 'usec' => $to_usec];
		socket_set_option($socket, SOL_SOCKET, $SO_xxTIMEO, $timeout);
	}

	/**
	 * Check if value is in given range
	 *
	 * @param int|float $value value
	 * @param int|float $low   lower bound - included
	 * @param int|float $high  upper bound - included
	 * @return bool is in range
	 */
	public static function inRange($value, $low, $high)
	{
		// swap bounds if reverse
		if ($low > $high) {
			$sw = $low;
			$low = $high;
			$high = $sw;
		}

		return $value >= $low && $value <= $high;
	}

	/**
	 * Reverse the effect of array_chunk.
	 *
	 * @param array $array
	 * @return array
	 */
	public static function array_unchunk($array)
	{
		return call_user_func_array('array_merge', $array);
	}

	/**
	 * Clamp value to given bounds
	 *
	 * @param float $value input
	 * @param float $min   lower bound - included
	 * @param float $max   upper bound - included
	 * @return float result
	 */
	public static function clamp($value, $min, $max)
	{
		// swap bounds if reverse
		if ($min > $max) {
			$sw = $min;
			$min = $max;
			$max = $sw;
		}

		if ($value < $min) {
			$value = $min;
		} else if ($value > $max) {
			$value = $max;
		}
		return $value;
	}

	/**
	 * Get today as the nr of days since epoch
	 *
	 * @return int nr of days
	 */
	public static function unixDay()
	{
		$dt = new \DateTime('@' . ntime());
		$epoch = new \DateTime('1970-01-01');
		$diff = $dt->diff($epoch);
		return (int) $diff->format("%a");
	}

	/**
	 * Check if the value looks like a Mongo key (ObjectID).
	 * Mongo key is a 24 chars long hex string, so it's unlikely to occur as an alias.
	 *
	 * TODO unit test
	 *
	 * @param mixed $key object to inspect (string or ObjectID)
	 * @return bool is _id value
	 */
	public static function isMongoKey($key)
	{
		if ($key instanceof ObjectID) return true;

		return (is_string($key) and strlen($key) === 24 and ctype_xdigit($key));
	}

	/**
	 * Check if array is associative
	 *
	 * TODO unit test
	 *
	 * @param array $array
	 * @return bool is associative (keys are not a sequence of numbers starting 0)
	 */
	public static function isAssoc(array $array)
	{
		return array_keys($array) !== range(0, count($array) - 1);
	}

	/**
	 * Ceil to closest multiple of 0.5
	 *
	 * @param float $n
	 * @return float rounded up to 0.5
	 */
	public static function halfCeil($n)
	{
		return ceil($n * 2) / 2;
	}

	/**
	 * Round to closest multiple of 0.5
	 *
	 * @param float $n
	 * @return float rounded to 0.5
	 */
	public static function halfRound($n)
	{
		return round($n * 2) / 2;
	}

	/**
	 * Floor to closest multiple of 0.5
	 *
	 * @param float $n
	 * @return float rounded down to 0.5
	 */
	public static function halfFloor($n)
	{
		return ceil($n * 2) / 2;
	}

	/**
	 * A dirty way to read private or protected fields.
	 * Use only for debugging or vendor hacking when really needed.
	 *
	 * @param mixed  $obj
	 * @param string $prop
	 * @return mixed
	 */
	public static function readProtected($obj, $prop)
	{
		$reflection = new ReflectionClass($obj);
		$property = $reflection->getProperty($prop);
		$property->setAccessible(true);
		return $property->getValue($obj);
	}

	/**
	 * A dirty way to write private or protected fields.
	 * Use only for debugging or vendor hacking when really needed.
	 *
	 * @param mixed  $obj
	 * @param string $prop
	 * @param mixed $value
	 */
	public static function writeProtected($obj, $prop, $value)
	{
		$reflection = new ReflectionClass($obj);
		$property = $reflection->getProperty($prop);
		$property->setAccessible(true);
		$property->setValue($obj, $value);
	}

	/**
	 * A dirty way to call private or protected methods.
	 * Use only for debugging or vendor hacking when really needed.
	 *
	 * @param mixed $obj
	 * @param string $methodName
	 * @param array ...$arguments
	 * @return mixed
	 */
	public static function callProtected($obj, $methodName, ...$arguments)
	{
		$method = new ReflectionMethod(get_class($obj), $methodName);
		$method->setAccessible(true);
		return $method->invoke($obj, ...$arguments);
	}

	/**
	 * Get class constants
	 *
	 * @param string $class f. q. class name
	 * @return array constants
	 */
	public static function getClassConstants($class)
	{
		$reflect = new ReflectionClass($class);
		return $reflect->getConstants();
	}

	/**
	 * Start printing log messages to stdout (for debug)
	 */
	public static function logToStdout($htmlEscape = true)
	{
		$lf = new LineFormatter();
		Log::listen(function ($obj) use ($htmlEscape, $lf) {
			/** @var MessageLogged $obj */

			$message = $obj->message;
			$context = $obj->context;
			$level = $obj->level;

			if ($message instanceof \Exception) {
				$message = self::getStackTraceAsString($message);
			}

			if ($htmlEscape) $message = e($message);

			$time = microtime(true) - APP_START_TIME;
			echo sprintf("%7.3f", $time) . " [$level] $message";
			if (!empty($context)) echo Utils::callProtected($lf, 'stringify', $context);
			echo "\n";
		});
	}

	public static function logQueries()
	{
		\DB::listen(function ($query) {
            /** @var QueryExecuted $query */
            foreach ($query->bindings as $i=>$binding) $b[$i] = "'$binding'";
            Log::debug('SQL: ' . preg_replace_array('/\\?/', $b, $query->sql));
		});
	}

	/**
	 * Given an array of comma and quote positions, returns all commas that aren't within a pair of quotes.
	 *
	 * @param int[] $commas
	 * @param int[] $quotes
	 * @return int[]
	 */
	public static function discardPositionsWithinPairs($commas, $quotes)
	{
		$pairs = array_chunk($quotes, 2);

		$goodCommas = [];
		foreach ($commas as $comma) {
			$found = false;
			foreach ($pairs as $pair) {
				if ($comma > $pair[0] && $comma < $pair[1]) {
					$found = true;
					break;
				}
			}
			if (!$found) {
				$goodCommas[] = $comma;
			}
		}

		return $goodCommas;
	}

	/**
	 * Check if any item in an array is true.
	 *
	 * @param bool[] $arr
	 * @return bool
	 */
	public static function anyTrue($arr)
	{
		foreach ($arr as $item) {
			if ($item) return true;
		}

		return false;
	}

	/**
	 * Get all keys from an array whose values match a given set.
	 *
	 * @param array       $arr
	 * @param array|mixed $allowed_values
	 * @return array
	 */
	public static function keysOfValues($arr, $allowed_values)
	{
		if (!is_array($allowed_values)) {
			$allowed_values = [$allowed_values];
		}

		$matched = [];
		foreach ($arr as $k => $v) {
			if (in_array($v, $allowed_values)) {
				$matched[] = $k;
			}
		}

		return $matched;
	}

	/**
	 * Convert value within range to a ratio of this range.
	 * eg. 5 on 0..10 = 0.5
	 *
	 * @param float $value
	 * @param float $minValue
	 * @param float $maxValue
	 * @return float
	 */
	public static function valToRatio($value, $minValue, $maxValue)
	{
		$value = Utils::clamp($value, $minValue, $maxValue);
		return ($value - $minValue) / ($maxValue - $minValue);
	}

	/**
	 * Apply ratio to a range & get proportional value.
	 * eg. 0.5 on 0..10 = 5
	 *
	 * @param $ratio
	 * @param $minValue
	 * @param $maxValue
	 * @return mixed
	 */
	public static function ratioToVal($ratio, $minValue, $maxValue)
	{
		$ratio = Utils::clamp($ratio, 0, 1);
		return $minValue + ($maxValue - $minValue) * $ratio;
	}

	/**
	 * Range transformation
	 *
	 * @param float $value
	 * @param float $minInput
	 * @param float $maxInput
	 * @param float $minOutput
	 * @param float $maxOutput
	 * @return float
	 */
	public static function transform($value, $minInput, $maxInput, $minOutput, $maxOutput)
	{
		$ratio = self::valToRatio($value, $minInput, $maxInput);
		return self::ratioToVal($ratio, $minOutput, $maxOutput);
	}

	private static function initSortCollator()
	{
		if (self::$collator === null) {
			if (extension_loaded('intl')) {
				if (is_object($collator = collator_create('cs_CZ.UTF-8'))) {
					$collator->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);
					self::$collator = $collator;
				}
			} else {
				Log::warning('!! Intl PHP extension not installed / enabled.');
				self::$collator = false;
			}
		}
	}

	public static function mbNatCaseCompare($a, $b)
	{
		self::initSortCollator();

		$a = "$a";
		$b = "$b";

		if (strlen($a) > 0 && $a[0] == '_') if (strlen($b) == 0 || $b[0] != '_') return 1;
		if (strlen($b) > 0 && $b[0] == '_') if (strlen($a) == 0 || $a[0] != '_') return -1;

		if ($a === "" && $b !== "") return 1;
		if ($a !== "" && $b === "") return -1;
		if ($a === "" && $b === "") return 0;

		if (self::$collator)
			return self::$collator->compare($a, $b);
		else
			return strnatcasecmp($a, $b);
	}

	public static function mbNatCaseSort(&$arr)
	{
		uasort($arr, function ($a, $b) {
			return self::mbNatCaseCompare($a, $b);
		});
	}

	/**
	 * Check if exception should be reported in eventlog.
	 *
	 * @param \Exception $e
	 * @return bool
	 */
	public static function shouldReportException(\Exception $e)
	{
		$eh = app(\Illuminate\Contracts\Debug\ExceptionHandler::class);
		return Utils::callProtected($eh, 'shouldReport', $e);
	}

	const GEO_DEG = 'GEO_DEGREES';
	const GEO_DEG_MIN = 'GEO_DEG_MIN';
	const GEO_DEG_MIN_SEC = 'GEO_DEG_MIN_SEC';

	/**
	 * @param string|int $coord
	 * @return float
	 */
	public static function geoToDegrees($coord)
	{
		$coord = "$coord";

		// remove any whitespace
		$coord = preg_replace('/\s/', '', $coord);
		if (strlen($coord) == 0) throw new ArgumentException("\"$coord\" is not in a known GPS coordinate format");

		// remove leading letter, invert if needed
		$invert = false;
		if (in_array($coord[0], ['N', 'S', 'E', 'W'])) {
			$invert = in_array($coord[0], ['S', 'W']);
			$coord = substr($coord, 1);
		}
		if (in_array($coord[strlen($coord) - 1], ['N', 'S', 'E', 'W'])) {
			$invert = in_array($coord[strlen($coord) - 1], ['S', 'W']);
			$coord = substr($coord, 0, -1);
		}

		$invert = $invert ? -1 : 1;

		// Degrees
		if (Str::maskMatch("F°?", $coord)) {
			return $invert * floatval(rtrim($coord, '°'));
		}

		if (Str::maskMatch("D°F'?", $coord)) {
			list($d, $m) = explode('°', $coord);
			$d = floatval($d);
			if ($d < 0) {
				$d = -$d;
				$invert *= -1;
			}
			$m = floatval(rtrim($m, "'"));
			return $invert * ($d + $m / 60);
		}

		if (Str::maskMatch("D°D'F\"?", $coord)) {
			list($d, $ms) = explode('°', $coord);
			list($m, $s) = explode("'", $ms);

			$d = floatval($d);
			if ($d < 0) {
				$d = -$d;
				$invert *= -1;
			}
			$m = floatval($m);
			$s = floatval(rtrim($s, '"'));
			return $invert * ($d + ($m + $s / 60) / 60);
		}

		throw new ArgumentException("\"$coord\" is not in a known GPS coordinate format");
	}

	public static function convertGeoNS($coord, $target = self::GEO_DEG)
	{
		$result = self::convertGeo($coord, $target);
		if ($result[0] == '-') {
			$result = 'S ' . substr($result, 1);
		} else {
			$result = 'N ' . $result;
		}
		return $result;
	}

	public static function convertGeoEW($coord, $target = self::GEO_DEG)
	{
		$result = self::convertGeo($coord, $target);
		if ($result[0] == '-') {
			$result = 'W ' . substr($result, 1);
		} else {
			$result = 'E ' . $result;
		}
		return $result;
	}

	public static function convertGeo($coord, $target = self::GEO_DEG)
	{
		$degrees = self::geoToDegrees($coord);
		$invert = $degrees < 0;
		$degrees = abs($degrees);

		$invert = $invert ? -1 : 1;

		switch ($target) {
			case self::GEO_DEG:
				return "" . $invert * round($degrees, 5);

			case self::GEO_DEG_MIN:
				$frac = $degrees - floor($degrees);
				$mins = $frac * 60;
				return ($invert * floor($degrees)) . "° " . round($mins, 3) . "'";

			case self::GEO_DEG_MIN_SEC:
				$frac = $degrees - floor($degrees);
				$mins = $frac * 60;
				$frac = $mins - floor($mins);
				$secs = $frac * 60;
				return ($invert * floor($degrees)) . "° " . floor($mins) . "' " . round($secs, 1) . "\"";

			default:
				throw new ArgumentException("$target is not a valid geo coord format");
		}
	}

	public static function formatFileSize($bytes)
	{
		if ($bytes >= 1073741824)
		{
			$bytes = number_format($bytes / 1073741824, 2) . ' GB';
		}
		elseif ($bytes >= 1048576)
		{
			$bytes = number_format($bytes / 1048576, 2) . ' MB';
		}
		elseif ($bytes >= 1024)
		{
			$bytes = number_format($bytes / 1024, 2) . ' kB';
		}
		else
		{
			$bytes = $bytes . ' B';
		}

		return $bytes;
	}


    /**
     * Format date safely for y2038
     *
     * @param string   $format    date format string
     * @param int|null $timestamp formatted timestamp, or null for current time
     * @return string result
     */
    public static function fdate($format, $timestamp = null)
    {
        if ($timestamp === null) $timestamp = time();

        $dt = new \DateTime('@' . $timestamp); // UTC
        $dt->setTimezone(new \DateTimeZone(date('e'))); // set local timezone
        return $dt->format($format);
    }

    /**
     * Format time as XXd XXh XXm XXs (parse-able by HumanTime)
     *
     * @param int $secs seconds
     * @param bool $rough get only approximate time (for estimate)
     * @return string result
     */
    public static function ftime($secs, $rough = false)
    {
        $d = (int) ($secs / 86400);
        $secs -= $d * 86400;
        $h = (int) ($secs / 3600);
        $secs -= $h * 3600;
        $m = (int) ($secs / 60);
        $secs -= $m * 60;
        $s = $secs;

        $chunks = [];

        if ($rough) {
            if ($d > 5) {
                if ($h > 15) $d++; // round up
                $chunks[] = "{$d}d";
            } elseif ($d >= 1) {
                $chunks[] = "{$d}d";

                if ($h) $chunks[] = "{$h}h";
            } else {
                // under 1 d
                if ($h >= 4) {
                    if ($m > 40) $h++; // round up
                    $chunks[] = "{$h}h";
                } elseif ($h >= 1) {
                    $chunks[] = "{$h}h";

                    if ($m) $chunks[] = "{$m}m";
                } else {
                    if ($m) $chunks[] = "{$m}m";

                    if ($s || empty($chunks)) {
                        $chunks[] = "{$s}s";
                    }
                }
            }
        } else {
            // precise

            if ($d) $chunks[] = "{$d}d";
            if ($h) $chunks[] = "{$h}h";
            if ($m) $chunks[] = "{$m}m";

            if ($s || empty($chunks)) {
                $chunks[] = "{$s}s";
            }
        }

        return trim(implode(' ', $chunks));
    }
    /**
     * Get time in seconds from user entered interval
     *
     * @param $time
     * @return int seconds
     */
    public static function strToSeconds($time)
    {
        // seconds pass through
        if (preg_match('/^\d+$/', trim("$time"))) {
            return intval($time);
        }

        $time = preg_replace('/(\s*|,|and)/i', '', $time);
        $pieces = preg_split('/(?<=[a-z])(?=\d)/i', $time, 0, PREG_SPLIT_NO_EMPTY);
        return array_sum(array_map('self::strToSeconds_do', $pieces));
    }

    /** @noinspection PhpUnusedPrivateMethodInspection */
    private static function strToSeconds_do($time)
    {
        if (preg_match('/^(\d+)\s*(s|secs?|seconds?)$/', $time, $m)) {
            return intval($m[1]);
        }

        if (preg_match('/^(\d+)\s*(m|mins?|minutes?)$/', $time, $m)) {
            return intval($m[1]) * 60;
        }

        if (preg_match('/^(\d+)\s*(h|hours?)$/', $time, $m)) {
            return intval($m[1]) * 60*60;
        }

        if (preg_match('/^(\d+)\s*(d|days?)$/', $time, $m)) {
            return intval($m[1]) * 86400;
        }

        if (preg_match('/^(\d+)\s*(w|weeks?)$/', $time, $m)) {
            return intval($m[1]) * 86400*7;
        }

        if (preg_match('/^(\d+)\s*(M|months?)$/', $time, $m)) {
            return intval($m[1]) * 86400*30;
        }

        if (preg_match('/^(\d+)\s*(y|years?)$/', $time, $m)) {
            return intval($m[1]) * 86400*365;
        }

        throw new FormatException("Bad time interval: \"$time\"");
    }
}