<?php

/*
 * This file is part of the PHP-JSON-FIXER package.
 *
 * (c) Jitendra Adhikari <jiten.adhikary@gmail.com>
 *     <https://github.com/adhocore>
 *
 * Licensed under MIT license.
 */

namespace Ahc\Json;

/**
 * Attempts to fix truncated JSON by padding contextual counterparts at the end.
 *
 * @author  Jitendra Adhikari <jiten.adhikary@gmail.com>
 * @license MIT
 *
 * @link    https://github.com/adhocore/php-json-fixer
 */
class Fixer
{
    use PadsJson;

    /** @var array Current token stack indexed by position */
    protected $stack = [];

    /** @var bool If current char is within a string */
    protected $inStr = false;

    /** @var bool Whether to throw Exception on failure */
    protected $silent = false;

    /** @var array The complementary pairs */
    protected $pairs = [
        '{' => '}',
        '[' => ']',
        '"' => '"',
    ];

    /** @var int The last seen object `{` type position */
    protected $objectPos = -1;

    /** @var int The last seen array `[` type position */
    protected $arrayPos  = -1;

    /** @var string Missing value. (Options: true, false, null) */
    protected $missingValue = 'null';

    /**
     * Set/unset silent mode.
     *
     * @param bool $silent
     *
     * @return $this
     */
    public function silent($silent = true)
    {
        $this->silent = (bool) $silent;

        return $this;
    }

    /**
     * Set missing value.
     *
     * @param mixed $value
     *
     * @return $this
     */
    public function missingValue($value)
    {
        if ($value === null) {
            $value = 'null';
        } elseif (\is_bool($value)) {
            $value = $value ? 'true' : 'false';
        }

        $this->missingValue = $value;

        return $this;
    }

    /**
     * Fix the truncated JSON.
     *
     * @param string $json The JSON string to fix.
     *
     * @throws \RuntimeException When fixing fails.
     *
     * @return string Fixed JSON. If failed with silent then original JSON.
     */
    public function fix($json)
    {
        list($head, $json, $tail) = $this->trim($json);

        if (empty($json) || $this->isValid($json)) {
            return $json;
        }

        if (null !== $tmpJson = $this->quickFix($json)) {
            return $tmpJson;
        }

        $this->reset();

        return $head . $this->doFix($json) . $tail;
    }

    protected function trim($json)
    {
        \preg_match('/^(\s*)([^\s]+)(\s*)$/', $json, $match);

        $match += ['', '', '', ''];
        $match[2] = \trim($json);

        \array_shift($match);

        return $match;
    }

    protected function isValid($json)
    {
        \json_decode($json);

        return \JSON_ERROR_NONE === \json_last_error();
    }

    protected function quickFix($json)
    {
        if (\strlen($json) === 1 && isset($this->pairs[$json])) {
            return $json . $this->pairs[$json];
        }

        if ($json[0] !== '"') {
            return $this->maybeLiteral($json);
        }

        return $this->padString($json);
    }

    protected function reset()
    {
        $this->stack     = [];
        $this->inStr     = false;
        $this->objectPos = -1;
        $this->arrayPos  = -1;
    }

    protected function maybeLiteral($json)
    {
        if (!\in_array($json[0], ['t', 'f', 'n'])) {
            return null;
        }

        foreach (['true', 'false', 'null'] as $literal) {
            if (\strpos($literal, $json) === 0) {
                return $literal;
            }
        }

        // @codeCoverageIgnoreStart
        return null;
        // @codeCoverageIgnoreEnd
    }

    protected function doFix($json)
    {
        list($index, $char) = [-1, ''];

        while (isset($json[++$index])) {
            list($prev, $char) = [$char, $json[$index]];

            $next = isset($json[$index + 1]) ? $json[$index + 1] : '';

            if (!\in_array($char, [' ', "\n", "\r"])) {
                $this->stack($prev, $char, $index, $next);
            }
        }

        return $this->fixOrFail($json);
    }

    protected function stack($prev, $char, $index, $next)
    {
        if ($this->maybeStr($prev, $char, $index)) {
            return;
        }

        $last = $this->lastToken();

        if (\in_array($last, [',', ':', '"']) && \preg_match('/\"|\d|\{|\[|t|f|n/', $char)) {
            $this->popToken();
        }

        if (\in_array($char, [',', ':', '[', '{'])) {
            $this->stack[$index] = $char;
        }

        $this->updatePos($char, $index);
    }

    protected function lastToken()
    {
        return \end($this->stack);
    }

    protected function popToken($token = null)
    {
        // Last one
        if (null === $token) {
            return \array_pop($this->stack);
        }

        $keys = \array_reverse(\array_keys($this->stack));
        foreach ($keys as $key) {
            if ($this->stack[$key] === $token) {
                unset($this->stack[$key]);
                break;
            }
        }
    }

    protected function maybeStr($prev, $char, $index)
    {
        if ($prev !== '\\' && $char === '"') {
            $this->inStr = !$this->inStr;
        }

        if ($this->inStr && $this->lastToken() !== '"') {
            $this->stack[$index] = '"';
        }

        return $this->inStr;
    }

    protected function updatePos($char, $index)
    {
        if ($char === '{') {
            $this->objectPos = $index;
        } elseif ($char === '}') {
            $this->popToken('{');
            $this->objectPos = -1;
        } elseif ($char === '[') {
            $this->arrayPos = $index;
        } elseif ($char === ']') {
            $this->popToken('[');
            $this->arrayPos = -1;
        }
    }

    protected function fixOrFail($json)
    {
        $length  = \strlen($json);
        $tmpJson = $this->pad($json);

        if ($this->isValid($tmpJson)) {
            return $tmpJson;
        }

        if ($this->silent) {
            return $json;
        }

        throw new \RuntimeException(
            \sprintf('Could not fix JSON (tried padding `%s`)', \substr($tmpJson, $length))
        );
    }
}
