<?php declare(strict_types=1);

/*
 * This file is part of Composer.
 *
 * (c) Nils Adermann <naderman@naderman.de>
 *     Jordi Boggiano <j.boggiano@seld.be>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Composer\Json;

use Composer\Pcre\Preg;
use Composer\Util\Filesystem;
use JsonSchema\Validator;
use Seld\JsonLint\JsonParser;
use Seld\JsonLint\ParsingException;
use Composer\Util\HttpDownloader;
use Composer\IO\IOInterface;
use Composer\Downloader\TransportException;

/**
 * Reads/writes json files.
 *
 * @author Konstantin Kudryashiv <ever.zet@gmail.com>
 * @author Jordi Boggiano <j.boggiano@seld.be>
 */
class JsonFile
{
    public const LAX_SCHEMA = 1;
    public const STRICT_SCHEMA = 2;
    public const AUTH_SCHEMA = 3;
    public const LOCK_SCHEMA = 4;

    /** @deprecated Use \JSON_UNESCAPED_SLASHES */
    public const JSON_UNESCAPED_SLASHES = 64;
    /** @deprecated Use \JSON_PRETTY_PRINT */
    public const JSON_PRETTY_PRINT = 128;
    /** @deprecated Use \JSON_UNESCAPED_UNICODE */
    public const JSON_UNESCAPED_UNICODE = 256;

    public const COMPOSER_SCHEMA_PATH = __DIR__ . '/../../../res/composer-schema.json';
    public const LOCK_SCHEMA_PATH = __DIR__ . '/../../../res/composer-lock-schema.json';

    public const INDENT_DEFAULT = '    ';

    /** @var string */
    private $path;
    /** @var ?HttpDownloader */
    private $httpDownloader;
    /** @var ?IOInterface */
    private $io;
    /** @var string */
    private $indent = self::INDENT_DEFAULT;

    /**
     * Initializes json file reader/parser.
     *
     * @param  string                    $path           path to a lockfile
     * @param  ?HttpDownloader           $httpDownloader required for loading http/https json files
     * @throws \InvalidArgumentException
     */
    public function __construct(string $path, ?HttpDownloader $httpDownloader = null, ?IOInterface $io = null)
    {
        $this->path = $path;

        if (null === $httpDownloader && Preg::isMatch('{^https?://}i', $path)) {
            throw new \InvalidArgumentException('http urls require a HttpDownloader instance to be passed');
        }
        $this->httpDownloader = $httpDownloader;
        $this->io = $io;
    }

    public function getPath(): string
    {
        return $this->path;
    }

    /**
     * Checks whether json file exists.
     */
    public function exists(): bool
    {
        return is_file($this->path);
    }

    /**
     * Reads json file.
     *
     * @throws ParsingException
     * @throws \RuntimeException
     * @return mixed
     */
    public function read()
    {
        try {
            if ($this->httpDownloader) {
                $json = $this->httpDownloader->get($this->path)->getBody();
            } else {
                if (!Filesystem::isReadable($this->path)) {
                    throw new \RuntimeException('The file "'.$this->path.'" is not readable.');
                }
                if ($this->io && $this->io->isDebug()) {
                    $realpathInfo = '';
                    $realpath = realpath($this->path);
                    if (false !== $realpath && $realpath !== $this->path) {
                        $realpathInfo = ' (' . $realpath . ')';
                    }
                    $this->io->writeError('Reading ' . $this->path . $realpathInfo);
                }
                $json = file_get_contents($this->path);
            }
        } catch (TransportException $e) {
            throw new \RuntimeException($e->getMessage(), 0, $e);
        } catch (\Exception $e) {
            throw new \RuntimeException('Could not read '.$this->path."\n\n".$e->getMessage());
        }

        if ($json === false) {
            throw new \RuntimeException('Could not read '.$this->path);
        }

        $this->indent = self::detectIndenting($json);

        return static::parseJson($json, $this->path);
    }

    /**
     * Writes json file.
     *
     * @param  mixed[]                              $hash    writes hash into json file
     * @param  int                                  $options json_encode options
     * @throws \UnexpectedValueException|\Exception
     * @return void
     */
    public function write(array $hash, int $options = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
    {
        if ($this->path === 'php://memory') {
            file_put_contents($this->path, static::encode($hash, $options, $this->indent));

            return;
        }

        $dir = dirname($this->path);
        if (!is_dir($dir)) {
            if (file_exists($dir)) {
                throw new \UnexpectedValueException(
                    realpath($dir).' exists and is not a directory.'
                );
            }
            if (!@mkdir($dir, 0777, true)) {
                throw new \UnexpectedValueException(
                    $dir.' does not exist and could not be created.'
                );
            }
        }

        $retries = 3;
        while ($retries--) {
            try {
                $this->filePutContentsIfModified($this->path, static::encode($hash, $options, $this->indent). ($options & JSON_PRETTY_PRINT ? "\n" : ''));
                break;
            } catch (\Exception $e) {
                if ($retries > 0) {
                    usleep(500000);
                    continue;
                }

                throw $e;
            }
        }
    }

    /**
     * Modify file properties only if content modified
     *
     * @return int|false
     */
    private function filePutContentsIfModified(string $path, string $content)
    {
        $currentContent = @file_get_contents($path);
        if (false === $currentContent || $currentContent !== $content) {
            return file_put_contents($path, $content);
        }

        return 0;
    }

    /**
     * Validates the schema of the current json file according to composer-schema.json rules
     *
     * @param  int                     $schema     a JsonFile::*_SCHEMA constant
     * @param  string|null             $schemaFile a path to the schema file
     * @throws JsonValidationException
     * @throws ParsingException
     * @return true                    true on success
     *
     * @phpstan-param self::*_SCHEMA $schema
     */
    public function validateSchema(int $schema = self::STRICT_SCHEMA, ?string $schemaFile = null): bool
    {
        if (!Filesystem::isReadable($this->path)) {
            throw new \RuntimeException('The file "'.$this->path.'" is not readable.');
        }
        $content = file_get_contents($this->path);
        $data = json_decode($content);

        if (null === $data && 'null' !== $content) {
            self::validateSyntax($content, $this->path);
        }

        return self::validateJsonSchema($this->path, $data, $schema, $schemaFile);
    }

    /**
     * Validates the schema of the current json file according to composer-schema.json rules
     *
     * @param  mixed                   $data       Decoded JSON data to validate
     * @param  int                     $schema     a JsonFile::*_SCHEMA constant
     * @param  string|null             $schemaFile a path to the schema file
     * @throws JsonValidationException
     * @return true                    true on success
     *
     * @phpstan-param self::*_SCHEMA $schema
     */
    public static function validateJsonSchema(string $source, $data, int $schema, ?string $schemaFile = null): bool
    {
        $isComposerSchemaFile = false;
        if (null === $schemaFile) {
            if ($schema === self::LOCK_SCHEMA) {
                $schemaFile = self::LOCK_SCHEMA_PATH;
            } else {
                $isComposerSchemaFile = true;
                $schemaFile = self::COMPOSER_SCHEMA_PATH;
            }
        }

        // Prepend with file:// only when not using a special schema already (e.g. in the phar)
        if (false === strpos($schemaFile, '://')) {
            $schemaFile = 'file://' . $schemaFile;
        }

        $schemaData = (object) ['$ref' => $schemaFile, '$schema' => "https://json-schema.org/draft-04/schema#"];

        if ($schema === self::STRICT_SCHEMA && $isComposerSchemaFile) {
            $schemaData = json_decode((string) file_get_contents($schemaFile));
            $schemaData->additionalProperties = false;
            $schemaData->required = ['name', 'description'];
        } elseif ($schema === self::AUTH_SCHEMA && $isComposerSchemaFile) {
            $schemaData = (object) ['$ref' => $schemaFile.'#/properties/config', '$schema' => "https://json-schema.org/draft-04/schema#"];
        }

        $validator = new Validator();
        // convert assoc arrays to objects
        $data = json_decode((string) json_encode($data));
        $validator->validate($data, $schemaData);

        if (!$validator->isValid()) {
            $errors = [];
            foreach ($validator->getErrors() as $error) {
                $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message'];
            }
            throw new JsonValidationException('"'.$source.'" does not match the expected JSON schema', $errors);
        }

        return true;
    }

    /**
     * Encodes an array into (optionally pretty-printed) JSON
     *
     * @param  mixed  $data    Data to encode into a formatted JSON string
     * @param  int    $options json_encode options (defaults to JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
     * @param  string $indent  Indentation string
     * @return string Encoded json
     */
    public static function encode($data, int $options = \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE, string $indent = self::INDENT_DEFAULT): string
    {
        $json = json_encode($data, $options);

        if (false === $json) {
            self::throwEncodeError(json_last_error());
        }

        if (($options & JSON_PRETTY_PRINT) > 0 && $indent !== self::INDENT_DEFAULT) {
            // Pretty printing and not using default indentation
            return Preg::replaceCallback(
                '#^ {4,}#m',
                static function ($match) use ($indent): string {
                    return str_repeat($indent, (int) (strlen($match[0]) / 4));
                },
                $json
            );
        }

        return $json;
    }

    /**
     * Throws an exception according to a given code with a customized message
     *
     * @param  int               $code return code of json_last_error function
     * @throws \RuntimeException
     * @return never
     */
    private static function throwEncodeError(int $code): void
    {
        switch ($code) {
            case JSON_ERROR_DEPTH:
                $msg = 'Maximum stack depth exceeded';
                break;
            case JSON_ERROR_STATE_MISMATCH:
                $msg = 'Underflow or the modes mismatch';
                break;
            case JSON_ERROR_CTRL_CHAR:
                $msg = 'Unexpected control character found';
                break;
            case JSON_ERROR_UTF8:
                $msg = 'Malformed UTF-8 characters, possibly incorrectly encoded';
                break;
            default:
                $msg = 'Unknown error';
        }

        throw new \RuntimeException('JSON encoding failed: '.$msg);
    }

    /**
     * Parses json string and returns hash.
     *
     * @param null|string $json json string
     * @param string $file the json file
     *
     * @throws ParsingException
     * @return mixed
     */
    public static function parseJson(?string $json, ?string $file = null)
    {
        if (null === $json) {
            return null;
        }
        $data = json_decode($json, true);
        if (null === $data && JSON_ERROR_NONE !== json_last_error()) {
            // attempt resolving simple conflicts in lock files so that one can run `composer update --lock` and get a valid lock file
            if ($file !== null && str_ends_with($file, '.lock') && str_contains($json, '"content-hash"')) {
                $replaced = Preg::replace(
                    '{\r?\n<<<<<<< [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n(?:\|{7} [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n)?=======\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n>>>>>>> [^\r\n]+(\r?\n)}',
                    '    "content-hash": "VCS merge conflict detected. Please run `composer update --lock`.",$1',
                    $json,
                    1,
                    $count
                );
                if ($count === 1) {
                    $data = json_decode($replaced, true);
                    if ($data !== null) {
                        return $data;
                    }
                }
            }

            self::validateSyntax($json, $file);
        }

        return $data;
    }

    /**
     * Validates the syntax of a JSON string
     *
     * @throws \UnexpectedValueException
     * @throws ParsingException
     * @return bool                      true on success
     */
    protected static function validateSyntax(string $json, ?string $file = null): bool
    {
        $parser = new JsonParser();
        $result = $parser->lint($json);
        if (null === $result) {
            if (defined('JSON_ERROR_UTF8') && JSON_ERROR_UTF8 === json_last_error()) {
                if ($file === null) {
                    throw new \UnexpectedValueException('The input is not UTF-8, could not parse as JSON');
                } else {
                    throw new \UnexpectedValueException('"' . $file . '" is not UTF-8, could not parse as JSON');
                }
            }

            return true;
        }

        if ($file === null) {
            throw new ParsingException(
                'The input does not contain valid JSON' . "\n" . $result->getMessage(),
                $result->getDetails()
            );
        } else {
            throw new ParsingException(
                '"' . $file . '" does not contain valid JSON' . "\n" . $result->getMessage(),
                $result->getDetails()
            );
        }
    }

    public static function detectIndenting(?string $json): string
    {
        if (Preg::isMatchStrictGroups('#^([ \t]+)"#m', $json ?? '', $match)) {
            return $match[1];
        }

        return self::INDENT_DEFAULT;
    }
}
