<?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;

final class Helper
{
    /** @var string[] */
    public const JSON_TYPES = ['string', 'number', 'boolean', 'null', 'object', 'array'];

    /** @var string[] */
    public const JSON_SUBTYPES = ['integer' => 'number'];

    /** @var string[] */
    protected const PHP_TYPE_MAP = [
        'NULL' => 'null',
        'integer' => 'integer',
        'double' => 'number',
        'boolean' => 'boolean',
        'array' => 'array',
        'object' => 'object',
        'string' => 'string',
    ];

    /**
     * @param string $type
     * @return bool
     */
    public static function isValidJsonType(string $type): bool
    {
        if (isset(self::JSON_SUBTYPES[$type])) {
            return true;
        }

        return in_array($type, self::JSON_TYPES, true);
    }

    /**
     * @param string $type
     * @return null|string
     */
    public static function getJsonSuperType(string $type): ?string
    {
        return self::JSON_SUBTYPES[$type] ?? null;
    }

    /**
     * @param mixed $value
     * @param bool $use_subtypes
     * @return null|string
     */
    public static function getJsonType($value, bool $use_subtypes = true): ?string
    {
        $type = self::PHP_TYPE_MAP[gettype($value)] ?? null;
        if ($type === null) {
            return null;
        } elseif ($type === 'array') {
            return self::isIndexedArray($value) ? 'array' : null;
        }

        if ($use_subtypes) {
            if ($type === 'number' && self::isMultipleOf($value, 1)) {
                return 'integer';
            }
        } elseif ($type === 'integer') {
            return 'number';
        }

        return $type;
    }

    /**
     * @param string $type
     * @param string|string[] $allowed
     * @return bool
     */
    public static function jsonTypeMatches(string $type, $allowed): bool
    {
        if (!$allowed) {
            return false;
        }

        if (is_string($allowed)) {
            if ($type === $allowed) {
                return true;
            }

            return $allowed === self::getJsonSuperType($type);
        }

        if (is_array($allowed)) {
            if (in_array($type, $allowed, true)) {
                return true;
            }

            if ($type = self::getJsonSuperType($type)) {
                return in_array($type, $allowed, true);
            }
        }

        return false;
    }

    /**
     * @param mixed $value
     * @param string|string[] $type
     * @return bool
     */
    public static function valueIsOfJsonType($value, $type): bool
    {
        $t = self::getJsonType($value);
        if ($t === null) {
            return false;
        }

        return self::jsonTypeMatches($t, $type);
    }

    /**
     * @param array $array
     * @return bool
     */
    public static function isIndexedArray(array $array): bool
    {
        for ($i = 0, $max = count($array); $i < $max; $i++) {
            if (!array_key_exists($i, $array)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Converts assoc-arrays to objects (recursive)
     * @param scalar|object|array|null $schema
     * @return scalar|object|array|null
     */
    public static function convertAssocArrayToObject($schema)
    {
        if (is_null($schema) || is_scalar($schema)) {
            return $schema;
        }

        $keepArray = is_array($schema) && self::isIndexedArray($schema);

        $data = [];

        foreach ($schema as $key => $value) {
            $data[$key] = is_array($value) || is_object($value) ? self::convertAssocArrayToObject($value) : $value;
        }

        return $keepArray ? $data : (object) $data;
    }

    /**
     * @param mixed $a
     * @param mixed $b
     * @return bool
     */
    public static function equals($a, $b): bool
    {
        if ($a === $b) {
            return true;
        }

        $type = self::getJsonType($a, false);
        if ($type === null || $type !== self::getJsonType($b, false)) {
            return false;
        }

        if ($type === 'number') {
            return $a == $b;
        }

        if ($type === "array") {
            $count = count($a);
            if ($count !== count($b)) {
                return false;
            }

            for ($i = 0; $i < $count; $i++) {
                if (!array_key_exists($i, $a) || !array_key_exists($i, $b)) {
                    return false;
                }
                if (!self::equals($a[$i], $b[$i])) {
                    return false;
                }
            }

            return true;
        }

        if ($type === "object") {
            $a = get_object_vars($a);
            if ($a === null) {
                return false;
            }

            $b = get_object_vars($b);
            if ($b === null) {
                return false;
            }

            if (count($a) !== count($b)) {
                return false;
            }

            foreach ($a as $prop => $value) {
                if (!array_key_exists($prop, $b)) {
                    return false;
                }
                if (!self::equals($value, $b[$prop])) {
                    return false;
                }
            }

            return true;
        }

        return false;
    }


    /**
     * @var bool|null True if bcmath extension is available
     */
    private static ?bool $hasBCMath = null;

    /**
     * @var bool True to use bcmath
     */
    public static bool $useBCMath = true;

    /**
     * @var int Number scale to used when using comparisons
     */
    public static int $numberScale = 14;

    /**
     * @param $number
     * @param $divisor
     * @param int|null $scale
     * @return bool
     */
    public static function isMultipleOf($number, $divisor, ?int $scale = null): bool
    {
        if (!$number || $number == $divisor) {
            return true;
        }

        if ($divisor == 0) {
            return $number == 0;
        }

        // maybe we get lucky
        if (!fmod($number, $divisor)) {
            return true;
        }

        $divisor = abs($divisor);
        if ($divisor == 1) {
            // should have been caught by the above fmod()
            return false;
        }

        $number = abs($number);
        if ($divisor > $number) {
            // number / divisor < 1
            return false;
        }

        // Use global scale if null
        $scale ??= self::$numberScale;

        if (
            !self::$useBCMath ||
            !(self::$hasBCMath ??= extension_loaded('bcmath'))
        ) {
            // use an approximation
            return abs($number - round($number / $divisor) * $divisor) < (10 ** -$scale);
        }

        // use bcmath

        $number = number_format($number, $scale, '.', '');
        $divisor = number_format($divisor, $scale, '.', '');

        // number can be zero after formatting
        if (!(float)$divisor) {
            return $number === $divisor;
        }

        $x = bcdiv($number, $divisor, 0);
        $x = bcmul($divisor, $x, $scale);
        $x = bcsub($number, $x, $scale);

        return 0 === bccomp($x, 0, $scale);
    }

    /**
     * @param $value
     * @return mixed
     */
    public static function cloneValue($value)
    {
        if ($value === null || is_scalar($value)) {
            return $value;
        }

        if (is_array($value)) {
            return array_map(self::class . '::cloneValue', $value);
        }

        if (is_object($value)) {
            return (object)array_map(self::class . '::cloneValue', get_object_vars($value));
        }

        return null;
    }

    /**
     * @param string $pattern
     * @return bool
     */
    public static function isValidPattern(string $pattern): bool
    {
        if (strpos($pattern, '\Z') !== false) {
            return false;
        }

        return @preg_match("\x07{$pattern}\x07u", '') !== false;
    }

    /**
     * @param string $pattern
     * @return string
     */
    public static function patternToRegex(string $pattern): string
    {
        return "\x07{$pattern}\x07uD";
    }

    /**
     * @param mixed $data
     * @return mixed
     */
    public static function toJSON($data)
    {
        if ($data === null || is_scalar($data)) {
            return $data;
        }

        $map = [];

        $isArray = true;
        $index = 0;
        foreach ($data as $key => $value) {
            $map[$key] = self::toJSON($value);
            if ($isArray) {
                if ($index !== $key) {
                    $isArray = false;
                } else {
                    $index++;
                }
            }
        }

        if ($isArray) {
            if (!$map && is_object($data)) {
                return (object) $map;
            }
            return $map;
        }

        return (object) $map;
    }
}
