<?php

/* ============================================================================
 * Copyright 2020 Zindex Software
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * ============================================================================ */
namespace Matomo\Dependencies\McpServer\Opis\JsonSchema;

use SplObjectStorage;
use Matomo\Dependencies\McpServer\Opis\JsonSchema\Info\SchemaInfo;
use Matomo\Dependencies\McpServer\Opis\JsonSchema\Schemas\LazySchema;
use Matomo\Dependencies\McpServer\Opis\JsonSchema\Parsers\SchemaParser;
use Matomo\Dependencies\McpServer\Opis\JsonSchema\Resolvers\SchemaResolver;
use Matomo\Dependencies\McpServer\Opis\JsonSchema\Exceptions\DuplicateSchemaIdException;
class SchemaLoader
{
    /** @var Schema[]|object[] */
    protected array $uriCache = [];
    protected SplObjectStorage $dataCache;
    protected SchemaParser $parser;
    protected ?SchemaResolver $resolver;
    protected bool $decodeJsonString = \false;
    protected ?Uri $base = null;
    /**
     * @param null|SchemaParser $parser
     * @param null|SchemaResolver $resolver
     * @param bool $decodeJsonString
     */
    public function __construct(?SchemaParser $parser = null, ?SchemaResolver $resolver = null, bool $decodeJsonString = \true)
    {
        $this->dataCache = new SplObjectStorage();
        $this->parser = $parser ?? new SchemaParser();
        $this->resolver = $resolver;
        $this->decodeJsonString = $decodeJsonString;
    }
    public function baseUri() : ?Uri
    {
        return $this->base;
    }
    public function setBaseUri(?Uri $uri) : self
    {
        $this->base = $uri;
        return $this;
    }
    public function parser() : SchemaParser
    {
        return $this->parser;
    }
    public function setParser(SchemaParser $parser) : self
    {
        $this->parser = $parser;
        return $this;
    }
    public function resolver() : ?SchemaResolver
    {
        return $this->resolver;
    }
    public function setResolver(?SchemaResolver $resolver) : self
    {
        $this->resolver = $resolver;
        return $this;
    }
    /**
     * @param object $data
     * @param null $id
     * @param string|null $draft
     * @return Schema
     */
    public function loadObjectSchema(object $data, $id = null, ?string $draft = null) : Schema
    {
        // Check if already loaded
        if ($schema = $this->checkExistingObject($data)) {
            return $schema;
        }
        if (!$id) {
            $id = $this->createSchemaId($data);
        }
        $handle_id = fn(Uri $id): ?Schema => $this->checkExistingUri($id);
        $handle_object = function (object $data, Uri $id, string $draft) : ?Schema {
            $this->handleObject($data, $id, null, null, [], $draft, (string) $id);
            return $this->checkExistingObject($data);
        };
        return $this->parser->parseRootSchema($data, Uri::parse($id, \true), $handle_id, $handle_object, $draft);
    }
    /**
     * @param bool $data
     * @param null $id
     * @param string|null $draft
     * @return Schema
     */
    public function loadBooleanSchema(bool $data, $id = null, ?string $draft = null) : Schema
    {
        if (!$id) {
            $id = $this->createSchemaId($data);
        }
        return $this->parser->parseSchema(new SchemaInfo($data, Uri::parse($id, \true), null, null, [], $draft));
    }
    /**
     * @param Uri $uri
     * @return Schema|null
     */
    public function loadSchemaById(Uri $uri) : ?Schema
    {
        if (!$uri->isAbsolute()) {
            if ($this->base === null || !$this->base->isAbsolute()) {
                return null;
            }
            $uri = $this->base->resolveRef($uri);
        }
        $fragment = $uri->fragment();
        if ($fragment === null) {
            $uri = Uri::merge($uri, null, \true);
            $fragment = '';
        }
        $schema = $this->checkExistingUri($uri);
        if ($schema !== null) {
            return $schema;
        }
        if ($fragment === '') {
            return $this->resolve($uri);
        }
        $root = Uri::merge('#', $uri);
        // Check if already resolved
        if (($schema = $this->checkExistingUri($root)) === null) {
            // Try to resolve
            if (($schema = $this->resolve($root)) === null) {
                // Schema not found
                return null;
            }
        }
        // Resolve json pointer
        if ($fragment !== '' && $schema && $schema->info()->isObject() && ($pointer = JsonPointer::parse($fragment)) && $pointer->isAbsolute()) {
            $object = $pointer->data($schema->info()->data());
            if (is_bool($object)) {
                $schema = $this->loadBooleanSchema($object, $uri, $schema->info()->draft());
            } elseif (is_object($object)) {
                $schema = $this->loadObjectSchema($object, $uri, $schema->info()->draft());
            } else {
                $schema = null;
            }
            if ($schema) {
                $key = $this->cacheKey((string) $uri);
                $this->uriCache[$key] = $schema;
                return $schema;
            }
        }
        // Check fragment
        return $this->checkExistingUri($uri);
    }
    /**
     * Clears internal cache
     */
    public function clearCache() : void
    {
        $this->dataCache->removeAll($this->dataCache);
        $this->uriCache = [];
    }
    /**
     * @param Uri $uri
     * @return null|Schema
     */
    protected function resolve(Uri $uri) : ?Schema
    {
        if ($this->resolver === null) {
            return null;
        }
        $data = $this->resolver->resolve($uri);
        if ($this->decodeJsonString && is_string($data)) {
            $data = json_decode($data, \false);
        }
        if (is_bool($data)) {
            $this->handleBoolean($data, $uri, null, null, [], $this->parser->defaultDraftVersion(), (string) $uri);
            return $this->checkExistingUri($uri);
        }
        if (is_object($data)) {
            if ($data instanceof Schema) {
                return $data;
            }
            $this->handleObject($data, $uri, null, null, [], $this->parser->defaultDraftVersion(), (string) $uri);
            return $this->checkExistingObject($data);
        }
        return null;
    }
    /**
     * @param object $data
     * @return null|Schema
     */
    protected function checkExistingObject(object $data) : ?Schema
    {
        if (!$this->dataCache->offsetExists($data)) {
            return null;
        }
        $schema = $this->dataCache[$data];
        if ($schema instanceof LazySchema) {
            $schema = $schema->schema();
            $this->dataCache[$data] = $schema;
        } elseif (!$schema instanceof Schema) {
            $schema = null;
        }
        return $schema;
    }
    /**
     * @param Uri $uri
     * @return null|Schema
     */
    protected function checkExistingUri(Uri $uri) : ?Schema
    {
        if ($uri->fragment() === null || !$uri->isAbsolute()) {
            return null;
        }
        $key = $this->cacheKey((string) $uri);
        if (!isset($this->uriCache[$key])) {
            return null;
        }
        $schema = $this->uriCache[$key];
        if (!$schema instanceof Schema) {
            return $this->uriCache[$key] = $this->checkExistingObject($schema);
        }
        if ($schema instanceof LazySchema) {
            $schema = $schema->schema();
            $this->uriCache[$key] = $schema;
        }
        return $schema;
    }
    /**
     * @param bool $data
     * @param Uri|null $id
     * @param Uri|null $base
     * @param Uri|null $root
     * @param array $path
     * @param string $draft
     * @param string $pointer
     */
    protected function handleBoolean(bool $data, ?Uri $id, ?Uri $base, ?Uri $root, array $path, string $draft, string $pointer)
    {
        $key = $this->cacheKey($pointer);
        if (isset($this->uriCache[$key])) {
            return;
        }
        $this->uriCache[$key] = $this->parser->parseSchema(new SchemaInfo($data, $id, $base, $root, $path, $draft));
    }
    /**
     * @param array $data
     * @param Uri $base
     * @param Uri $root
     * @param array $path
     * @param string $draft
     * @param string $pointer
     */
    protected function handleArray(array $data, Uri $base, Uri $root, array $path, string $draft, string $pointer)
    {
        foreach ($data as $key => $value) {
            if (!is_int($key)) {
                continue;
            }
            if (is_bool($value)) {
                $this->handleBoolean($value, null, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . $key);
            } elseif (is_array($value)) {
                $this->handleArray($value, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . $key);
            } elseif (is_object($value)) {
                $this->handleObject($value, null, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . $key);
            }
        }
    }
    /**
     * @param object $data
     * @param Uri|null $id
     * @param Uri|null $base
     * @param Uri|null $root
     * @param array $path
     * @param string $draft
     * @param string $pointer
     */
    protected function handleObject(object $data, ?Uri $id, ?Uri $base, ?Uri $root, array $path, string $draft, string $pointer)
    {
        $schema_id = $this->parser->parseId($data);
        $schema_anchor = $this->parser->parseAnchor($data, $draft);
        $draft = $this->parser->parseSchemaDraft($data) ?? $draft;
        if ($schema_id !== null) {
            $id = Uri::merge($schema_id, $base, \true);
        } elseif ($schema_anchor !== null) {
            $id = Uri::merge('#' . $schema_anchor, $base, \true);
        }
        $lazy = new LazySchema(new SchemaInfo($data, $id, $base, $root, $path, $draft), $this->parser);
        if ($id && $id->isAbsolute()) {
            $key = $this->cacheKey((string) $id);
            if (isset($this->uriCache[$key])) {
                throw new DuplicateSchemaIdException($id, $data);
            }
            $this->uriCache[$key] = $lazy;
        }
        // When $id and $anchor are both present add a reference to the same lazy object
        if ($schema_id !== null && $schema_anchor !== null) {
            $anchor_id = Uri::merge('#' . $schema_anchor, $id, \true);
            $key = $this->cacheKey((string) $anchor_id);
            if (isset($this->uriCache[$key])) {
                throw new DuplicateSchemaIdException($anchor_id, $data);
            }
            $this->uriCache[$key] = $lazy;
        }
        $this->dataCache[$data] = $lazy;
        $this->uriCache[$this->cacheKey($pointer)] = $lazy;
        if ($root === null) {
            $root = $id;
        }
        if ($base === null) {
            $base = $id ?? $root;
        } elseif ($id !== null) {
            $base = $id;
        }
        foreach ($data as $key => $value) {
            if (!is_string($key)) {
                continue;
            }
            if (is_bool($value)) {
                $this->handleBoolean($value, null, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . JsonPointer::encodePath($key));
            } elseif (is_array($value)) {
                $this->handleArray($value, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . JsonPointer::encodePath($key));
            } elseif (is_object($value)) {
                $this->handleObject($value, null, $base, $root, array_merge($path, [$key]), $draft, $pointer . '/' . JsonPointer::encodePath($key));
            }
        }
    }
    /**
     * @param string $path
     * @return string
     */
    protected function cacheKey(string $path) : string
    {
        return isset($path[32]) ? md5($path) : $path;
    }
    /**
     * @param bool|object $data
     * @return string
     */
    protected function createSchemaId($data) : string
    {
        if (is_bool($data)) {
            $data = $data ? 'true' : 'false';
        } else {
            $data = spl_object_hash($data);
        }
        return "schema:///{$data}.json";
    }
}
