<?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 Opis\JsonSchema;

use ArrayObject;
use Opis\String\UnicodeString;
use Opis\JsonSchema\Errors\ValidationError;

class ValidationContext
{
    /** @var mixed */
    protected $rootData;

    /** @var string[]|int[] */
    protected array $currentDataPath = [];

    protected ?array $fullPath = null;

    protected array $globals;

    /** @var mixed */
    protected $currentData = null;

    protected ?self $parent = null;

    protected SchemaLoader $loader;

    protected ?Schema $sender = null;

    /** @var object[]|null[]|null */
    protected ?array $shared = null;

    /** @var null|string[]|Schema[]|object[] */
    protected ?array $slots = null;

    protected int $sharedIndex = -1;

    protected int $pathIndex = 0;

    protected int $maxErrors = 1;

    protected bool $stopAtFirstError = true;

    /**
     * @param $data
     * @param SchemaLoader $loader
     * @param null|self $parent
     * @param Schema|null $parent
     * @param array $globals
     * @param null|string[]|Schema[] $slots
     * @param int $max_errors
     */
    public function __construct(
        $data,
        SchemaLoader $loader,
        ?self $parent = null,
        ?Schema $sender = null,
        array $globals = [],
        ?array $slots = null,
        int $max_errors = 1,
        bool $stop_at_first_error = true
    ) {
        $this->sender = $sender;
        $this->rootData = $data;
        $this->loader = $loader;
        $this->parent = $parent;
        $this->globals = $globals;
        $this->slots = null;
        $this->maxErrors = $max_errors;
        $this->stopAtFirstError = $stop_at_first_error;
        $this->currentData = [
            [$data, false],
        ];

        if ($slots) {
            $this->setSlots($slots);
        }
    }

    /**
     * @param $data
     * @param Schema|null $sender
     * @param array|null $globals
     * @param array|null $slots
     * @param int|null $max_errors
     * @return self
     */
    public function newInstance(
        $data,
        ?Schema $sender,
        ?array $globals = null,
        ?array $slots = null,
        ?int $max_errors = null,
        ?bool $stop_at_first_error = null
    ): self {
        return new self(
            $data,
            $this->loader,
            $this,
            $sender,
                $globals ?? $this->globals,
                $slots ?? $this->slots,
            $max_errors ?? $this->maxErrors,
            $stop_at_first_error ?? $this->stopAtFirstError
        );
    }

    public function create(
        Schema $sender,
        ?Variables $mapper = null,
        ?Variables $globals = null,
        ?array $slots = null,
        ?int $maxErrors = null,
        ?bool $stop_at_first_error = null
    ): self {
        if ($globals) {
            $globals = $globals->resolve($this->rootData(), $this->currentDataPath());
            if (!is_array($globals)) {
                $globals = (array)$globals;
            }
            $globals += $this->globals;
        } else {
            $globals = $this->globals;
        }

        if ($mapper) {
            $data = $mapper->resolve($this->rootData(), $this->currentDataPath());
        } else {
            $data = $this->currentData();
        }

        return new self($data, $this->loader, $this, $sender, $globals, $slots ?? $this->slots,
            $maxErrors ?? $this->maxErrors, $stop_at_first_error ?? $this->stopAtFirstError);
    }

    public function sender(): ?Schema
    {
        return $this->sender;
    }

    /**
     * @return self|null
     */
    public function parent(): ?self
    {
        return $this->parent;
    }

    /**
     * @return SchemaLoader
     */
    public function loader(): SchemaLoader
    {
        return $this->loader;
    }

    /**
     * @return mixed
     */
    public function rootData()
    {
        return $this->rootData;
    }

    /**
     * @return mixed
     */
    public function currentData()
    {
        return $this->currentData[$this->pathIndex][0];
    }

    /**
     * @param $value
     */
    public function setCurrentData($value): void
    {
        $this->currentData[$this->pathIndex][0] = $value;
        $this->currentData[$this->pathIndex][1] = false;
    }

    /**
     * @return string|null
     */
    public function currentDataType(): ?string
    {
        $type = $this->currentData[$this->pathIndex][1];
        if ($type === false) {
            $type = Helper::getJsonType($this->currentData[$this->pathIndex][0]);
            $this->currentData[$this->pathIndex][1] = $type;
        }

        return $type;
    }

    public function fullDataPath(): array
    {
        if ($this->fullPath === null) {
            if ($this->parent === null) {
                return $this->currentDataPath;
            }
            $this->fullPath = array_merge($this->parent->fullDataPath(), $this->currentDataPath);
        }

        return $this->fullPath;
    }

    /**
     * @return int[]|string[]
     */
    public function currentDataPath(): array
    {
        return $this->currentDataPath;
    }

    /**
     * @param string|int $key
     * @return $this
     */
    public function pushDataPath($key): self
    {
        $this->currentDataPath[] = $key;
        if ($this->fullPath !== null) {
            $this->fullPath[] = $key;
        }

        $data = $this->currentData[$this->pathIndex][0];

        if (is_array($data)) {
            $data = $data[$key] ?? null;
        } elseif (is_object($data)) {
            $data = $data->{$key} ?? null;
        } else {
            $data = null;
        }

        $this->currentData[] = [$data, false];
        $this->pathIndex++;

        return $this;
    }

    /**
     * @return $this
     */
    public function popDataPath(): self
    {
        if ($this->pathIndex < 1) {
            return $this;
        }

        if ($this->fullPath !== null) {
            array_pop($this->fullPath);
        }
        array_pop($this->currentDataPath);
        array_pop($this->currentData);
        $this->pathIndex--;

        return $this;
    }

    /**
     * @return array
     */
    public function globals(): array
    {
        return $this->globals;
    }

    /**
     * @param array $globals
     * @param bool $overwrite
     * @return $this
     */
    public function setGlobals(array $globals, bool $overwrite = false): self
    {
        if ($overwrite) {
            $this->globals = $globals;
        } elseif ($globals) {
            $this->globals = $globals + $this->globals;
        }

        return $this;
    }

    /**
     * @return object[]|Schema[]|string[]|null
     */
    public function slots(): ?array
    {
        return $this->slots;
    }

    /**
     * @param array|null $slots
     * @return $this
     */
    public function setSlots(?array $slots): self
    {
        if ($slots) {
            $list = [];

            foreach ($slots as $name => $value) {
                if (is_bool($value)) {
                    $value = $this->loader->loadBooleanSchema($value);
                } elseif (is_object($value)) {
                    if ($value instanceof Schema) {
                        $list[$name] = $value;
                        continue;
                    }
                    $value = $this->loader->loadObjectSchema($value);
                } elseif (is_string($value)) {
                    if (isset($this->slots[$value])) {
                        $value = $this->slots[$value];
                    } elseif ($this->parent) {
                        $value = $this->parent->slot($value);
                    }
                }

                if ($value instanceof Schema) {
                    $list[$name] = $value;
                }
            }

            $this->slots = $list;
        } else {
            $this->slots = null;
        }

        return $this;
    }

    /**
     * @param string $name
     * @return Schema|null
     */
    public function slot(string $name): ?Schema
    {
        return $this->slots[$name] ?? null;
    }

    /**
     * @return int
     */
    public function maxErrors(): int
    {
        return $this->maxErrors;
    }

    /**
     * @param int $max
     * @return $this
     */
    public function setMaxErrors(int $max): self
    {
        $this->maxErrors = $max;

        return $this;
    }


    public function stopAtFirstError(): bool
    {
        return $this->stopAtFirstError;
    }

    public function setStopAtFirstError(bool $stop): self
    {
        $this->stopAtFirstError = $stop;

        return $this;
    }

    /* --------------------- */

    /**
     * @param Schema $schema
     * @return $this
     */
    public function pushSharedObject(Schema $schema): self
    {
        $unevaluated = !in_array($schema->info()->draft(), ['06', '07']);
        if ($unevaluated && ($parser = $this->loader->parser()) && !$parser->option('allowUnevaluated', true)) {
            $unevaluated = false;
        }

        $this->shared[] = [
            'schema' => $schema,
            'unevaluated' => $unevaluated,
            'object' => null,
        ];
        $this->sharedIndex++;

        return $this;
    }

    /**
     * @return $this
     */
    public function popSharedObject(): self
    {
        if ($this->sharedIndex < 0) {
            return $this;
        }

        $data = array_pop($this->shared);
        $this->sharedIndex--;

        if ($data['unevaluated'] && $data['object']) {
            if ($this->sharedIndex >= 0) {
                $this->mergeUnevaluated($data['object']);
            } elseif ($this->parent && $this->parent->sharedIndex >= 0) {
                $this->parent->mergeUnevaluated($data['object']);
            }
        }

        return $this;
    }

    /**
     * @return object|null
     */
    public function sharedObject(): ?object
    {
        if ($this->sharedIndex < 0) {
            return null;
        }

        return $this->shared[$this->sharedIndex]['object'] ??= (object)[];
    }

    public function schema(): ?Schema
    {
        return $this->shared[$this->sharedIndex]['schema'] ?? null;
    }

    public function trackUnevaluated(): bool
    {
        return $this->shared[$this->sharedIndex]['unevaluated'] ?? false;
    }

    protected function mergeUnevaluated(object $obj): void
    {
        switch ($this->currentDataType()) {
            case 'object':
                if (isset($obj->evaluatedProperties)) {
                    $this->addEvaluatedProperties($obj->evaluatedProperties);
                }
                break;
            case 'array':
                if (isset($obj->evaluatedItems)) {
                    $this->addEvaluatedItems($obj->evaluatedItems);
                }
                break;
        }
    }

    /* ----------------*/

    public function getStringLength(): ?int
    {
        if ($this->currentDataType() !== 'string') {
            return null;
        }

        $shared = $this->sharedObject();

        if (!isset($shared->stringLength)) {
            $shared->stringLength = UnicodeString::from($this->currentData())->length();
        }

        return $shared->stringLength;
    }

    public function setDecodedContent(string $content): bool
    {
        if ($this->currentDataType() !== 'string') {
            return false;
        }

        $this->sharedObject()->decodedContent = $content;

        return true;
    }

    public function getDecodedContent(): ?string
    {
        if ($this->currentDataType() !== 'string') {
            return null;
        }
        return $this->sharedObject()->decodedContent ?? $this->currentData();
    }

    public function getObjectProperties(): ?array
    {
        if ($this->currentDataType() !== 'object') {
            return null;
        }

        return $this->sharedObject()->objectProperties ??= array_keys(get_object_vars($this->currentData()));
    }

    public function addCheckedProperties(?array $properties): bool
    {
        if (!$properties) {
            return false;
        }

        $shared = $this->sharedObject();

        if (!isset($shared->checkedProperties)) {
            $shared->checkedProperties = $properties;
        } else {
            $shared->checkedProperties = array_values(array_unique(array_merge($shared->checkedProperties, $properties)));
        }

        return true;
    }

    public function getCheckedProperties(): ?array
    {
        return $this->sharedObject()->checkedProperties ?? null;
    }

    public function getUncheckedProperties(): ?array
    {
        $properties = $this->getObjectProperties();
        if (!$properties) {
            return $properties;
        }

        $checked = $this->sharedObject()->checkedProperties ?? null;
        if (!$checked) {
            return $properties;
        }

        return array_values(array_diff($properties, $checked));
    }

    public function markAllAsEvaluatedProperties(): bool
    {
        return $this->addEvaluatedProperties($this->getObjectProperties());
    }

    public function addEvaluatedProperties(?array $properties): bool
    {
        if (!$properties || !($this->currentDataType() === 'object') || !$this->trackUnevaluated()) {
            return false;
        }

        $shared = $this->sharedObject();

        if (!isset($shared->evaluatedProperties)) {
            $shared->evaluatedProperties = $properties;
        } else {
            $shared->evaluatedProperties = array_values(array_unique(array_merge($shared->evaluatedProperties, $properties)));
        }

        return true;
    }

    public function getEvaluatedProperties(): ?array
    {
        return $this->sharedObject()->evaluatedProperties ?? null;
    }

    public function getUnevaluatedProperties(): ?array
    {
        $properties = $this->getObjectProperties();
        if (!$properties) {
            return $properties;
        }

        $evaluated = $this->sharedObject()->evaluatedProperties ?? null;
        if (!$evaluated) {
            return $properties;
        }

        return array_values(array_diff($properties, $evaluated));
    }

    public function markAllAsEvaluatedItems(): bool
    {
        return $this->addEvaluatedItems(range(0, count($this->currentData())));
    }

    public function markCountAsEvaluatedItems(int $count): bool
    {
        if (!$count) {
            return false;
        }

        return $this->addEvaluatedItems(range(0, $count));
    }

    public function addEvaluatedItems(?array $items): bool
    {
        if (!$items || !($this->currentDataType() === 'array') || !$this->trackUnevaluated()) {
            return false;
        }

        $shared = $this->sharedObject();

        if (!isset($shared->evaluatedItems)) {
            $shared->evaluatedItems = $items;
        } else {
            $shared->evaluatedItems = array_values(array_unique(array_merge($shared->evaluatedItems, $items), SORT_NUMERIC));
        }

        return true;
    }

    public function getEvaluatedItems(): ?array
    {
        return $this->sharedObject()->evaluatedItems ?? null;
    }

    public function getUnevaluatedItems(): ?array
    {
        if ($this->currentDataType() !== 'array') {
            return null;
        }

        $items = array_keys($this->currentData());
        if (!$items) {
            return $items;
        }

        $evaluated = $this->sharedObject()->evaluatedItems ?? null;
        if (!$evaluated) {
            return $items;
        }

        return array_values(array_diff($items, $evaluated));
    }

    public function validateSchemaWithoutEvaluated(
        Schema $schema,
        ?int $maxErrors = null,
        bool $reset_on_error_only = false,
        ?ArrayObject $array = null
    ): ?ValidationError {
        $currentMaxErrors = $this->maxErrors;

        $this->maxErrors = $maxErrors ?? $currentMaxErrors;

        if ($this->trackUnevaluated()) {
            $shared = $this->sharedObject();

            $props = $shared->evaluatedProperties ?? null;
            $items = $shared->evaluatedItems ?? null;

            $error = $schema->validate($this);

            if ($array) {
                $value = null;

                if ($shared->evaluatedProperties ?? null) {
                    if ($props) {
                        if ($diff = array_diff($shared->evaluatedProperties, $props)) {
                            $value['properties'] = $diff;
                        }
                    } else {
                        $value['properties'] = $shared->evaluatedProperties;
                    }
                }

                if ($shared->evaluatedItems ?? null) {
                    if ($items) {
                        if ($diff = array_diff($shared->evaluatedItems, $items)) {
                            $value['items'] = $diff;
                        }
                    } else {
                        $value['items'] = $shared->evaluatedItems;
                    }
                }

                if ($value) {
                    $array[] = $value;
                }
            }

            if ($reset_on_error_only) {
                if ($error) {
                    $shared->evaluatedProperties = $props;
                    $shared->evaluatedItems = $items;
                }
            } else {
                $shared->evaluatedProperties = $props;
                $shared->evaluatedItems = $items;
            }
        } else {
            $error = $schema->validate($this);
        }

        $this->maxErrors = $currentMaxErrors;

        return $error;
    }
}