<?php

/*
 * This file is part of the JsonSchema package.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace JsonSchema;

use JsonSchema\Exception\JsonDecodingException;
use JsonSchema\Uri\Retrievers\UriRetrieverInterface;
use JsonSchema\Uri\UriRetriever;

/**
 * Take in an object that's a JSON schema and take care of all $ref references
 *
 * @author Tyler Akins <fidian@rumkin.com>
 * @see    README.md
 */
class RefResolver
{
    /**
     * HACK to prevent too many recursive expansions.
     * Happens e.g. when you want to validate a schema against the schema
     * definition.
     *
     * @var integer
     */
    protected static $depth = 0;

    /**
     * maximum references depth
     * @var integer
     */
    public static $maxDepth = 7;

    /**
     * @var UriRetrieverInterface
     */
    protected $uriRetriever = null;

    /**
     * @var object
     */
    protected $rootSchema = null;

    /**
     * @param UriRetriever $retriever
     */
    public function __construct($retriever = null)
    {
        $this->uriRetriever = $retriever;
    }

    /**
     * Retrieves a given schema given a ref and a source URI
     *
     * @param  string $ref       Reference from schema
     * @param  string $sourceUri URI where original schema was located
     * @return object            Schema
     */
    public function fetchRef($ref, $sourceUri)
    {
        $retriever  = $this->getUriRetriever();
        $jsonSchema = $retriever->retrieve($ref, $sourceUri);
        $this->resolve($jsonSchema);

        return $jsonSchema;
    }

    /**
     * Return the URI Retriever, defaulting to making a new one if one
     * was not yet set.
     *
     * @return UriRetriever
     */
    public function getUriRetriever()
    {
        if (is_null($this->uriRetriever)) {
            $this->setUriRetriever(new UriRetriever);
        }

        return $this->uriRetriever;
    }

    /**
     * Resolves all $ref references for a given schema.  Recurses through
     * the object to resolve references of any child schemas.
     *
     * The 'format' property is omitted because it isn't required for
     * validation.  Theoretically, this class could be extended to look
     * for URIs in formats: "These custom formats MAY be expressed as
     * an URI, and this URI MAY reference a schema of that format."
     *
     * The 'id' property is not filled in, but that could be made to happen.
     *
     * @param object $schema    JSON Schema to flesh out
     * @param string $sourceUri URI where this schema was located
     */
    public function resolve($schema, $sourceUri = null)
    {
        if (self::$depth > self::$maxDepth) {
            throw new JsonDecodingException(JSON_ERROR_DEPTH);
        }
        ++self::$depth;

        if (! is_object($schema)) {
            --self::$depth;
            return;
        }

        if (null === $sourceUri && ! empty($schema->id)) {
            $sourceUri = $schema->id;
        }

        if (null === $this->rootSchema) {
            $this->rootSchema = $schema;
        }

        // Resolve $ref first
        $this->resolveRef($schema, $sourceUri);

        // These properties are just schemas
        // eg.  items can be a schema or an array of schemas
        foreach (array('additionalItems', 'additionalProperties', 'extends', 'items') as $propertyName) {
            $this->resolveProperty($schema, $propertyName, $sourceUri);
        }

        // These are all potentially arrays that contain schema objects
        // eg.  type can be a value or an array of values/schemas
        // eg.  items can be a schema or an array of schemas
        foreach (array('disallow', 'extends', 'items', 'type', 'allOf', 'anyOf', 'oneOf') as $propertyName) {
            $this->resolveArrayOfSchemas($schema, $propertyName, $sourceUri);
        }

        // These are all objects containing properties whose values are schemas
        foreach (array('dependencies', 'patternProperties', 'properties') as $propertyName) {
            $this->resolveObjectOfSchemas($schema, $propertyName, $sourceUri);
        }

        --self::$depth;
    }

    /**
     * Given an object and a property name, that property should be an
     * array whose values can be schemas.
     *
     * @param object $schema       JSON Schema to flesh out
     * @param string $propertyName Property to work on
     * @param string $sourceUri    URI where this schema was located
     */
    public function resolveArrayOfSchemas($schema, $propertyName, $sourceUri)
    {
        if (! isset($schema->$propertyName) || ! is_array($schema->$propertyName)) {
            return;
        }

        foreach ($schema->$propertyName as $possiblySchema) {
            $this->resolve($possiblySchema, $sourceUri);
        }
    }

    /**
     * Given an object and a property name, that property should be an
     * object whose properties are schema objects.
     *
     * @param object $schema       JSON Schema to flesh out
     * @param string $propertyName Property to work on
     * @param string $sourceUri    URI where this schema was located
     */
    public function resolveObjectOfSchemas($schema, $propertyName, $sourceUri)
    {
        if (! isset($schema->$propertyName) || ! is_object($schema->$propertyName)) {
            return;
        }

        foreach (get_object_vars($schema->$propertyName) as $possiblySchema) {
            $this->resolve($possiblySchema, $sourceUri);
        }
    }

    /**
     * Given an object and a property name, that property should be a
     * schema object.
     *
     * @param object $schema       JSON Schema to flesh out
     * @param string $propertyName Property to work on
     * @param string $sourceUri    URI where this schema was located
     */
    public function resolveProperty($schema, $propertyName, $sourceUri)
    {
        if (! isset($schema->$propertyName)) {
            return;
        }

        $this->resolve($schema->$propertyName, $sourceUri);
    }

    /**
     * Look for the $ref property in the object.  If found, remove the
     * reference and augment this object with the contents of another
     * schema.
     *
     * @param object $schema    JSON Schema to flesh out
     * @param string $sourceUri URI where this schema was located
     */
    public function resolveRef($schema, $sourceUri)
    {
        $ref = '$ref';

        if (empty($schema->$ref)) {
            return;
        }

        $splitRef = explode('#', $schema->$ref, 2);

        $refDoc = $splitRef[0];
        $refPath = null;
        if (count($splitRef) === 2) {
            $refPath = explode('/', $splitRef[1]);
            array_shift($refPath);
        }

        if (empty($refDoc) && empty($refPath)) {
            // TODO: Not yet implemented - root pointer ref, causes recursion issues
            return;
        }

        if (!empty($refDoc)) {
            $refSchema = $this->fetchRef($refDoc, $sourceUri);
        } else {
            $refSchema = $this->rootSchema;
        }

        if (null !== $refPath) {
            $refSchema = $this->resolveRefSegment($refSchema, $refPath);
        }

        unset($schema->$ref);

        // Augment the current $schema object with properties fetched
        foreach (get_object_vars($refSchema) as $prop => $value) {
            $schema->$prop = $value;
        }
    }

    /**
     * Set URI Retriever for use with the Ref Resolver
     *
     * @param UriRetriever $retriever
     * @return $this for chaining
     */
    public function setUriRetriever(UriRetriever $retriever)
    {
        $this->uriRetriever = $retriever;

        return $this;
    }

    protected function resolveRefSegment($data, $pathParts)
    {
        foreach ($pathParts as $path) {
            $path = strtr($path, array('~1' => '/', '~0' => '~', '%25' => '%'));

            if (is_array($data)) {
                $data = $data[$path];
            } else {
                $data = $data->{$path};
            }
        }

        return $data;
    }
}
