<?php

/**
 * This file is part of the Tracy (https://tracy.nette.org)
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
 */

declare(strict_types=1);

namespace Tracy\Dumper;

use Tracy;
use Tracy\Helpers;


/**
 * Converts PHP values to internal representation.
 * @internal
 */
final class Describer
{
	public const HiddenValue = '*****';

	// Number.MAX_SAFE_INTEGER
	private const JsSafeInteger = 1 << 53 - 1;

	/** @var int */
	public $maxDepth = 7;

	/** @var int */
	public $maxLength = 150;

	/** @var int */
	public $maxItems = 100;

	/** @var Value[] */
	public $snapshot = [];

	/** @var bool */
	public $debugInfo = false;

	/** @var array */
	public $keysToHide = [];

	/** @var callable|null  fn(string $key, mixed $val): bool */
	public $scrubber;

	/** @var bool */
	public $location = false;

	/** @var callable[] */
	public $resourceExposers;

	/** @var array<string,callable> */
	public $objectExposers;

	/** @var (int|\stdClass)[] */
	public $references = [];


	public function describe($var): \stdClass
	{
		uksort($this->objectExposers, function ($a, $b): int {
			return $b === '' || (class_exists($a, false) && is_subclass_of($a, $b)) ? -1 : 1;
		});

		try {
			return (object) [
				'value' => $this->describeVar($var),
				'snapshot' => $this->snapshot,
				'location' => $this->location ? self::findLocation() : null,
			];

		} finally {
			$free = [[], []];
			$this->snapshot = &$free[0];
			$this->references = &$free[1];
		}
	}


	/**
	 * @return mixed
	 */
	private function describeVar($var, int $depth = 0, ?int $refId = null)
	{
		if ($var === null || is_bool($var)) {
			return $var;
		}

		$m = 'describe' . explode(' ', gettype($var))[0];
		return $this->$m($var, $depth, $refId);
	}


	/**
	 * @return Value|int
	 */
	private function describeInteger(int $num)
	{
		return $num <= self::JsSafeInteger && $num >= -self::JsSafeInteger
			? $num
			: new Value(Value::TypeNumber, "$num");
	}


	/**
	 * @return Value|float
	 */
	private function describeDouble(float $num)
	{
		if (!is_finite($num)) {
			return new Value(Value::TypeNumber, (string) $num);
		}

		$js = json_encode($num);
		return strpos($js, '.')
			? $num
			: new Value(Value::TypeNumber, "$js.0"); // to distinct int and float in JS
	}


	/**
	 * @return Value|string
	 */
	private function describeString(string $s, int $depth = 0)
	{
		$encoded = Helpers::encodeString($s, $depth ? $this->maxLength : null);
		if ($encoded === $s) {
			return $encoded;
		} elseif (Helpers::isUtf8($s)) {
			return new Value(Value::TypeStringHtml, $encoded, Helpers::utf8Length($s));
		} else {
			return new Value(Value::TypeBinaryHtml, $encoded, strlen($s));
		}
	}


	/**
	 * @return Value|array
	 */
	private function describeArray(array $arr, int $depth = 0, ?int $refId = null)
	{
		if ($refId) {
			$res = new Value(Value::TypeRef, 'p' . $refId);
			$value = &$this->snapshot[$res->value];
			if ($value && $value->depth <= $depth) {
				return $res;
			}

			$value = new Value(Value::TypeArray);
			$value->id = $res->value;
			$value->depth = $depth;
			if ($this->maxDepth && $depth >= $this->maxDepth) {
				$value->length = count($arr);
				return $res;
			} elseif ($depth && $this->maxItems && count($arr) > $this->maxItems) {
				$value->length = count($arr);
				$arr = array_slice($arr, 0, $this->maxItems, true);
			}

			$items = &$value->items;

		} elseif ($arr && $this->maxDepth && $depth >= $this->maxDepth) {
			return new Value(Value::TypeArray, null, count($arr));

		} elseif ($depth && $this->maxItems && count($arr) > $this->maxItems) {
			$res = new Value(Value::TypeArray, null, count($arr));
			$res->depth = $depth;
			$items = &$res->items;
			$arr = array_slice($arr, 0, $this->maxItems, true);
		}

		$items = [];
		foreach ($arr as $k => $v) {
			$refId = $this->getReferenceId($arr, $k);
			$items[] = [
				$this->describeVar($k, $depth + 1),
				$this->isSensitive((string) $k, $v)
					? new Value(Value::TypeText, self::hideValue($v))
					: $this->describeVar($v, $depth + 1, $refId),
			] + ($refId ? [2 => $refId] : []);
		}

		return $res ?? $items;
	}


	private function describeObject(object $obj, int $depth = 0): Value
	{
		$id = spl_object_id($obj);
		$value = &$this->snapshot[$id];
		if ($value && $value->depth <= $depth) {
			return new Value(Value::TypeRef, $id);
		}

		$value = new Value(Value::TypeObject, Helpers::getClass($obj));
		$value->id = $id;
		$value->depth = $depth;
		$value->holder = $obj; // to be not released by garbage collector in collecting mode
		if ($this->location) {
			$rc = $obj instanceof \Closure
				? new \ReflectionFunction($obj)
				: new \ReflectionClass($obj);
			if ($rc->getFileName() && ($editor = Helpers::editorUri($rc->getFileName(), $rc->getStartLine()))) {
				$value->editor = (object) ['file' => $rc->getFileName(), 'line' => $rc->getStartLine(), 'url' => $editor];
			}
		}

		if ($this->maxDepth && $depth < $this->maxDepth) {
			$value->items = [];
			$props = $this->exposeObject($obj, $value);
			foreach ($props ?? [] as $k => $v) {
				$this->addPropertyTo($value, (string) $k, $v, Value::PropertyVirtual, $this->getReferenceId($props, $k));
			}
		}

		return new Value(Value::TypeRef, $id);
	}


	/**
	 * @param  resource  $resource
	 */
	private function describeResource($resource, int $depth = 0): Value
	{
		$id = 'r' . (int) $resource;
		$value = &$this->snapshot[$id];
		if (!$value) {
			$type = is_resource($resource) ? get_resource_type($resource) : 'closed';
			$value = new Value(Value::TypeResource, $type . ' resource');
			$value->id = $id;
			$value->depth = $depth;
			$value->items = [];
			if (isset($this->resourceExposers[$type])) {
				foreach (($this->resourceExposers[$type])($resource) as $k => $v) {
					$value->items[] = [htmlspecialchars($k), $this->describeVar($v, $depth + 1)];
				}
			}
		}

		return new Value(Value::TypeRef, $id);
	}


	/**
	 * @return Value|string
	 */
	public function describeKey(string $key)
	{
		if (preg_match('#^[\w!\#$%&*+./;<>?@^{|}~-]{1,50}$#D', $key) && !preg_match('#^(true|false|null)$#iD', $key)) {
			return $key;
		}

		$value = $this->describeString($key);
		return is_string($value) // ensure result is Value
			? new Value(Value::TypeStringHtml, $key, Helpers::utf8Length($key))
			: $value;
	}


	public function addPropertyTo(
		Value $value,
		string $k,
		$v,
		$type = Value::PropertyVirtual,
		?int $refId = null,
		?string $class = null
	) {
		if ($value->depth && $this->maxItems && count($value->items ?? []) >= $this->maxItems) {
			$value->length = ($value->length ?? count($value->items)) + 1;
			return;
		}

		$class = $class ?? $value->value;
		$value->items[] = [
			$this->describeKey($k),
			$type !== Value::PropertyVirtual && $this->isSensitive($k, $v, $class)
				? new Value(Value::TypeText, self::hideValue($v))
				: $this->describeVar($v, $value->depth + 1, $refId),
			$type === Value::PropertyPrivate ? $class : $type,
		] + ($refId ? [3 => $refId] : []);
	}


	private function exposeObject(object $obj, Value $value): ?array
	{
		foreach ($this->objectExposers as $type => $dumper) {
			if (!$type || $obj instanceof $type) {
				return $dumper($obj, $value, $this);
			}
		}

		if ($this->debugInfo && method_exists($obj, '__debugInfo')) {
			return $obj->__debugInfo();
		}

		Exposer::exposeObject($obj, $value, $this);
		return null;
	}


	private function isSensitive(string $key, $val, ?string $class = null): bool
	{
		return $val instanceof \SensitiveParameterValue
			|| ($this->scrubber !== null && ($this->scrubber)($key, $val, $class))
			|| isset($this->keysToHide[strtolower($key)])
			|| isset($this->keysToHide[strtolower($class . '::$' . $key)]);
	}


	private static function hideValue($val): string
	{
		if ($val instanceof \SensitiveParameterValue) {
			$val = $val->getValue();
		}

		return self::HiddenValue . ' (' . (is_object($val) ? Helpers::getClass($val) : gettype($val)) . ')';
	}


	public function getReferenceId($arr, $key): ?int
	{
		if (PHP_VERSION_ID >= 70400) {
			if ((!$rr = \ReflectionReference::fromArrayElement($arr, $key))) {
				return null;
			}

			$tmp = &$this->references[$rr->getId()];
			if ($tmp === null) {
				return $tmp = count($this->references);
			}

			return $tmp;
		}

		$uniq = new \stdClass;
		$copy = $arr;
		$orig = $copy[$key];
		$copy[$key] = $uniq;
		if ($arr[$key] !== $uniq) {
			return null;
		}

		$res = array_search($uniq, $this->references, true);
		$copy[$key] = $orig;
		if ($res === false) {
			$this->references[] = &$arr[$key];
			return count($this->references);
		}

		return $res + 1;
	}


	/**
	 * Finds the location where dump was called. Returns [file, line, code]
	 */
	private static function findLocation(): ?array
	{
		foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $item) {
			if (isset($item['class']) && ($item['class'] === self::class || $item['class'] === Tracy\Dumper::class)) {
				$location = $item;
				continue;
			} elseif (isset($item['function'])) {
				try {
					$reflection = isset($item['class'])
						? new \ReflectionMethod($item['class'], $item['function'])
						: new \ReflectionFunction($item['function']);
					if (
						$reflection->isInternal()
						|| preg_match('#\s@tracySkipLocation\s#', (string) $reflection->getDocComment())
					) {
						$location = $item;
						continue;
					}
				} catch (\ReflectionException $e) {
				}
			}

			break;
		}

		if (isset($location['file'], $location['line']) && @is_file($location['file'])) { // @ - may trigger error
			$lines = file($location['file']);
			$line = $lines[$location['line'] - 1];
			return [
				$location['file'],
				$location['line'],
				trim(preg_match('#\w*dump(er::\w+)?\(.*\)#i', $line, $m) ? $m[0] : $line),
			];
		}

		return null;
	}
}
