<?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 Packetery\Tracy\Dumper;

use Packetery\Tracy;
use Packetery\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) ? 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'] === \Packetery\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'])) {
            $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;
    }
}
