<?php

declare(strict_types=1);

/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2013 Jonathan Vollebregt (jnvsor@gmail.com), Rokas Šleinius (raveren@gmail.com)
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
 * this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 * the Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

namespace Kint\Parser;

use DomainException;
use Exception;
use Kint\Zval\BlobValue;
use Kint\Zval\InstanceValue;
use Kint\Zval\Representation\Representation;
use Kint\Zval\ResourceValue;
use Kint\Zval\Value;
use ReflectionObject;
use ReflectionProperty;
use stdClass;
use TypeError;

class Parser
{
    /**
     * Plugin triggers.
     *
     * These are constants indicating trigger points for plugins
     *
     * BEGIN: Before normal parsing
     * SUCCESS: After successful parsing
     * RECURSION: After parsing cancelled by recursion
     * DEPTH_LIMIT: After parsing cancelled by depth limit
     * COMPLETE: SUCCESS | RECURSION | DEPTH_LIMIT
     *
     * While a plugin's getTriggers may return any of these
     */
    public const TRIGGER_NONE = 0;
    public const TRIGGER_BEGIN = 1;
    public const TRIGGER_SUCCESS = 2;
    public const TRIGGER_RECURSION = 4;
    public const TRIGGER_DEPTH_LIMIT = 8;
    public const TRIGGER_COMPLETE = 14;

    protected $caller_class;
    protected $depth_limit = 0;
    protected $marker;
    protected $object_hashes = [];
    protected $parse_break = false;
    protected $plugins = [];

    /**
     * @param int     $depth_limit Maximum depth to parse data
     * @param ?string $caller      Caller class name
     */
    public function __construct(int $depth_limit = 0, ?string $caller = null)
    {
        $this->marker = "kint\0".\random_bytes(16);

        $this->depth_limit = $depth_limit;
        $this->caller_class = $caller;
    }

    /**
     * Set the caller class.
     */
    public function setCallerClass(?string $caller = null): void
    {
        $this->noRecurseCall();

        $this->caller_class = $caller;
    }

    public function getCallerClass(): ?string
    {
        return $this->caller_class;
    }

    /**
     * Set the depth limit.
     *
     * @param int $depth_limit Maximum depth to parse data, 0 for none
     */
    public function setDepthLimit(int $depth_limit = 0): void
    {
        $this->noRecurseCall();

        $this->depth_limit = $depth_limit;
    }

    public function getDepthLimit(): int
    {
        return $this->depth_limit;
    }

    /**
     * Parses a variable into a Kint object structure.
     *
     * @param mixed &$var The input variable
     * @param Value $o    The base object
     */
    public function parse(&$var, Value $o): Value
    {
        $o->type = \strtolower(\gettype($var));

        if (!$this->applyPlugins($var, $o, self::TRIGGER_BEGIN)) {
            return $o;
        }

        switch ($o->type) {
            case 'array':
                return $this->parseArray($var, $o);
            case 'boolean':
            case 'double':
            case 'integer':
            case 'null':
                return $this->parseGeneric($var, $o);
            case 'object':
                return $this->parseObject($var, $o);
            case 'resource':
                return $this->parseResource($var, $o);
            case 'string':
                return $this->parseString($var, $o);
            case 'unknown type':
            case 'resource (closed)':
            default:
                return $this->parseResourceClosed($var, $o);
        }
    }

    public function addPlugin(PluginInterface $p): bool
    {
        if (!$types = $p->getTypes()) {
            return false;
        }

        if (!$triggers = $p->getTriggers()) {
            return false;
        }

        $p->setParser($this);

        foreach ($types as $type) {
            if (!isset($this->plugins[$type])) {
                $this->plugins[$type] = [
                    self::TRIGGER_BEGIN => [],
                    self::TRIGGER_SUCCESS => [],
                    self::TRIGGER_RECURSION => [],
                    self::TRIGGER_DEPTH_LIMIT => [],
                ];
            }

            foreach ($this->plugins[$type] as $trigger => &$pool) {
                if ($triggers & $trigger) {
                    $pool[] = $p;
                }
            }
        }

        return true;
    }

    public function clearPlugins(): void
    {
        $this->plugins = [];
    }

    public function haltParse(): void
    {
        $this->parse_break = true;
    }

    public function childHasPath(InstanceValue $parent, Value $child): bool
    {
        if ('__PHP_Incomplete_Class' === $parent->classname) {
            return false;
        }

        if ('object' === $parent->type && (null !== $parent->access_path || $child->static || $child->const)) {
            if (Value::ACCESS_PUBLIC === $child->access) {
                return true;
            }

            if (Value::ACCESS_PRIVATE === $child->access && $this->caller_class) {
                if ($this->caller_class === $child->owner_class) {
                    return true;
                }
            } elseif (Value::ACCESS_PROTECTED === $child->access && $this->caller_class) {
                if ($this->caller_class === $child->owner_class) {
                    return true;
                }

                if (\is_subclass_of($this->caller_class, $child->owner_class)) {
                    return true;
                }

                if (\is_subclass_of($child->owner_class, $this->caller_class)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Returns an array without the recursion marker in it.
     *
     * DO NOT pass an array that has had it's marker removed back
     * into the parser, it will result in an extra recursion
     *
     * @param array $array Array potentially containing a recursion marker
     *
     * @return array Array with recursion marker removed
     */
    public function getCleanArray(array $array): array
    {
        unset($array[$this->marker]);

        return $array;
    }

    protected function noRecurseCall(): void
    {
        $bt = \debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS);

        $caller_frame = [
            'function' => __FUNCTION__,
        ];

        while (isset($bt[0]['object']) && $bt[0]['object'] === $this) {
            $caller_frame = \array_shift($bt);
        }

        foreach ($bt as $frame) {
            if (isset($frame['object']) && $frame['object'] === $this) {
                throw new DomainException(__CLASS__.'::'.$caller_frame['function'].' cannot be called from inside a parse');
            }
        }
    }

    /**
     * @param null|bool|float|int &$var
     */
    private function parseGeneric(&$var, Value $o): Value
    {
        $rep = new Representation('Contents');
        $rep->contents = $var;
        $rep->implicit_label = true;
        $o->addRepresentation($rep);
        $o->value = $rep;

        $this->applyPlugins($var, $o, self::TRIGGER_SUCCESS);

        return $o;
    }

    /**
     * Parses a string into a Kint BlobValue structure.
     *
     * @param string &$var The input variable
     * @param Value  $o    The base object
     */
    private function parseString(string &$var, Value $o): Value
    {
        $string = new BlobValue();
        $string->transplant($o);
        $string->encoding = BlobValue::detectEncoding($var);
        $string->size = \strlen($var);

        $rep = new Representation('Contents');
        $rep->contents = $var;
        $rep->implicit_label = true;

        $string->addRepresentation($rep);
        $string->value = $rep;

        $this->applyPlugins($var, $string, self::TRIGGER_SUCCESS);

        return $string;
    }

    /**
     * Parses an array into a Kint object structure.
     *
     * @param array &$var The input variable
     * @param Value $o    The base object
     */
    private function parseArray(array &$var, Value $o): Value
    {
        $array = new Value();
        $array->transplant($o);
        $array->size = \count($var);

        if (isset($var[$this->marker])) {
            --$array->size;
            $array->hints[] = 'recursion';

            $this->applyPlugins($var, $array, self::TRIGGER_RECURSION);

            return $array;
        }

        $rep = new Representation('Contents');
        $rep->implicit_label = true;
        $array->addRepresentation($rep);
        $array->value = $rep;

        if (!$array->size) {
            $this->applyPlugins($var, $array, self::TRIGGER_SUCCESS);

            return $array;
        }

        if ($this->depth_limit && $o->depth >= $this->depth_limit) {
            $array->hints[] = 'depth_limit';

            $this->applyPlugins($var, $array, self::TRIGGER_DEPTH_LIMIT);

            return $array;
        }

        $copy = \array_values($var);

        // It's really really hard to access numeric string keys in arrays,
        // and it's really really hard to access integer properties in
        // objects, so we just use array_values and index by counter to get
        // at it reliably for reference testing. This also affects access
        // paths since it's pretty much impossible to access these things
        // without complicated stuff you should never need to do.
        $i = 0;

        // Set the marker for recursion
        $var[$this->marker] = $array->depth;

        $refmarker = new stdClass();

        foreach ($var as $key => &$val) {
            if ($key === $this->marker) {
                continue;
            }

            $child = new Value();
            $child->name = $key;
            $child->depth = $array->depth + 1;
            $child->access = Value::ACCESS_NONE;
            $child->operator = Value::OPERATOR_ARRAY;

            if (null !== $array->access_path) {
                if (\is_string($key) && (string) (int) $key === $key) {
                    $child->access_path = 'array_values('.$array->access_path.')['.$i.']'; // @codeCoverageIgnore
                } else {
                    $child->access_path = $array->access_path.'['.\var_export($key, true).']';
                }
            }

            $stash = $val;
            $copy[$i] = $refmarker;
            if ($val === $refmarker) {
                $child->reference = true;
                $val = $stash;
            }

            $rep->contents[] = $this->parse($val, $child);
            ++$i;
        }

        $this->applyPlugins($var, $array, self::TRIGGER_SUCCESS);
        unset($var[$this->marker]);

        return $array;
    }

    /**
     * Parses an object into a Kint InstanceValue structure.
     *
     * @param object &$var The input variable
     * @param Value  $o    The base object
     */
    private function parseObject(&$var, Value $o): Value
    {
        $hash = \spl_object_hash($var);
        $values = (array) $var;

        $object = new InstanceValue();
        $object->transplant($o);
        $object->classname = \get_class($var);
        $object->spl_object_hash = $hash;
        $object->size = \count($values);

        if (KINT_PHP72) {
            $object->spl_object_id = \spl_object_id($var);
        }

        if (isset($this->object_hashes[$hash])) {
            $object->hints[] = 'recursion';

            $this->applyPlugins($var, $object, self::TRIGGER_RECURSION);

            return $object;
        }

        $this->object_hashes[$hash] = $object;

        if ($this->depth_limit && $o->depth >= $this->depth_limit) {
            $object->hints[] = 'depth_limit';

            $this->applyPlugins($var, $object, self::TRIGGER_DEPTH_LIMIT);
            unset($this->object_hashes[$hash]);

            return $object;
        }

        $reflector = new ReflectionObject($var);

        if ($reflector->isUserDefined()) {
            $object->filename = $reflector->getFileName();
            $object->startline = $reflector->getStartLine();
        }

        $rep = new Representation('Properties');

        $readonly = [];

        // Reflection is both slower and more painful to use than array casting
        // We only use it to identify readonly and uninitialized properties
        if (KINT_PHP74 && '__PHP_Incomplete_Class' != $object->classname) {
            $rprops = $reflector->getProperties();

            while ($reflector = $reflector->getParentClass()) {
                $rprops = \array_merge($rprops, $reflector->getProperties(ReflectionProperty::IS_PRIVATE));
            }

            foreach ($rprops as $rprop) {
                if ($rprop->isStatic()) {
                    continue;
                }

                $rprop->setAccessible(true);

                if (KINT_PHP81 && $rprop->isReadOnly()) {
                    if ($rprop->isPublic()) {
                        $readonly[$rprop->getName()] = true;
                    } elseif ($rprop->isProtected()) {
                        $readonly["\0*\0".$rprop->getName()] = true;
                    } elseif ($rprop->isPrivate()) {
                        $readonly["\0".$rprop->getDeclaringClass()->getName()."\0".$rprop->getName()] = true;
                    }
                }

                if ($rprop->isInitialized($var)) {
                    continue;
                }

                $undefined = null;

                $child = new Value();
                $child->type = 'undefined';
                $child->depth = $object->depth + 1;
                $child->owner_class = $rprop->getDeclaringClass()->getName();
                $child->operator = Value::OPERATOR_OBJECT;
                $child->name = $rprop->getName();
                $child->readonly = KINT_PHP81 && $rprop->isReadOnly();

                if ($rprop->isPublic()) {
                    $child->access = Value::ACCESS_PUBLIC;
                } elseif ($rprop->isProtected()) {
                    $child->access = Value::ACCESS_PROTECTED;
                } elseif ($rprop->isPrivate()) {
                    $child->access = Value::ACCESS_PRIVATE;
                }

                // Can't dynamically add undefined properties, so no need to use var_export
                if ($this->childHasPath($object, $child)) {
                    $child->access_path .= $object->access_path.'->'.$child->name;
                }

                if ($this->applyPlugins($undefined, $child, self::TRIGGER_BEGIN)) {
                    $this->applyPlugins($undefined, $child, self::TRIGGER_SUCCESS);
                }
                $rep->contents[] = $child;
            }
        }

        $copy = \array_values($values);
        $refmarker = new stdClass();
        $i = 0;

        // Reflection will not show parent classes private properties, and if a
        // property was unset it will happly trigger a notice looking for it.
        foreach ($values as $key => &$val) {
            // Casting object to array:
            // private properties show in the form "\0$owner_class_name\0$property_name";
            // protected properties show in the form "\0*\0$property_name";
            // public properties show in the form "$property_name";
            // http://www.php.net/manual/en/language.types.array.php#language.types.array.casting

            $child = new Value();
            $child->depth = $object->depth + 1;
            $child->owner_class = $object->classname;
            $child->operator = Value::OPERATOR_OBJECT;
            $child->access = Value::ACCESS_PUBLIC;
            if (isset($readonly[$key])) {
                $child->readonly = true;
            }

            $split_key = \explode("\0", (string) $key, 3);

            if (3 === \count($split_key) && '' === $split_key[0]) {
                $child->name = $split_key[2];
                if ('*' === $split_key[1]) {
                    $child->access = Value::ACCESS_PROTECTED;
                } else {
                    $child->access = Value::ACCESS_PRIVATE;
                    $child->owner_class = $split_key[1];
                }
            } elseif (KINT_PHP72) {
                $child->name = (string) $key;
            } else {
                $child->name = $key; // @codeCoverageIgnore
            }

            if ($this->childHasPath($object, $child)) {
                $child->access_path = $object->access_path;

                if (!KINT_PHP72 && \is_int($child->name)) {
                    $child->access_path = 'array_values((array) '.$child->access_path.')['.$i.']'; // @codeCoverageIgnore
                } elseif (\preg_match('/^[a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*$/', $child->name)) {
                    $child->access_path .= '->'.$child->name;
                } else {
                    $child->access_path .= '->{'.\var_export((string) $child->name, true).'}';
                }
            }

            $stash = $val;
            try {
                $copy[$i] = $refmarker;
            } catch (TypeError $e) {
                $child->reference = true;
            }
            if ($val === $refmarker) {
                $child->reference = true;
                $val = $stash;
            }

            $rep->contents[] = $this->parse($val, $child);
            ++$i;
        }

        $object->addRepresentation($rep);
        $object->value = $rep;
        $this->applyPlugins($var, $object, self::TRIGGER_SUCCESS);
        unset($this->object_hashes[$hash]);

        return $object;
    }

    /**
     * Parses a resource into a Kint ResourceValue structure.
     *
     * @param resource &$var The input variable
     * @param Value    $o    The base object
     */
    private function parseResource(&$var, Value $o): Value
    {
        $resource = new ResourceValue();
        $resource->transplant($o);
        $resource->resource_type = \get_resource_type($var);

        $this->applyPlugins($var, $resource, self::TRIGGER_SUCCESS);

        return $resource;
    }

    /**
     * Parses a closed resource into a Kint object structure.
     *
     * @param mixed &$var The input variable
     * @param Value $o    The base object
     */
    private function parseResourceClosed(&$var, Value $o): Value
    {
        $o->type = 'resource (closed)';
        $this->applyPlugins($var, $o, self::TRIGGER_SUCCESS);

        return $o;
    }

    /**
     * Applies plugins for an object type.
     *
     * @param mixed &$var    variable
     * @param Value $o       Kint object parsed so far
     * @param int   $trigger The trigger to check for the plugins
     *
     * @return bool Continue parsing
     */
    private function applyPlugins(&$var, Value &$o, int $trigger): bool
    {
        $break_stash = $this->parse_break;

        /** @psalm-var bool */
        $this->parse_break = false;

        $plugins = [];

        if (isset($this->plugins[$o->type][$trigger])) {
            $plugins = $this->plugins[$o->type][$trigger];
        }

        foreach ($plugins as $plugin) {
            try {
                $plugin->parse($var, $o, $trigger);
            } catch (Exception $e) {
                \trigger_error(
                    'An exception ('.\get_class($e).') was thrown in '.$e->getFile().' on line '.$e->getLine().' while executing Kint Parser Plugin "'.\get_class($plugin).'". Error message: '.$e->getMessage(),
                    E_USER_WARNING
                );
            }

            if ($this->parse_break) {
                $this->parse_break = $break_stash;

                return false;
            }
        }

        $this->parse_break = $break_stash;

        return true;
    }
}
