#pragma once

#include <functional>
#include <iostream>
#include <optional>
#include <vector>

#include <valijson/constraints/concrete_constraints.hpp>
#include <valijson/internal/adapter.hpp>
#include <valijson/internal/debug.hpp>
#include <valijson/internal/json_pointer.hpp>
#include <valijson/internal/json_reference.hpp>
#include <valijson/internal/uri.hpp>
#include <valijson/constraint_builder.hpp>
#include <valijson/schema.hpp>
#include <valijson/schema_registry.hpp>
#include <valijson/exceptions.hpp>

namespace valijson {

/**
 * @brief  Parser for populating a Schema based on a JSON Schema document.
 *
 * The SchemaParser class supports Drafts 3 and 4 of JSON Schema, however
 * Draft 3 support should be considered deprecated.
 *
 * The functions provided by this class have been templated so that they can
 * be used with different Adapter types.
 */
class SchemaParser
{
public:
    /// Supported versions of JSON Schema
    enum Version {
        kDraft3,      ///< @deprecated JSON Schema v3 has been superseded by v4
        kDraft4,
        kDraft7
    };

    /**
     * @brief  Construct a new SchemaParser for a given version of JSON Schema
     *
     * @param  version  Version of JSON Schema that will be expected
     */
    explicit SchemaParser(const Version version = kDraft7)
      : m_version(version) { }

    /**
     * @brief  Release memory associated with custom ConstraintBuilders
     */
    virtual ~SchemaParser()
    {
        for (const auto& entry : constraintBuilders) {
            delete entry.second;
        }
    }

    /**
     * @brief  Struct to contain templated function type for fetching documents
     */
    template<typename AdapterType>
    struct FunctionPtrs
    {
        typedef typename adapters::AdapterTraits<AdapterType>::DocumentType DocumentType;

        /// Templated function pointer type for fetching remote documents
        typedef std::function<const DocumentType* (const std::string &uri)> FetchDoc;

        /// Templated function pointer type for freeing fetched documents
        typedef std::function<void (const DocumentType *)> FreeDoc;
    };

    /**
     * @brief  Add a custom constraint to this SchemaParser

     * @param  key      name that will be used to identify relevant constraints
     *                  while parsing a schema document
     * @param  builder  pointer to a subclass of ConstraintBuilder that can
     *                  parse custom constraints found in a schema document,
     *                  and return an appropriate instance of Constraint; this
     *                  class guarantees that it will take ownership of this
     *                  pointer - unless this function throws an exception
     *
     * @todo   consider accepting a list of custom ConstraintBuilders in
     *         constructor, so that this class remains immutable after
     *         construction
     *
     * @todo   Add additional checks for key conflicts, empty keys, and
     *         potential restrictions relating to case sensitivity
     */
    void addConstraintBuilder(const std::string &key, const ConstraintBuilder *builder)
    {
        constraintBuilders.push_back(std::make_pair(key, builder));
    }

    /**
     * @brief  Populate a Schema object from JSON Schema document
     *
     * When processing Draft 3 schemas, the parentSubschema and ownName pointers
     * should be set in contexts where a 'required' constraint would be valid.
     * These are used to add a RequiredConstraint object to the Schema that
     * contains the required property.
     *
     * @param  node          Reference to node to parse
     * @param  schema        Reference to Schema to populate
     * @param  fetchDoc      Function to fetch remote JSON documents (optional)
     */
    template<typename AdapterType>
    void populateSchema(
        const AdapterType &node,
        Schema &schema,
        typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc = nullptr,
        typename FunctionPtrs<AdapterType>::FreeDoc freeDoc = nullptr)
    {
        if ((fetchDoc == nullptr ) ^ (freeDoc == nullptr)) {
            throwRuntimeError("Remote document fetching can't be enabled without both fetch and free functions");
        }

        typename DocumentCache<AdapterType>::Type docCache;
        SchemaRegistry schemaRegistry;
#if VALIJSON_USE_EXCEPTIONS
        try {
#endif
            resolveThenPopulateSchema(schema, node, node, schema, std::optional<std::string>(), "", fetchDoc, nullptr,
                    nullptr, docCache, schemaRegistry);
#if VALIJSON_USE_EXCEPTIONS
        } catch (...) {
            freeDocumentCache<AdapterType>(docCache, freeDoc);
            throw;
        }
#endif

        freeDocumentCache<AdapterType>(docCache, freeDoc);
    }

private:

    typedef std::vector<std::pair<std::string, const ConstraintBuilder *>>
        ConstraintBuilders;

    ConstraintBuilders constraintBuilders;

    template<typename AdapterType>
    struct DocumentCache
    {
        typedef typename adapters::AdapterTraits<AdapterType>::DocumentType DocumentType;

        typedef std::map<std::string, const DocumentType*> Type;
    };

    /**
     * @brief  Free memory used by fetched documents
     *
     * If a custom 'free' function has not been provided, then the default
     * delete operator will be used.
     *
     * @param  docCache  collection of fetched documents to free
     * @param  freeDoc   optional custom free function
     */
    template<typename AdapterType>
    void freeDocumentCache(const typename DocumentCache<AdapterType>::Type
            &docCache, typename FunctionPtrs<AdapterType>::FreeDoc freeDoc)
    {
        typedef typename DocumentCache<AdapterType>::Type DocCacheType;

        for (const typename DocCacheType::value_type &v : docCache) {
            freeDoc(v.second);
        }
    }

    /**
     * @brief  Find the complete URI for a document, within a resolution scope
     *
     * This function captures five different cases that can occur when
     * attempting to resolve a document URI within a particular resolution
     * scope:
     *
     *  (1) resolution scope not present, but URN or absolute document URI is
     *       => document URI as-is
     *  (2) resolution scope not present, and document URI is relative or absent
     *       => document URI, if present, otherwise no result
     *  (3) resolution scope is present, and document URI is a relative path
     *       => resolve document URI relative to resolution scope
     *  (4) resolution scope is present, and document URI is absolute
     *       => document URI as-is
     *  (5) resolution scope is present, but document URI is not
     *       => resolution scope as-is
     *
     * This function assumes that the resolution scope is absolute.
     *
     * When resolving a document URI relative to the resolution scope, the
     * document URI should be used to replace the path, query and fragment
     * portions of URI provided by the resolution scope.
     */
    virtual std::optional<std::string> resolveDocumentUri(
            const std::optional<std::string>& resolutionScope,
            const std::optional<std::string>& documentUri)
    {
        if (resolutionScope) {
            if (documentUri) {
                if (internal::uri::isUriAbsolute(*documentUri) || internal::uri::isUrn(*documentUri)) {
                    // (4) resolution scope is present, and document URI is absolute
                    //      => document URI as-is
                    return *documentUri;
                } else {
                    // (3) resolution scope is present, and document URI is a relative path
                    //      => resolve document URI relative to resolution scope
                    return internal::uri::resolveRelativeUri(*resolutionScope, *documentUri);
                }
            } else {
                // (5) resolution scope is present, but document URI is not
                //      => resolution scope as-is
                return *resolutionScope;
            }
        } else if (documentUri && internal::uri::isUriAbsolute(*documentUri)) {
            // (1a) resolution scope not present, but absolute document URI is
            //      => document URI as-is
            return *documentUri;
        } else if (documentUri && internal::uri::isUrn(*documentUri)) {
            // (1b) resolution scope not present, but URN is
            //       => document URI as-is
            return *documentUri;
        } else {
            // (2) resolution scope not present, and document URI is relative or absent
            //      => document URI, if present, otherwise no result
            // documentUri is already std::optional
            return documentUri;
        }
    }

    /**
     * @brief  Extract a JSON Reference string from a node
     *
     * @param  node    node to extract the JSON Reference from
     * @param  result  reference to string to set with the result
     *
     * @throws std::invalid_argument if node is an object containing a `$ref`
     *         property but with a value that cannot be interpreted as a string
     *
     * @return \c true if a JSON Reference was extracted; \c false otherwise
     */
    template<typename AdapterType>
    bool extractJsonReference(const AdapterType &node, std::string &result)
    {
        if (!node.isObject()) {
            return false;
        }

        const typename AdapterType::Object o = node.getObject();
        const typename AdapterType::Object::const_iterator itr = o.find("$ref");
        if (itr == o.end()) {
            return false;
        } else if (!itr->second.getString(result)) {
            throwRuntimeError("$ref property expected to contain string value.");
        }

        return true;
    }

    /**
     * Sanitise an optional JSON Pointer, trimming trailing slashes
     */
    static std::string sanitiseJsonPointer(const std::optional<std::string>& input)
    {
        if (input) {
            if (*input == "/") {
                return "";
            }

            return *input;
        }

        // If the JSON Pointer is not set, assume that the URI points to
        // the root of the document
        return "";
    }

    static std::string escapeJsonPointerToken(const std::string &token)
    {
        std::string result;
        for (const char c : token) {
            if (c == '~') {
                result += "~0";
            } else if (c == '/') {
                result += "~1";
            } else {
                result += c;
            }
        }

        return result;
    }

    const char *idKeyword() const
    {
        return m_version == kDraft7 ? "$id" : "id";
    }

    std::optional<std::string> resolveId(
            const std::optional<std::string> &currentScope,
            const std::string &id) const
    {
        if (!currentScope || internal::uri::isUriAbsolute(id) ||
                internal::uri::isUrn(id)) {
            return id;
        }

        return internal::uri::resolveRelativeUri(*currentScope, id);
    }

    template<typename AdapterType>
    bool findSchemaById(
            const AdapterType &node,
            const std::optional<std::string> &currentScope,
            const std::string &targetUri,
            const std::string &nodePath,
            std::string &foundPath,
            std::optional<std::string> &foundScope) const
    {
        std::optional<std::string> updatedScope = currentScope;

        if (node.isObject()) {
            const typename AdapterType::Object object = node.asObject();
            const typename AdapterType::Object::const_iterator itr =
                    object.find(idKeyword());
            if (itr != object.end() && itr->second.maybeString()) {
                updatedScope = resolveId(currentScope, itr->second.asString());
                if (updatedScope && *updatedScope == targetUri) {
                    foundPath = nodePath;
                    foundScope = currentScope;
                    return true;
                }
            }

            for (const typename AdapterType::ObjectMember member : object) {
                const std::string childPath =
                        nodePath + "/" + escapeJsonPointerToken(member.first);
                if (findSchemaById(member.second, updatedScope, targetUri,
                            childPath, foundPath, foundScope)) {
                    return true;
                }
            }
        } else if (node.maybeArray()) {
            size_t index = 0;
            for (const AdapterType item : node.asArray()) {
                const std::string childPath = nodePath + "/" +
                        std::to_string(index++);
                if (findSchemaById(item, updatedScope, targetUri, childPath,
                            foundPath, foundScope)) {
                    return true;
                }
            }
        }

        return false;
    }

    template<typename AdapterType>
    bool findScopeForPath(
            const AdapterType &node,
            const std::optional<std::string> &currentScope,
            const std::string &targetPath,
            const std::string &nodePath,
            std::optional<std::string> &foundScope) const
    {
        if (nodePath == targetPath) {
            foundScope = currentScope;
            return true;
        }

        std::optional<std::string> updatedScope = currentScope;

        if (node.isObject()) {
            const typename AdapterType::Object object = node.asObject();
            const typename AdapterType::Object::const_iterator itr =
                    object.find(idKeyword());
            if (itr != object.end() && itr->second.maybeString()) {
                updatedScope = resolveId(currentScope, itr->second.asString());
            }

            for (const typename AdapterType::ObjectMember member : object) {
                const std::string childPath =
                        nodePath + "/" + escapeJsonPointerToken(member.first);
                if (findScopeForPath(member.second, updatedScope, targetPath,
                            childPath, foundScope)) {
                    return true;
                }
            }
        } else {
            if (node.maybeArray()) {
                size_t index = 0;
                for (const AdapterType item : node.asArray()) {
                    const std::string childPath = nodePath + "/" +
                            std::to_string(index++);
                    if (findScopeForPath(item, updatedScope, targetPath,
                                childPath, foundScope)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * @brief  Search the schema registry for a schema matching a given key
     *
     * If the key is not present in the schema registry, a nullptr will be
     * returned, and the contents of the registry will remain unchanged. This is
     * in contrast to the behaviour of the std::map [] operator, which would
     * add the nullptr to the registry.
     *
     * @param  schemaRegistry  schema registry to query
     * @param  queryKey        key to search for
     *
     * @return shared pointer to Schema if found, nullptr otherwise
     */
    static const Subschema * querySchemaRegistry(SchemaRegistry &schemaRegistry,
            const std::string &queryKey)
    {
        const SchemaRegistry::iterator itr = schemaRegistry.find(queryKey);
        if (itr == schemaRegistry.end()) {
            return nullptr;
        }

        return itr->second;
    }

    /**
     * @brief  Add entries to the schema registry for a given list of keys
     *
     * @param  schemaRegistry   schema registry to update
     * @param  keysToRegister   list of keys to register
     * @param  schema           shared pointer to schema that keys will map to
     *
     * @throws std::logic_error if any of the keys are already present in the
     *         schema registry. This behaviour is intended to help detect incorrect
     *         usage of the schema registry during development, and is not expected
     *         to occur otherwise, even for malformed schemas.
     */
    static void updateSchemaRegistry(SchemaRegistry &schemaRegistry,
            const std::vector<std::string> &keysToRegister,
            const Subschema *schema)
    {
        for (const std::string &keyToRegister : keysToRegister) {
            const SchemaRegistry::value_type value(keyToRegister, schema);
            if (!schemaRegistry.insert(value).second) {
                throwLogicError("Key '" + keyToRegister + "' already in schema registry.");
            }
        }
    }

    /**
     * @brief  Recursive helper function for retrieving or creating schemas
     *
     * This function will be applied recursively until a concrete node is found.
     * A concrete node is a node that contains actual schema constraints rather
     * than a JSON Reference.
     *
     * This termination condition may be trigged by visiting the concrete node
     * at the end of a series of $ref nodes, or by finding a schema for one of
     * those $ref nodes in the schema registry. An entry will be added to the
     * schema registry for each node visited on the path to the concrete node.
     *
     * @param  rootSchema      The Schema instance, and root subschema, through
     *                         which other subschemas can be created and
     *                         modified
     * @param  rootNode        Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document
     * @param  node            Reference to the node to parse
     * @param  currentScope    URI for current resolution scope
     * @param  nodePath        JSON Pointer representing path to current node
     * @param  fetchDoc        Function to fetch remote JSON documents (optional)
     * @param  parentSubschema Optional pointer to the parent schema, used to
     *                         support required keyword in Draft 3
     * @param  ownName         Optional pointer to a node name, used to support
     *                         the 'required' keyword in Draft 3
     * @param  docCache        Cache of resolved and fetched remote documents
     * @param  schemaRegistry  Registry of populated schemas
     * @param  newRegistryKeys A list of keys that should be added to the registry
     *                         when recursion terminates
     */
    template<typename AdapterType>
    const Subschema * makeOrReuseSchema(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        const Subschema *parentSubschema,
        const std::string *ownName,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry,
        std::vector<std::string> &newRegistryKeys)
    {
        std::string jsonRef;

        // Check for the first termination condition (found a non-$ref node)
        if (!extractJsonReference(node, jsonRef)) {

            // Construct a key that we can use to search the schema registry for
            // a schema corresponding to the current node
            const std::string schemaRegistryKey = currentScope ? (*currentScope + nodePath) : nodePath;

            // Retrieve an existing schema from the registry if possible
            const Subschema *registeredPtr = querySchemaRegistry(schemaRegistry, schemaRegistryKey);

            // Create a new schema otherwise
            const Subschema *subschema = registeredPtr ? registeredPtr : rootSchema.createSubschema();

            // Add registry entries for keys belonging to any $ref nodes that were
            // visited before arriving at the current node
            updateSchemaRegistry(schemaRegistry, newRegistryKeys, subschema);
            if (!registeredPtr) {
                schemaRegistry.emplace(schemaRegistryKey, subschema);
            }

            // Schema registry did not contain a preexisting schema corresponding
            // to the current node, so the schema that was returned will need
            // to be populated
            if (!registeredPtr) {
                populateSchema(rootSchema, rootNode, node, *subschema,
                        currentScope, nodePath, fetchDoc, parentSubschema,
                        ownName, docCache, schemaRegistry);
            }

            return subschema;
        }

        // Returns a document URI if the reference points somewhere
        // other than the current document
        const std::optional<std::string> documentUri = internal::json_reference::getJsonReferenceUri(jsonRef);

        // Extract JSON Pointer from JSON Reference, with any trailing
        // slashes removed so that keys in the schema registry end
        // consistently
        const std::string actualJsonPointer = sanitiseJsonPointer(
                internal::json_reference::getJsonReferencePointer(jsonRef));

        // Determine the actual document URI based on the resolution
        // scope. An absolute document URI will take precedence when
        // present, otherwise we need to resolve the URI relative to
        // the current resolution scope
        const std::optional<std::string> actualDocumentUri = resolveDocumentUri(currentScope, documentUri);

        // Construct a key to search the schema registry for an existing schema
        const std::string queryKey = actualDocumentUri ? (*actualDocumentUri + actualJsonPointer) : actualJsonPointer;

        if (!actualJsonPointer.empty() && actualJsonPointer[0] != '/') {
            const std::string idRef = actualDocumentUri ?
                    (*actualDocumentUri + "#" + actualJsonPointer) :
                    ("#" + actualJsonPointer);
            std::string registeredPath;
            std::optional<std::string> registeredScope;
            if (findSchemaById(rootNode, currentScope, idRef,
                        "", registeredPath, registeredScope)) {
                const AdapterType registeredRoot =
                        internal::json_pointer::resolveJsonPointerStrict(
                                rootNode, registeredPath);

                const Subschema *registeredPtr = querySchemaRegistry(schemaRegistry, idRef);
                if (registeredPtr) {
                    updateSchemaRegistry(schemaRegistry, newRegistryKeys, registeredPtr);
                    return registeredPtr;
                }

                const Subschema *subschema = rootSchema.createSubschema();
                newRegistryKeys.push_back(idRef);
                updateSchemaRegistry(schemaRegistry, newRegistryKeys, subschema);

                populateSchema(rootSchema, registeredRoot, registeredRoot,
                        *subschema, registeredScope, "", fetchDoc,
                        parentSubschema, ownName, docCache, schemaRegistry);

                return subschema;
            }
        }

        // Check for the second termination condition (found a $ref node that
        // already has an entry in the schema registry)
        const Subschema *registeredPtr = querySchemaRegistry(schemaRegistry, queryKey);
        if (registeredPtr) {
            updateSchemaRegistry(schemaRegistry, newRegistryKeys, registeredPtr);
            return registeredPtr;
        }

        if (actualDocumentUri && (!currentScope || *actualDocumentUri != *currentScope)) {
            std::string registeredPath;
            std::optional<std::string> registeredScope;
            if (findSchemaById(rootNode, currentScope, *actualDocumentUri,
                        "", registeredPath, registeredScope)) {
                const AdapterType registeredRoot =
                        internal::json_pointer::resolveJsonPointerStrict(
                                rootNode, registeredPath);
                const AdapterType referencedAdapter = actualJsonPointer.empty()
                        ? registeredRoot
                        : internal::json_pointer::resolveJsonPointerStrict(
                                registeredRoot, actualJsonPointer);

                std::optional<std::string> referencedScope = registeredScope;
                if (!actualJsonPointer.empty()) {
                    findScopeForPath(registeredRoot, registeredScope,
                            actualJsonPointer, "", referencedScope);
                }

                const Subschema *subschema = rootSchema.createSubschema();
                newRegistryKeys.push_back(queryKey);
                updateSchemaRegistry(schemaRegistry, newRegistryKeys, subschema);

                populateSchema(rootSchema, registeredRoot, referencedAdapter,
                        *subschema, referencedScope, actualJsonPointer,
                        fetchDoc, parentSubschema, ownName, docCache,
                        schemaRegistry);

                return subschema;
            }

            const typename FunctionPtrs<AdapterType>::DocumentType *newDoc = nullptr;

            // Have we seen this document before?
            typename DocumentCache<AdapterType>::Type::iterator docCacheItr =
                    docCache.find(*actualDocumentUri);
            if (docCacheItr == docCache.end()) {
                // Resolve reference against remote document
                if (!fetchDoc) {
                    throwRuntimeError("Fetching of remote JSON References not enabled.");
                }

                // Returns a pointer to the remote document that was
                // retrieved, or null if retrieval failed. This class
                // will take ownership of the pointer, and call freeDoc
                // when it is no longer needed.
                newDoc = fetchDoc(*actualDocumentUri);

                // Can't proceed without the remote document
                if (!newDoc) {
                    throwRuntimeError("Failed to fetch referenced schema document: " + *actualDocumentUri);
                }

                typedef typename DocumentCache<AdapterType>::Type::value_type
                        DocCacheValueType;

                docCache.insert(DocCacheValueType(*actualDocumentUri, newDoc));

            } else {
                newDoc = docCacheItr->second;
            }

            const AdapterType newRootNode(*newDoc);

            // Find where we need to be in the document
            const AdapterType &referencedAdapter =
                    internal::json_pointer::resolveJsonPointerStrict(newRootNode,
                            actualJsonPointer);

            newRegistryKeys.push_back(queryKey);

            // Populate the schema, starting from the referenced node, with
            // nested JSON References resolved relative to the new root node
            return makeOrReuseSchema(rootSchema, newRootNode, referencedAdapter,
                    actualDocumentUri, actualJsonPointer, fetchDoc, parentSubschema,
                    ownName, docCache, schemaRegistry, newRegistryKeys);

        }

        if (std::find(newRegistryKeys.begin(), newRegistryKeys.end(), queryKey) != newRegistryKeys.end()) {
            throwRuntimeError("found cycle while resolving JSON reference");
        }

        // JSON References in nested schema will be resolved relative to the
        // current document
        const AdapterType &referencedAdapter =
                internal::json_pointer::resolveJsonPointerStrict(
                        rootNode, actualJsonPointer);

        newRegistryKeys.push_back(queryKey);

        std::optional<std::string> referencedScope = currentScope;
        findScopeForPath(rootNode, currentScope, actualJsonPointer, "",
                referencedScope);

        // Populate the schema, starting from the referenced node, with
        // nested JSON References resolved relative to the new root node
        return makeOrReuseSchema(rootSchema, rootNode, referencedAdapter,
                referencedScope, actualJsonPointer, fetchDoc, parentSubschema,
                ownName, docCache, schemaRegistry, newRegistryKeys);
    }

    /**
     * @brief  Return pointer for the schema corresponding to a given node
     *
     * This function makes use of a schema registry, so that if the path to the
     * current node is the same as one that has already been parsed and
     * populated, a pointer to the existing Subschema will be returned.
     *
     * Should a series of $ref, or reference, nodes be resolved before reaching
     * a concrete node, an entry will be added to the schema registry for each of
     * the nodes in that path.
     *
     * @param  rootSchema      The Schema instance, and root subschema, through
     *                         which other subschemas can be created and
     *                         modified
     * @param  rootNode        Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document
     * @param  node            Reference to the node to parse
     * @param  currentScope    URI for current resolution scope
     * @param  nodePath        JSON Pointer representing path to current node
     * @param  fetchDoc        Function to fetch remote JSON documents (optional)
     * @param  parentSubschema Optional pointer to the parent schema, used to
     *                         support required keyword in Draft 3
     * @param  ownName         Optional pointer to a node name, used to support
     *                         the 'required' keyword in Draft 3
     * @param  docCache        Cache of resolved and fetched remote documents
     * @param  schemaRegistry  Registry of populated schemas
     */
    template<typename AdapterType>
    const Subschema * makeOrReuseSchema(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        const Subschema *parentSubschema,
        const std::string *ownName,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        std::vector<std::string> schemaRegistryKeysToCreate;

        return makeOrReuseSchema(rootSchema, rootNode, node, currentScope,
                nodePath, fetchDoc, parentSubschema, ownName, docCache,
                schemaRegistry, schemaRegistryKeysToCreate);
    }

    /**
     * @brief  Populate a Schema object from JSON Schema document
     *
     * When processing Draft 3 schemas, the parentSubschema and ownName pointers
     * should be set in contexts where a 'required' constraint would be valid.
     * These are used to add a RequiredConstraint object to the Schema that
     * contains the required property.
     *
     * @param  rootSchema       The Schema instance, and root subschema, through
     *                          which other subschemas can be created and
     *                          modified
     * @param  rootNode         Reference to the node from which JSON References
     *                          will be resolved when they refer to the current
     *                          document
     * @param  node             Reference to node to parse
     * @param  subschema        Reference to Schema to populate
     * @param  currentScope     URI for current resolution scope
     * @param  nodePath         JSON Pointer representing path to current node
     * @param  fetchDoc         Optional function to fetch remote JSON documents
     * @param  parentSubschema  Optional pointer to the parent schema, used to
     *                          support required keyword in Draft 3
     * @param  ownName          Optional pointer to a node name, used to support
     *                          the 'required' keyword in Draft 3
     * @param  docCache         Cache of resolved and fetched remote documents
     * @param  schemaRegistry   Registry of populated schemas
     */
    template<typename AdapterType>
    void populateSchema(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const Subschema &subschema,
        const std::optional<std::string>& currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        const Subschema *parentSubschema,
        const std::string *ownName,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        static_assert((std::is_convertible<AdapterType,
            const valijson::adapters::Adapter &>::value),
            "SchemaParser::populateSchema must be invoked with an "
            "appropriate Adapter implementation");

        if (!node.isObject()) {
            if (m_version == kDraft7 && node.maybeBool()) {
                // Boolean schema
                if (!node.asBool()) {
                    rootSchema.setAlwaysInvalid(&subschema, true);
                }
                return;
            } else {
                std::string s;
                s += "Expected node at ";
                s += nodePath;
                if (m_version == kDraft7) {
                    s += " to contain schema object or boolean value; actual node type is: ";
                } else {
                    s += " to contain schema object; actual node type is: ";
                }
                s += internal::nodeTypeAsString(node);
                throwRuntimeError(s);
            }
        }

        const typename AdapterType::Object object = node.asObject();
        typename AdapterType::Object::const_iterator itr(object.end());

        // Check for schema identifier attribute and update current scope.
        std::optional<std::string> updatedScope;
        bool foundId = false;
        if ((itr = object.find(idKeyword())) != object.end() && itr->second.maybeString()) {
            foundId = true;
            const std::string id = itr->second.asString();
            rootSchema.setSubschemaId(&subschema, itr->second.asString());
            updatedScope = resolveId(currentScope, id);
        } else {
            updatedScope = currentScope;
        }

        if (foundId && updatedScope) {
            schemaRegistry.emplace(*updatedScope, &subschema);
        }

        // Add the type constraint first to be the first one to check because other constraints may rely on it
        if ((itr = object.find("type")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeTypeConstraint(rootSchema, rootNode, itr->second, updatedScope, nodePath + "/type", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("allOf")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeAllOfConstraint(rootSchema, rootNode, itr->second,
                            updatedScope, nodePath + "/allOf", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("anyOf")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeAnyOfConstraint(rootSchema, rootNode, itr->second,
                            updatedScope, nodePath + "/anyOf", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("const")) != object.end()) {
            rootSchema.addConstraintToSubschema(makeConstConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("contains")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeContainsConstraint(rootSchema, rootNode, itr->second,
                            updatedScope, nodePath + "/contains", fetchDoc,
                            docCache, schemaRegistry), &subschema);
        }

        if ((itr = object.find("dependencies")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeDependenciesConstraint(rootSchema, rootNode,
                            itr->second, updatedScope,
                            nodePath + "/dependencies", fetchDoc, docCache,
                            schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("description")) != object.end()) {
            if (itr->second.maybeString()) {
                rootSchema.setSubschemaDescription(&subschema,
                        itr->second.asString());
            } else {
                throwRuntimeError(
                        "'description' attribute should have a string value");
            }
        }

        if ((itr = object.find("divisibleBy")) != object.end()) {
            if (m_version == kDraft3) {
                if (itr->second.maybeInteger()) {
                    rootSchema.addConstraintToSubschema(
                            makeMultipleOfIntConstraint(itr->second),
                            &subschema);
                } else if (itr->second.maybeDouble()) {
                    rootSchema.addConstraintToSubschema(
                            makeMultipleOfDoubleConstraint(itr->second),
                            &subschema);
                } else {
                    throwRuntimeError("Expected a numeric value for "
                            " 'divisibleBy' constraint.");
                }
            } else {
                throwRuntimeError(
                        "'divisibleBy' constraint not valid after draft 3");
            }
        }

        if ((itr = object.find("enum")) != object.end()) {
            rootSchema.addConstraintToSubschema(makeEnumConstraint(itr->second), &subschema);
        }

        // Draft 3's 'extends' keyword is equivalent to 'allOf' in later drafts
        // and accepts either a single schema or an array of schemas.
        if (m_version == kDraft3 && (itr = object.find("extends")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeExtendsConstraint(rootSchema, rootNode, itr->second,
                            updatedScope, nodePath + "/extends", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("format")) != object.end()) {
            rootSchema.addConstraintToSubschema(makeFormatConstraint(itr->second), &subschema);
        }

        {
            const typename AdapterType::Object::const_iterator itemsItr =
                    object.find("items");

            if (object.end() != itemsItr) {
                if (!itemsItr->second.isArray()) {
                    rootSchema.addConstraintToSubschema(
                            makeSingularItemsConstraint(rootSchema, rootNode,
                                    itemsItr->second, updatedScope,
                                    nodePath + "/items", fetchDoc, docCache,
                                    schemaRegistry),
                            &subschema);

                } else {
                    const typename AdapterType::Object::const_iterator
                            additionalItemsItr = object.find("additionalItems");
                    rootSchema.addConstraintToSubschema(
                            makeLinearItemsConstraint(rootSchema, rootNode,
                                    itemsItr != object.end() ? &itemsItr->second : nullptr,
                                    additionalItemsItr != object.end() ? &additionalItemsItr->second : nullptr,
                                    updatedScope, nodePath + "/items",
                                    nodePath + "/additionalItems", fetchDoc,
                                    docCache, schemaRegistry),
                            &subschema);
                }
            }
        }

        {
            const typename AdapterType::Object::const_iterator ifItr = object.find("if");
            const typename AdapterType::Object::const_iterator thenItr = object.find("then");
            const typename AdapterType::Object::const_iterator elseItr = object.find("else");

            if (object.end() != ifItr) {
                if (m_version == kDraft7) {
                    rootSchema.addConstraintToSubschema(
                          makeConditionalConstraint(rootSchema, rootNode,
                                ifItr->second,
                                thenItr == object.end() ? nullptr : &thenItr->second,
                                elseItr == object.end() ? nullptr : &elseItr->second,
                                updatedScope, nodePath, fetchDoc, docCache, schemaRegistry),
                          &subschema);
                } else {
                    throwRuntimeError("Not supported");
                }
            }
        }

        if (m_version == kDraft7) {
            if ((itr = object.find("exclusiveMaximum")) != object.end()) {
                rootSchema.addConstraintToSubschema(
                    makeMaximumConstraintExclusive(itr->second),
                    &subschema);
            }

            if ((itr = object.find("maximum")) != object.end()) {
                rootSchema.addConstraintToSubschema(
                    makeMaximumConstraint<AdapterType>(itr->second, nullptr),
                    &subschema);
            }
        } else if ((itr = object.find("maximum")) != object.end()) {
            typename AdapterType::Object::const_iterator exclusiveMaximumItr =
                    object.find("exclusiveMaximum");
            if (exclusiveMaximumItr == object.end()) {
                rootSchema.addConstraintToSubschema(
                        makeMaximumConstraint<AdapterType>(itr->second, nullptr),
                        &subschema);
            } else {
                rootSchema.addConstraintToSubschema(
                        makeMaximumConstraint(itr->second, &exclusiveMaximumItr->second),
                        &subschema);
            }
        } else if (object.find("exclusiveMaximum") != object.end()) {
            throwRuntimeError("'exclusiveMaximum' constraint only valid if a 'maximum' "
                    "constraint is also present");
        }

        if ((itr = object.find("maxItems")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMaxItemsConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("maxLength")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMaxLengthConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("maxProperties")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMaxPropertiesConstraint(itr->second), &subschema);
        }

        if (m_version == kDraft7) {
            if ((itr = object.find("exclusiveMinimum")) != object.end()) {
                rootSchema.addConstraintToSubschema(
                        makeMinimumConstraintExclusive(itr->second), &subschema);
            }

            if ((itr = object.find("minimum")) != object.end()) {
                rootSchema.addConstraintToSubschema(
                        makeMinimumConstraint<AdapterType>(itr->second, nullptr),
                        &subschema);
            }
        } else if ((itr = object.find("minimum")) != object.end()) {
            typename AdapterType::Object::const_iterator exclusiveMinimumItr = object.find("exclusiveMinimum");
            if (exclusiveMinimumItr == object.end()) {
                rootSchema.addConstraintToSubschema(
                        makeMinimumConstraint<AdapterType>(itr->second, nullptr),
                        &subschema);
            } else {
                rootSchema.addConstraintToSubschema(
                        makeMinimumConstraint<AdapterType>(itr->second, &exclusiveMinimumItr->second),
                        &subschema);
            }
        } else if (object.find("exclusiveMinimum") != object.end()) {
            throwRuntimeError("'exclusiveMinimum' constraint only valid if a 'minimum' "
                    "constraint is also present");
        }

        if ((itr = object.find("minItems")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMinItemsConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("minLength")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMinLengthConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("minProperties")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeMinPropertiesConstraint(itr->second), &subschema);
        }

        if ((itr = object.find("multipleOf")) != object.end()) {
            if (m_version == kDraft3) {
                throwRuntimeError("'multipleOf' constraint not available in draft 3");
            } else if (itr->second.maybeInteger()) {
                rootSchema.addConstraintToSubschema(
                        makeMultipleOfIntConstraint(itr->second),
                        &subschema);
            } else if (itr->second.maybeDouble()) {
                rootSchema.addConstraintToSubschema(
                        makeMultipleOfDoubleConstraint(itr->second),
                        &subschema);
            } else {
                throwRuntimeError("Expected a numeric value for 'multipleOf' constraint.");
            }
        }

        if ((itr = object.find("not")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeNotConstraint(rootSchema, rootNode, itr->second, updatedScope, nodePath + "/not", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("oneOf")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makeOneOfConstraint(rootSchema, rootNode, itr->second, updatedScope, nodePath + "/oneOf", fetchDoc,
                            docCache, schemaRegistry),
                    &subschema);
        }

        if ((itr = object.find("pattern")) != object.end()) {
            rootSchema.addConstraintToSubschema(
                    makePatternConstraint(itr->second), &subschema);
        }

        {
            // Check for schema keywords that require the creation of a
            // PropertiesConstraint instance.
            const typename AdapterType::Object::const_iterator
                propertiesItr = object.find("properties"),
                patternPropertiesItr = object.find("patternProperties"),
                additionalPropertiesItr = object.find("additionalProperties");
            if (object.end() != propertiesItr ||
                object.end() != patternPropertiesItr ||
                object.end() != additionalPropertiesItr) {
                rootSchema.addConstraintToSubschema(
                        makePropertiesConstraint(rootSchema, rootNode,
                                propertiesItr != object.end() ? &propertiesItr->second : nullptr,
                                patternPropertiesItr != object.end() ? &patternPropertiesItr->second : nullptr,
                                additionalPropertiesItr != object.end() ? &additionalPropertiesItr->second : nullptr,
                                updatedScope, nodePath + "/properties",
                                nodePath + "/patternProperties",
                                nodePath + "/additionalProperties",
                                fetchDoc, &subschema, docCache, schemaRegistry),
                        &subschema);
            }
        }

        if ((itr = object.find("propertyNames")) != object.end()) {
            if (m_version == kDraft7) {
                rootSchema.addConstraintToSubschema(
                      makePropertyNamesConstraint(rootSchema, rootNode, itr->second, updatedScope,
                              nodePath, fetchDoc, docCache, schemaRegistry),
                      &subschema);
            } else {
                throwRuntimeError("Not supported");
            }
        }

        if ((itr = object.find("required")) != object.end()) {
            if (m_version == kDraft3) {
                if (parentSubschema && ownName) {
                    std::optional<constraints::RequiredConstraint> constraint =
                            makeRequiredConstraintForSelf(itr->second, *ownName);
                    if (constraint) {
                        rootSchema.addConstraintToSubschema(*constraint, parentSubschema);
                    }
                } else {
                    throwRuntimeError("'required' constraint not valid here");
                }
            } else {
                rootSchema.addConstraintToSubschema(makeRequiredConstraint(itr->second), &subschema);
            }
        }

        if ((itr = object.find("title")) != object.end()) {
            if (itr->second.maybeString()) {
                rootSchema.setSubschemaTitle(&subschema, itr->second.asString());
            } else {
                throwRuntimeError("'title' attribute should have a string value");
            }
        }

        if ((itr = object.find("uniqueItems")) != object.end()) {
            std::optional<constraints::UniqueItemsConstraint> constraint = makeUniqueItemsConstraint(itr->second);
            if (constraint) {
                rootSchema.addConstraintToSubschema(*constraint, &subschema);
            }
        }

        for (const auto & constraintBuilder : constraintBuilders) {
            if ((itr = object.find(constraintBuilder.first)) != object.end()) {
                constraints::Constraint *constraint = nullptr;
#if VALIJSON_USE_EXCEPTIONS
                try {
#endif
                    constraint = constraintBuilder.second->make(itr->second);
                    rootSchema.addConstraintToSubschema(*constraint, &subschema);
                    delete constraint;
#if VALIJSON_USE_EXCEPTIONS
                } catch (...) {
                    delete constraint;
                    throw;
                }
#endif
            }
        }
    }

    /**
     * @brief  Resolves a chain of JSON References before populating a schema
     *
     * This helper function is used directly by the publicly visible
     * populateSchema function. It ensures that the node being parsed is a
     * concrete node, and not a JSON Reference. This function will call itself
     * recursively to resolve references until a concrete node is found.
     *
     * @param  rootSchema     The Schema instance, and root subschema, through
     *                        which other subschemas can be created and modified
     * @param  rootNode       Reference to the node from which JSON References
     *                        will be resolved when they refer to the current
     *                        document
     * @param  node           Reference to node to parse
     * @param  subschema      Reference to Schema to populate
     * @param  currentScope   URI for current resolution scope
     * @param  nodePath       JSON Pointer representing path to current node
     * @param  fetchDoc       Function to fetch remote JSON documents (optional)
     * @param  parentSchema   Optional pointer to the parent schema, used to
     *                        support required keyword in Draft 3
     * @param  ownName        Optional pointer to a node name, used to support
     *                        the 'required' keyword in Draft 3
     * @param  docCache       Cache of resolved and fetched remote documents
     * @param  schemaRegistry Registry of populated schemas
     */
    template<typename AdapterType>
    void resolveThenPopulateSchema(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const Subschema &subschema,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        const Subschema *parentSchema,
        const std::string *ownName,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        std::string jsonRef;
        if (!extractJsonReference(node, jsonRef)) {
            populateSchema(rootSchema, rootNode, node, subschema, currentScope, nodePath, fetchDoc, parentSchema,
                    ownName, docCache, schemaRegistry);
            return;
        }

        // Returns a document URI if the reference points somewhere
        // other than the current document
        const std::optional<std::string> documentUri = internal::json_reference::getJsonReferenceUri(jsonRef);

        // Extract JSON Pointer from JSON Reference
        const std::string actualJsonPointer = sanitiseJsonPointer(
                internal::json_reference::getJsonReferencePointer(jsonRef));

        const std::optional<std::string> actualDocumentUri =
                resolveDocumentUri(currentScope, documentUri);

        if (!actualJsonPointer.empty() && actualJsonPointer[0] != '/') {
            const std::string idRef = actualDocumentUri ?
                    (*actualDocumentUri + "#" + actualJsonPointer) :
                    ("#" + actualJsonPointer);
            std::string registeredPath;
            std::optional<std::string> registeredScope;
            if (findSchemaById(rootNode, currentScope, idRef,
                        "", registeredPath, registeredScope)) {
                const AdapterType registeredRoot =
                        internal::json_pointer::resolveJsonPointerStrict(
                                rootNode, registeredPath);

                resolveThenPopulateSchema(rootSchema, registeredRoot,
                        registeredRoot, subschema, registeredScope,
                        "", fetchDoc, parentSchema, ownName,
                        docCache, schemaRegistry);
                return;
            }
        }

        if (actualDocumentUri && (!currentScope || *actualDocumentUri != *currentScope)) {
            std::string registeredPath;
            std::optional<std::string> registeredScope;
            if (findSchemaById(rootNode, currentScope, *actualDocumentUri,
                        "", registeredPath, registeredScope)) {
                const AdapterType registeredRoot =
                        internal::json_pointer::resolveJsonPointerStrict(
                                rootNode, registeredPath);
                const AdapterType referencedAdapter = actualJsonPointer.empty()
                        ? registeredRoot
                        : internal::json_pointer::resolveJsonPointerStrict(
                                registeredRoot, actualJsonPointer);

                resolveThenPopulateSchema(rootSchema, registeredRoot,
                        referencedAdapter, subschema, registeredScope,
                        actualJsonPointer, fetchDoc,
                        parentSchema, ownName, docCache, schemaRegistry);
                return;
            }

            // Resolve reference against remote document
            if (!fetchDoc) {
                throwRuntimeError("Fetching of remote JSON References not enabled.");
            }

            const typename DocumentCache<AdapterType>::DocumentType *newDoc = fetchDoc(*actualDocumentUri);

            // Can't proceed without the remote document
            if (!newDoc) {
                throwRuntimeError("Failed to fetch referenced schema document: " + *actualDocumentUri);
            }

            // Add to document cache
            typedef typename DocumentCache<AdapterType>::Type::value_type DocCacheValueType;

            docCache.insert(DocCacheValueType(*actualDocumentUri, newDoc));

            const AdapterType newRootNode(*newDoc);

            const AdapterType &referencedAdapter =
                internal::json_pointer::resolveJsonPointerStrict(newRootNode, actualJsonPointer);

            // TODO: Need to detect degenerate circular references
            resolveThenPopulateSchema(rootSchema, newRootNode, referencedAdapter,
                    subschema, actualDocumentUri, actualJsonPointer, fetchDoc,
                    parentSchema, ownName, docCache, schemaRegistry);

        } else if (!actualJsonPointer.empty()) {
            const AdapterType &referencedAdapter =
                    internal::json_pointer::resolveJsonPointerStrict(rootNode, actualJsonPointer);

            std::optional<std::string> referencedScope = currentScope;
            findScopeForPath(rootNode, currentScope, actualJsonPointer, "",
                    referencedScope);

            resolveThenPopulateSchema(rootSchema, rootNode, referencedAdapter,
                    subschema, referencedScope, actualJsonPointer, fetchDoc,
                    parentSchema, ownName, docCache, schemaRegistry);
        } else {
            throwRuntimeError("Cannot resolve reference \"" + jsonRef + "\".");
        }
    }

    /**
     * @brief   Make a new AllOfConstraint object
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           JSON node containing an array of child schemas
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new AllOfConstraint object that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::AllOfConstraint makeAllOfConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        if (!node.maybeArray()) {
            throwRuntimeError("Expected array value for 'allOf' constraint.");
        }

        constraints::AllOfConstraint constraint;

        int index = 0;
        for (const AdapterType schemaNode : node.asArray()) {
            if (schemaNode.maybeObject() || (m_version == kDraft7 && schemaNode.isBool())) {
                const std::string childPath = nodePath + "/" + std::to_string(index);
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, schemaNode, currentScope,
                        childPath, fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
                constraint.addSubschema(subschema);
                index++;
            } else {
                throwRuntimeError("Expected element to be a valid schema in 'allOf' constraint.");
            }
        }

        return constraint;
    }

    /**
     * @brief   Make a new constraint object for Draft 3's 'extends' keyword.
     *
     * The value of 'extends' may be either a single schema or an array of
     * schemas. The instance is required to validate against every schema
     * listed, so the keyword is modelled as an AllOfConstraint.
     */
    template<typename AdapterType>
    constraints::AllOfConstraint makeExtendsConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::AllOfConstraint constraint;

        if (node.maybeArray()) {
            int index = 0;
            for (const AdapterType schemaNode : node.asArray()) {
                if (!schemaNode.maybeObject()) {
                    throwRuntimeError("Expected element to be a valid schema "
                            "in 'extends' constraint.");
                }
                const std::string childPath = nodePath + "/"
                        + std::to_string(index);
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, schemaNode, currentScope,
                        childPath, fetchDoc, nullptr, nullptr, docCache,
                        schemaRegistry);
                constraint.addSubschema(subschema);
                index++;
            }
        } else if (node.maybeObject()) {
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, node, currentScope, nodePath,
                    fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraint.addSubschema(subschema);
        } else {
            throwRuntimeError("Expected schema or array of schemas for "
                    "'extends' constraint.");
        }

        return constraint;
    }

    /**
     * @brief   Make a new AnyOfConstraint object
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           JSON node containing an array of child schemas
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new AnyOfConstraint object that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::AnyOfConstraint makeAnyOfConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        if (!node.maybeArray()) {
            throwRuntimeError("Expected array value for 'anyOf' constraint.");
        }

        constraints::AnyOfConstraint constraint;

        int index = 0;
        for (const AdapterType schemaNode : node.asArray()) {
            if (schemaNode.maybeObject() || (m_version == kDraft7 && schemaNode.isBool())) {
                const std::string childPath = nodePath + "/" + std::to_string(index);
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, schemaNode, currentScope,
                        childPath, fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
                constraint.addSubschema(subschema);
                index++;
            } else {
                throwRuntimeError("Expected array element to be a valid schema in 'anyOf' constraint.");
            }
        }

        return constraint;
    }

    /**
     * @brief   Make a new ConditionalConstraint object.
     *
     * @param   rootSchema           The Schema instance, and root subschema,
     *                               through which other subschemas can be
     *                               created and modified
     * @param   rootNode             Reference to the node from which JSON
     *                               References will be resolved when they refer
     *                               to the current document; used for recursive
     *                               parsing of schemas
     * @param   ifNode               Schema that will be used to evaluate the
     *                               conditional.
     * @param   thenNode             Optional pointer to a JSON node containing
     *                               a schema that will be used when the conditional
     *                               evaluates to true.
     * @param   elseNode             Optional pointer to a JSON node containing
     *                               a schema that will be used when the conditional
     *                               evaluates to false.
     * @param   currentScope         URI for current resolution scope
     * @param   nodePath             JSON Pointer representing the path to
     *                               the 'contains' node
     * @param   fetchDoc             Function to fetch remote JSON documents
     *                               (optional)
     * @param   docCache             Cache of resolved and fetched remote
     *                               documents
     * @param   schemaRegistry       Registry of populated schemas
     *
     * @return  pointer to a new ContainsConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::ConditionalConstraint makeConditionalConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &ifNode,
        const AdapterType *thenNode,
        const AdapterType *elseNode,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::ConditionalConstraint constraint;

        const Subschema *ifSubschema = makeOrReuseSchema<AdapterType>(
                rootSchema, rootNode, ifNode, currentScope,
                nodePath + "/if", fetchDoc, nullptr, nullptr, docCache,
                schemaRegistry);
        constraint.setIfSubschema(ifSubschema);

        if (thenNode) {
            const Subschema *thenSubschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, *thenNode, currentScope, nodePath + "/then", fetchDoc, nullptr,
                    nullptr, docCache, schemaRegistry);
            constraint.setThenSubschema(thenSubschema);
        }

        if (elseNode) {
            const Subschema *elseSubschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, *elseNode, currentScope, nodePath + "/else", fetchDoc, nullptr,
                    nullptr, docCache, schemaRegistry);
            constraint.setElseSubschema(elseSubschema);
        }

        return constraint;
    }

    /**
     * @brief   Make a new ConstConstraint object.
     *
     * @param   node  JSON node containing an arbitrary value
     *
     * @return  pointer to a new MinimumConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::ConstConstraint makeConstConstraint(const AdapterType &node)
    {
        constraints::ConstConstraint constraint;
        constraint.setValue(node);
        return constraint;
    }

    /**
     * @brief   Make a new ContainsConstraint object.
     *
     * @param   rootSchema           The Schema instance, and root subschema,
     *                               through which other subschemas can be
     *                               created and modified
     * @param   rootNode             Reference to the node from which JSON
     *                               References will be resolved when they refer
     *                               to the current document; used for recursive
     *                               parsing of schemas
     * @param   contains             Optional pointer to a JSON node containing
     *                               an object mapping property names to
     *                               schemas.
     * @param   currentScope         URI for current resolution scope
     * @param   containsPath         JSON Pointer representing the path to
     *                               the 'contains' node
     * @param   fetchDoc             Function to fetch remote JSON documents
     *                               (optional)
     * @param   docCache             Cache of resolved and fetched remote
     *                               documents
     * @param   schemaRegistry       Registry of populated schemas
     *
     * @return  pointer to a new ContainsConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::ContainsConstraint makeContainsConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &contains,
        const std::optional<std::string> currentScope,
        const std::string &containsPath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::ContainsConstraint constraint;

        if (contains.isObject() || (m_version == kDraft7 && contains.maybeBool())) {
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, contains, currentScope, containsPath,
                    fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraint.setSubschema(subschema);

        } else if (contains.maybeObject()) {
            // If a loosely-typed Adapter type is being used, then we'll
            // assume that an empty schema has been provided.
            constraint.setSubschema(rootSchema.emptySubschema());

        } else {
            // All other formats will result in an exception being thrown.
            throwRuntimeError("Expected valid schema for 'contains' constraint.");
        }

        return constraint;
    }

    /**
     * @brief   Make a new DependenciesConstraint object
     *
     * The dependencies for a property can be defined several ways. When parsing
     * a Draft 4 schema, the following can be used:
     *  - an array that lists the name of each property that must be present
     *    if the dependent property is present
     *  - an object that specifies a schema which must be satisfied if the
     *    dependent property is present
     *
     * When parsing a Draft 3 schema, in addition to the formats above, the
     * following format can be used:
     *  - a string that names a single property that must be present if the
     *    dependent property is present
     *
     * Multiple methods can be used in the same dependency constraint.
     *
     * If the format of any part of the dependency node does not match one
     * of these formats, an exception will be thrown.
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           JSON node containing an object that defines a
     *                         mapping of properties to their dependencies.
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new DependencyConstraint that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::DependenciesConstraint makeDependenciesConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        if (!node.maybeObject()) {
            throwRuntimeError("Expected valid subschema for 'dependencies' constraint.");
        }

        constraints::DependenciesConstraint dependenciesConstraint;

        // Process each of the dependency mappings defined by the object
        for (const typename AdapterType::ObjectMember member : node.asObject()) {

            // First, we attempt to parse the value of the dependency mapping
            // as an array of strings. If the Adapter type does not support
            // strict types, then an empty string or empty object will be cast
            // to an array, and the resulting dependency list will be empty.
            // This is equivalent to using an empty object, but does mean that
            // if the user provides an actual string then this error will not
            // be detected.
            if (member.second.maybeArray()) {
                // Parse an array of dependency names
                std::vector<std::string> dependentPropertyNames;
                for (const AdapterType dependencyName : member.second.asArray()) {
                    if (dependencyName.maybeString()) {
                        dependentPropertyNames.push_back(dependencyName.getString());
                    } else {
                        throwRuntimeError("Expected string value in dependency list of property '" +
                            member.first + "' in 'dependencies' constraint.");
                    }
                }

                dependenciesConstraint.addPropertyDependencies(member.first,
                        dependentPropertyNames);

            // If the value of dependency mapping could not be processed as an
            // array, we'll try to process it as an object instead. Note that
            // strict type comparison is used here, since we've already
            // exercised the flexibility by loosely-typed Adapter types. If the
            // value of the dependency mapping is an object, then we'll try to
            // process it as a dependent schema.
            } else if (member.second.isObject() || (m_version == kDraft7 && member.second.maybeBool())) {
                // Parse dependent subschema
                const std::string childPath = nodePath + "/" +
                        escapeJsonPointerToken(member.first);
                const Subschema *childSubschema =
                        makeOrReuseSchema<AdapterType>(rootSchema, rootNode,
                                member.second, currentScope, childPath,
                                fetchDoc, nullptr, nullptr, docCache,
                                schemaRegistry);
                dependenciesConstraint.addSchemaDependency(member.first,
                        childSubschema);

            // If we're supposed to be parsing a Draft3 schema, then the value
            // of the dependency mapping can also be a string containing the
            // name of a single dependency.
            } else if (m_version == kDraft3 && member.second.isString()) {
                dependenciesConstraint.addPropertyDependency(member.first,
                        member.second.getString());

            // All other types result in an exception being thrown.
            } else {
                throwRuntimeError("Invalid dependencies definition.");
            }
        }

        return dependenciesConstraint;
    }

    /**
     * @brief   Make a new EnumConstraint object.
     *
     * @param   node  JSON node containing an array of values permitted by the
     *                constraint.
     *
     * @return  pointer to a new EnumConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::EnumConstraint makeEnumConstraint(
        const AdapterType &node)
    {
        // Make a copy of each value in the enum array
        constraints::EnumConstraint constraint;
        for (const AdapterType value : node.getArray()) {
            constraint.addValue(value);
        }

        /// @todo This will make another copy of the values while constructing
        /// the EnumConstraint. Move semantics in C++11 should make it possible
        /// to avoid these copies without complicating the implementation of the
        /// EnumConstraint class.
        return constraint;
    }

    /**
     * @brief   Make a new FormatConstraint object
     *
     * @param   node  JSON node containing the configuration for this constraint
     *
     * @return  pointer to a new FormatConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::FormatConstraint makeFormatConstraint(
        const AdapterType &node)
    {
        if (node.isString()) {
            const std::string value = node.asString();
            if (!value.empty()) {
                constraints::FormatConstraint constraint;
                constraint.setFormat(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a string value for 'format' constraint.");
    }

    /**
     * @brief   Make a new ItemsConstraint object.
     *
     * @param   rootSchema           The Schema instance, and root subschema,
     *                               through which other subschemas can be
     *                               created and modified
     * @param   rootNode             Reference to the node from which JSON
     *                               References will be resolved when they refer
     *                               to the current document; used for recursive
     *                               parsing of schemas
     * @param   items                Optional pointer to a JSON node containing
     *                               an object mapping property names to
     *                               schemas.
     * @param   additionalItems      Optional pointer to a JSON node containing
     *                               an additional properties schema or a
     *                               boolean value.
     * @param   currentScope         URI for current resolution scope
     * @param   itemsPath            JSON Pointer representing the path to
     *                               the 'items' node
     * @param   additionalItemsPath  JSON Pointer representing the path to
     *                               the 'additionalItems' node
     * @param   fetchDoc             Function to fetch remote JSON documents
     *                               (optional)
     * @param   docCache             Cache of resolved and fetched remote
     *                               documents
     * @param   schemaRegistry       Registry of populated schemas
     *
     * @return  pointer to a new ItemsConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::LinearItemsConstraint makeLinearItemsConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType *items,
        const AdapterType *additionalItems,
        const std::optional<std::string> currentScope,
        const std::string &itemsPath,
        const std::string &additionalItemsPath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::LinearItemsConstraint constraint;

        // Construct a Schema object for the additionalItems constraint,
        // if the additionalItems property is present
        if (additionalItems) {
            if (additionalItems->maybeBool()) {
                // If the value of the additionalItems property is a boolean
                // and is set to true, then additional array items do not need
                // to satisfy any constraints.
                if (additionalItems->asBool()) {
                    constraint.setAdditionalItemsSubschema(rootSchema.emptySubschema());
                }
            } else if (additionalItems->maybeObject()) {
                // If the value of the additionalItems property is an object,
                // then it should be parsed into a Schema object, which will be
                // used to validate additional array items.
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, *additionalItems, currentScope,
                        additionalItemsPath, fetchDoc, nullptr, nullptr, docCache,
                        schemaRegistry);
                constraint.setAdditionalItemsSubschema(subschema);
            } else {
                // Any other format for the additionalItems property will result
                // in an exception being thrown.
                throwRuntimeError("Expected bool or object value for 'additionalItems'");
            }
        } else {
            // The default value for the additionalItems property is an empty
            // object, which means that additional array items do not need to
            // satisfy any constraints.
            constraint.setAdditionalItemsSubschema(rootSchema.emptySubschema());
        }

        // Construct a Schema object for each item in the items array.
        // If the items constraint is not provided, then array items
        // will be validated against the additionalItems schema.
        if (items) {
            if (items->isArray()) {
                // If the items constraint contains an array, then it should
                // contain a list of child schemas which will be used to
                // validate the values at the corresponding indexes in a target
                // array.
                int index = 0;
                for (const AdapterType v : items->getArray()) {
                    const std::string childPath = itemsPath + "/" +
                            std::to_string(index);
                    const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                            rootSchema, rootNode, v, currentScope, childPath,
                            fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
                    constraint.addItemSubschema(subschema);
                    index++;
                }
            } else {
                throwRuntimeError("Expected array value for non-singular 'items' constraint.");
            }
        }

        return constraint;
    }

    /**
     * @brief   Make a new ItemsConstraint object.
     *
     * @param   rootSchema           The Schema instance, and root subschema,
     *                               through which other subschemas can be
     *                               created and modified
     * @param   rootNode             Reference to the node from which JSON
     *                               References will be resolved when they refer
     *                               to the current document; used for recursive
     *                               parsing of schemas
     * @param   items                Optional pointer to a JSON node containing
     *                               an object mapping property names to
     *                               schemas.
     * @param   currentScope         URI for current resolution scope
     * @param   itemsPath            JSON Pointer representing the path to
     *                               the 'items' node
     * @param   fetchDoc             Function to fetch remote JSON documents
     *                               (optional)
     * @param   docCache             Cache of resolved and fetched remote
     *                               documents
     * @param   schemaRegistry       Registry of populated schemas
     *
     * @return  pointer to a new ItemsConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::SingularItemsConstraint makeSingularItemsConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &items,
        const std::optional<std::string> currentScope,
        const std::string &itemsPath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::SingularItemsConstraint constraint;

        // Construct a Schema object for each item in the items array, if an
        // array is provided, or a single Schema object, in an object value is
        // provided. If the items constraint is not provided, then array items
        // will be validated against the additionalItems schema.
        if (items.isObject() || (m_version == kDraft7 && items.maybeBool())) {
            // If the items constraint contains an object value, then it
            // should contain a Schema that will be used to validate all
            // items in a target array. Any schema defined by the
            // additionalItems constraint will be ignored.
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, items, currentScope, itemsPath,
                    fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraint.setItemsSubschema(subschema);

        } else if (items.maybeObject()) {
            // If a loosely-typed Adapter type is being used, then we'll
            // assume that an empty schema has been provided.
            constraint.setItemsSubschema(rootSchema.emptySubschema());

        } else {
            // All other formats will result in an exception being thrown.
            throwRuntimeError("Expected valid schema for singular 'items' constraint.");
        }

        return constraint;
    }

    /**
     * @brief   Make a new MaximumConstraint object (draft 3 and 4).
     *
     * @param   node              JSON node containing the maximum value.
     * @param   exclusiveMaximum  Optional pointer to a JSON boolean value that
     *                            indicates whether maximum value is excluded
     *                            from the range of permitted values.
     *
     * @return  pointer to a new MaximumConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MaximumConstraint makeMaximumConstraint(
        const AdapterType &node,
        const AdapterType *exclusiveMaximum)
    {
        if (!node.maybeDouble()) {
            throwRuntimeError("Expected numeric value for maximum constraint.");
        }

        constraints::MaximumConstraint constraint;
        constraint.setMaximum(node.asDouble());

        if (exclusiveMaximum) {
            if (!exclusiveMaximum->maybeBool()) {
                throwRuntimeError("Expected boolean value for exclusiveMaximum constraint.");
            }

            constraint.setExclusiveMaximum(exclusiveMaximum->asBool());
        }

        return constraint;
    }

    /**
     * @brief   Make a new MaximumConstraint object that is always exclusive (draft 7).
     *
     * @param   node       JSON node containing an integer, representing the maximum value.
     *
     * @return  pointer to a new Maximum that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MaximumConstraint makeMaximumConstraintExclusive(const AdapterType &node)
    {
        if (!node.maybeDouble()) {
            throwRuntimeError("Expected numeric value for exclusiveMaximum constraint.");
        }

        constraints::MaximumConstraint constraint;
        constraint.setMaximum(node.asDouble());
        constraint.setExclusiveMaximum(true);
        return constraint;
    }

    /**
     * @brief   Make a new MaxItemsConstraint object.
     *
     * @param   node  JSON node containing an integer value representing the
     *                maximum number of items that may be contained by an array.
     *
     * @return  pointer to a new MaxItemsConstraint that belongs to the caller.
     */
    template<typename AdapterType>
    constraints::MaxItemsConstraint makeMaxItemsConstraint(
        const AdapterType &node)
    {
        if (node.maybeInteger()) {
            const int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MaxItemsConstraint constraint;
                constraint.setMaxItems(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected non-negative integer value for 'maxItems' constraint.");
    }

    /**
     * @brief   Make a new MaxLengthConstraint object.
     *
     * @param   node  JSON node containing an integer value representing the
     *                maximum length of a string.
     *
     * @return  pointer to a new MaxLengthConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MaxLengthConstraint makeMaxLengthConstraint(
        const AdapterType &node)
    {
        if (node.maybeInteger()) {
            const int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MaxLengthConstraint constraint;
                constraint.setMaxLength(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a non-negative integer value for 'maxLength' constraint.");
    }

    /**
     * @brief   Make a new MaxPropertiesConstraint object.
     *
     * @param   node  JSON node containing an integer value representing the
     *                maximum number of properties that may be contained by an
     *                object.
     *
     * @return  pointer to a new MaxPropertiesConstraint that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::MaxPropertiesConstraint makeMaxPropertiesConstraint(
        const AdapterType &node)
    {
        if (node.maybeInteger()) {
            int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MaxPropertiesConstraint constraint;
                constraint.setMaxProperties(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a non-negative integer for 'maxProperties' constraint.");
    }

    /**
     * @brief  Make a new MinimumConstraint object (draft 3 and 4).
     *
     * @param  node              JSON node containing an integer, representing
     *                           the minimum value.
     *
     * @param  exclusiveMinimum  Optional pointer to a JSON boolean value that
     *                           indicates whether the minimum value is
     *                           excluded from the range of permitted values.
     *
     * @return  pointer to a new MinimumConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MinimumConstraint makeMinimumConstraint(
        const AdapterType &node,
        const AdapterType *exclusiveMinimum)
    {
        if (!node.maybeDouble()) {
            throwRuntimeError("Expected numeric value for minimum constraint.");
        }

        constraints::MinimumConstraint constraint;
        constraint.setMinimum(node.asDouble());

        if (exclusiveMinimum) {
            if (!exclusiveMinimum->maybeBool()) {
                throwRuntimeError("Expected boolean value for 'exclusiveMinimum' constraint.");
            }

            constraint.setExclusiveMinimum(exclusiveMinimum->asBool());
        }

        return constraint;
    }

    /**
     * @brief   Make a new MinimumConstraint object that is always exclusive (draft 7).
     *
     * @param   node       JSON node containing an integer, representing the minimum value.
     *
     * @return  pointer to a new MinimumConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MinimumConstraint makeMinimumConstraintExclusive(const AdapterType &node)
    {
        if (!node.maybeDouble()) {
            throwRuntimeError("Expected numeric value for exclusiveMinimum constraint.");
        }

        constraints::MinimumConstraint constraint;
        constraint.setMinimum(node.asDouble());
        constraint.setExclusiveMinimum(true);
        return constraint;
    }

    /**
     * @brief  Make a new MinItemsConstraint object.
     *
     * @param  node  JSON node containing an integer value representing the
     *               minimum number of items that may be contained by an array.
     *
     * @return  pointer to a new MinItemsConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MinItemsConstraint makeMinItemsConstraint(const AdapterType &node)
    {
        if (node.maybeInteger()) {
            const int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MinItemsConstraint constraint;
                constraint.setMinItems(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a non-negative integer value for 'minItems' constraint.");
    }

    /**
     * @brief  Make a new MinLengthConstraint object.
     *
     * @param  node  JSON node containing an integer value representing the
     *               minimum length of a string.
     *
     * @return  pointer to a new MinLengthConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::MinLengthConstraint makeMinLengthConstraint(const AdapterType &node)
    {
        if (node.maybeInteger()) {
            const int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MinLengthConstraint constraint;
                constraint.setMinLength(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a non-negative integer value for 'minLength' constraint.");
    }


    /**
     * @brief   Make a new MaxPropertiesConstraint object.
     *
     * @param   node  JSON node containing an integer value representing the
     *                minimum number of properties that may be contained by an
     *                object.
     *
     * @return  pointer to a new MinPropertiesConstraint that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::MinPropertiesConstraint makeMinPropertiesConstraint(const AdapterType &node)
    {
        if (node.maybeInteger()) {
            int64_t value = node.asInteger();
            if (value >= 0) {
                constraints::MinPropertiesConstraint constraint;
                constraint.setMinProperties(value);
                return constraint;
            }
        }

        throwRuntimeError("Expected a non-negative integer for 'minProperties' constraint.");
    }

    /**
     * @brief   Make a new MultipleOfDoubleConstraint object
     *
     * @param   node  JSON node containing an numeric value that a target value
     *                must divide by in order to satisfy this constraint
     *
     * @return  a MultipleOfConstraint
     */
    template<typename AdapterType>
    constraints::MultipleOfDoubleConstraint makeMultipleOfDoubleConstraint(const AdapterType &node)
    {
        constraints::MultipleOfDoubleConstraint constraint;
        constraint.setDivisor(node.asDouble());
        return constraint;
    }

    /**
     * @brief   Make a new MultipleOfIntConstraint object
     *
     * @param   node  JSON node containing a numeric value that a target value
     *                must divide by in order to satisfy this constraint
     *
     * @return  a MultipleOfIntConstraint
     */
    template<typename AdapterType>
    constraints::MultipleOfIntConstraint makeMultipleOfIntConstraint(const AdapterType &node)
    {
        constraints::MultipleOfIntConstraint constraint;
        constraint.setDivisor(node.asInteger());
        return constraint;
    }

    /**
     * @brief   Make a new NotConstraint object
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           JSON node containing a schema
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new NotConstraint object that belongs to the caller
     */
    template<typename AdapterType>
    constraints::NotConstraint makeNotConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        if (node.maybeObject() || (m_version == kDraft7 && node.maybeBool())) {
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                    rootSchema, rootNode, node, currentScope, nodePath,
                    fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraints::NotConstraint constraint;
            constraint.setSubschema(subschema);
            return constraint;
        }

        throwRuntimeError("Expected object value for 'not' constraint.");
    }

    /**
     * @brief   Make a new OneOfConstraint object
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           JSON node containing an array of child schemas
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new OneOfConstraint that belongs to the caller
     */
    template<typename AdapterType>
    constraints::OneOfConstraint makeOneOfConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        constraints::OneOfConstraint constraint;

        int index = 0;
        for (const AdapterType schemaNode : node.getArray()) {
            const std::string childPath = nodePath + "/" + std::to_string(index);
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                rootSchema, rootNode, schemaNode, currentScope, childPath,
                fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraint.addSubschema(subschema);
            index++;
        }

        return constraint;
    }

    /**
     * @brief   Make a new PatternConstraint object.
     *
     * @param   node      JSON node containing a pattern string
     *
     * @return  pointer to a new PatternConstraint object that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::PatternConstraint makePatternConstraint(
        const AdapterType &node)
    {
        constraints::PatternConstraint constraint;
        constraint.setPattern(node.getString());
        return constraint;
    }

    /**
     * @brief   Make a new Properties object.
     *
     * @param   rootSchema                The Schema instance, and root
     *                                    subschema, through which other
     *                                    subschemas can be created and modified
     * @param   rootNode                  Reference to the node from which JSON
     *                                    References will be resolved when they
     *                                    refer to the current document; used
     *                                    for recursive parsing of schemas
     * @param   properties                Optional pointer to a JSON node
     *                                    containing an object mapping property
     *                                    names to schemas.
     * @param   patternProperties         Optional pointer to a JSON node
     *                                    containing an object mapping pattern
     *                                    property names to schemas.
     * @param   additionalProperties      Optional pointer to a JSON node
     *                                    containing an additional properties
     *                                    schema or a boolean value.
     * @param   currentScope              URI for current resolution scope
     * @param   propertiesPath            JSON Pointer representing the path to
     *                                    the 'properties' node
     * @param   patternPropertiesPath     JSON Pointer representing the path to
     *                                    the 'patternProperties' node
     * @param   additionalPropertiesPath  JSON Pointer representing the path to
     *                                    the 'additionalProperties' node
     * @param   fetchDoc                  Function to fetch remote JSON
     *                                    documents (optional)
     * @param   parentSubschema           Optional pointer to the Schema of the
     *                                    parent object, needed to support the
     *                                    'required' keyword in Draft 3
     * @param   docCache                  Cache of resolved and fetched remote
     *                                    documents
     * @param   schemaRegistry            Registry of populated schemas
     *
     * @return  pointer to a new Properties that belongs to the caller
     */
    template<typename AdapterType>
    constraints::PropertiesConstraint makePropertiesConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType *properties,
        const AdapterType *patternProperties,
        const AdapterType *additionalProperties,
        const std::optional<std::string> currentScope,
        const std::string &propertiesPath,
        const std::string &patternPropertiesPath,
        const std::string &additionalPropertiesPath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        const Subschema *parentSubschema,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        typedef typename AdapterType::ObjectMember Member;

        constraints::PropertiesConstraint constraint;

        // Create subschemas for 'properties' constraint
        if (properties) {
            for (const Member m : properties->getObject()) {
                const std::string &property = m.first;
                const std::string childPath = propertiesPath + "/" + property;
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, m.second, currentScope, childPath,
                        fetchDoc, parentSubschema, &property, docCache,
                        schemaRegistry);
                constraint.addPropertySubschema(property, subschema);
            }
        }

        // Create subschemas for 'patternProperties' constraint
        if (patternProperties) {
            for (const Member m : patternProperties->getObject()) {
                const std::string &pattern = m.first;
                const std::string childPath = patternPropertiesPath + "/" + pattern;
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, m.second, currentScope, childPath,
                        fetchDoc, parentSubschema, &pattern, docCache,
                        schemaRegistry);
                constraint.addPatternPropertySubschema(pattern, subschema);
            }
        }

        // Create an additionalItems subschema if required
        if (additionalProperties) {
            // If additionalProperties has been set, check for a boolean value.
            // Setting 'additionalProperties' to true allows the values of
            // additional properties to take any form. Setting it false
            // prohibits the use of additional properties.
            // If additionalProperties is instead an object, it should be
            // parsed as a schema. If additionalProperties has any other type,
            // then the schema is not valid.
            if (additionalProperties->isBool() ||
                additionalProperties->maybeBool()) {
                // If it has a boolean value that is 'true', then an empty
                // schema should be used.
                if (additionalProperties->asBool()) {
                    constraint.setAdditionalPropertiesSubschema(rootSchema.emptySubschema());
                }
            } else if (additionalProperties->isObject()) {
                // If additionalProperties is an object, it should be used as
                // a child schema.
                const Subschema *subschema = makeOrReuseSchema<AdapterType>(
                        rootSchema, rootNode, *additionalProperties,
                        currentScope, additionalPropertiesPath, fetchDoc, nullptr,
                        nullptr, docCache, schemaRegistry);
                constraint.setAdditionalPropertiesSubschema(subschema);
            } else {
                // All other types are invalid
                throwRuntimeError("Invalid type for 'additionalProperties' constraint.");
            }
        } else {
            // If an additionalProperties constraint is not provided, then the
            // default value is an empty schema.
            constraint.setAdditionalPropertiesSubschema(rootSchema.emptySubschema());
        }

        return constraint;
    }

    template<typename AdapterType>
    constraints::PropertyNamesConstraint makePropertyNamesConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &currentNode,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        const Subschema *subschema = makeOrReuseSchema<AdapterType>(rootSchema, rootNode, currentNode, currentScope,
                nodePath, fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
        constraints::PropertyNamesConstraint constraint;
        constraint.setSubschema(subschema);
        return constraint;
    }

    /**
     * @brief   Make a new RequiredConstraint.
     *
     * This function is used to create new RequiredContraint objects for
     * Draft 3 schemas.
     *
     * @param   node  Node containing a boolean value.
     * @param   name  Name of the required attribute.
     *
     * @return  pointer to a new RequiredConstraint object that belongs to the
     *          caller
     */
    template<typename AdapterType>
    std::optional<constraints::RequiredConstraint>
            makeRequiredConstraintForSelf(const AdapterType &node,
                    const std::string &name)
    {
        if (!node.maybeBool()) {
            throwRuntimeError("Expected boolean value for 'required' attribute.");
        }

        if (node.asBool()) {
            constraints::RequiredConstraint constraint;
            constraint.addRequiredProperty(name);
            return constraint;
        }

        return std::optional<constraints::RequiredConstraint>();
    }

    /**
     * @brief   Make a new RequiredConstraint.
     *
     * This function is used to create new RequiredContraint objects for
     * Draft 4 schemas.
     *
     * @param   node  Node containing an array of strings.
     *
     * @return  pointer to a new RequiredConstraint object that belongs to the
     *          caller
     */
    template<typename AdapterType>
    constraints::RequiredConstraint makeRequiredConstraint(
        const AdapterType &node)
    {
        constraints::RequiredConstraint constraint;

        for (const AdapterType v : node.getArray()) {
            if (!v.maybeString()) {
                throwRuntimeError("Expected required property name to be a string value");
            }

            constraint.addRequiredProperty(v.getString());
        }

        return constraint;
    }

    /**
     * @brief   Make a new TypeConstraint object
     *
     * @param   rootSchema     The Schema instance, and root subschema, through
     *                         which other subschemas can be created and modified
     * @param   rootNode       Reference to the node from which JSON References
     *                         will be resolved when they refer to the current
     *                         document; used for recursive parsing of schemas
     * @param   node           Node containing the name of a JSON type
     * @param   currentScope   URI for current resolution scope
     * @param   nodePath       JSON Pointer representing path to current node
     * @param   fetchDoc       Function to fetch remote JSON documents (optional)
     * @param   docCache       Cache of resolved and fetched remote documents
     * @param   schemaRegistry Registry of populated schemas
     *
     * @return  pointer to a new TypeConstraint object.
     */
    template<typename AdapterType>
    constraints::TypeConstraint makeTypeConstraint(
        Schema &rootSchema,
        const AdapterType &rootNode,
        const AdapterType &node,
        const std::optional<std::string> currentScope,
        const std::string &nodePath,
        const typename FunctionPtrs<AdapterType>::FetchDoc fetchDoc,
        typename DocumentCache<AdapterType>::Type &docCache,
        SchemaRegistry &schemaRegistry)
    {
        typedef constraints::TypeConstraint TypeConstraint;

        TypeConstraint constraint;

        if (node.maybeString()) {
            const TypeConstraint::JsonType type = TypeConstraint::jsonTypeFromString(node.getString());
            if (type == TypeConstraint::kAny && m_version == kDraft4) {
                throwRuntimeError("'any' type is not supported in version 4 schemas.");
            }

            constraint.addNamedType(type);

        } else if (node.maybeArray()) {
            int index = 0;
            for (const AdapterType v : node.getArray()) {
                if (v.maybeString()) {
                    const TypeConstraint::JsonType type = TypeConstraint::jsonTypeFromString(v.getString());
                    if (type == TypeConstraint::kAny && m_version == kDraft4) {
                        throwRuntimeError("'any' type is not supported in version 4 schemas.");
                    }

                    constraint.addNamedType(type);

                } else if (v.maybeObject() && m_version == kDraft3) {
                    const std::string childPath = nodePath + "/" + std::to_string(index);
                    const Subschema *subschema = makeOrReuseSchema<AdapterType>(rootSchema, rootNode, v, currentScope,
                            childPath, fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
                    constraint.addSchemaType(subschema);

                } else {
                    throwRuntimeError("Type name should be a string.");
                }

                index++;
            }

        } else if (node.maybeObject() && m_version == kDraft3) {
            const Subschema *subschema = makeOrReuseSchema<AdapterType>(rootSchema, rootNode, node, currentScope,
                    nodePath, fetchDoc, nullptr, nullptr, docCache, schemaRegistry);
            constraint.addSchemaType(subschema);

        } else {
            throwRuntimeError("Type name should be a string.");
        }

        return constraint;
    }

    /**
     * @brief   Make a new UniqueItemsConstraint object.
     *
     * @param   node  Node containing a boolean value.
     *
     * @return  pointer to a new UniqueItemsConstraint object that belongs to
     *          the caller, or nullptr if the boolean value is false.
     */
    template<typename AdapterType>
    std::optional<constraints::UniqueItemsConstraint> makeUniqueItemsConstraint(const AdapterType &node)
    {
        if (node.isBool() || node.maybeBool()) {
            // If the boolean value is true, this function will return a pointer
            // to a new UniqueItemsConstraint object. If it is value, then the
            // constraint is redundant, so nullptr is returned instead.
            if (node.asBool()) {
                return constraints::UniqueItemsConstraint();
            } else {
                return std::optional<constraints::UniqueItemsConstraint>();
            }
        }

        throwRuntimeError("Expected boolean value for 'uniqueItems' constraint.");
    }

private:

    /// Version of JSON Schema that should be expected when parsing
    Version m_version;
};

}  // namespace valijson
