<?php

namespace Knuckles\Scribe\Extracting;

use Faker\Factory;
use Faker\Generator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

trait ParamHelpers
{
    /**
     * Normalizes the stated "type" of a parameter (eg "int", "integer", "double", "array"...)
     * to a number of standard JSON types (integer, boolean, number, object...).
     * Will return the input if no match.
     *
     * @param  mixed  $value
     */
    public static function normalizeTypeName(?string $typeName, $value = null): string
    {
        if (! $typeName) {
            return 'string';
        }

        $base = str_replace('[]', '', mb_strtolower($typeName));

        return match ($base) {
            'bool' => str_replace($base, 'boolean', $typeName),
            'int' => str_replace($base, 'integer', $typeName),
            'float', 'double' => str_replace($base, 'number', $typeName),
            'array' => (empty($value) || array_keys($value)[0] === 0)
                ? static::normalizeTypeName(gettype($value[0] ?? '')).'[]'
                : 'object',
            default => $typeName
        };
    }

    protected function getFakeFactoryByName(string $name): ?\Closure
    {
        $faker = $this->getFaker();

        $name = mb_strtolower(array_reverse(explode('.', $name))[0]);
        $normalizedName = match (true) {
            Str::endsWith($name, ['email', 'email_address']) => 'email',
            Str::endsWith($name, ['uuid']) => 'uuid',
            Str::endsWith($name, ['url']) => 'url',
            Str::endsWith($name, ['locale']) => 'locale',
            Str::endsWith($name, ['timezone']) => 'timezone',
            default => $name,
        };

        return match ($normalizedName) {
            'email' => fn () => $faker->safeEmail(),
            'password', 'pwd' => fn () => $faker->password(),
            'url' => fn () => $faker->url(),
            'description' => fn () => $faker->sentence(),
            'uuid' => fn () => $faker->uuid(),
            'locale' => fn () => $faker->locale(),
            'timezone' => fn () => $faker->timezone(),
            default => null,
        };
    }

    protected function getFaker(): Generator
    {
        $faker = Factory::create();
        if ($seed = $this->config->get('examples.faker_seed')) {
            $faker->seed($seed);
        }

        return $faker;
    }

    protected function generateDummyValue(string $type, array $hints = [])
    {
        if (! empty($hints['enumValues'])) {
            return Arr::random($hints['enumValues']);
        }

        $fakeFactory = $this->getDummyValueGenerator($type, $hints);

        return $fakeFactory();
    }

    protected function getDummyValueGenerator(string $type, array $hints = []): \Closure
    {
        $baseType = $type;
        $isListType = false;

        if (Str::endsWith($type, '[]')) {
            $baseType = mb_strtolower(mb_substr($type, 0, mb_strlen($type) - 2));
            $isListType = true;
        }

        $size = $hints['size'] ?? null;
        if ($isListType) {
            // Return a one-array item for a list by default.
            return $size
                ? fn () => [$this->generateDummyValue($baseType, range(0, min($size - 1, 5)))]
                : fn () => [$this->generateDummyValue($baseType, $hints)];
        }

        if (($hints['name'] ?? false) && $baseType !== 'file') {
            $fakeFactoryByName = $this->getFakeFactoryByName($hints['name']);
            if ($fakeFactoryByName) {
                return $fakeFactoryByName;
            }
        }

        $faker = $this->getFaker();
        $min = $hints['min'] ?? null;
        $max = $hints['max'] ?? null;
        // If max and min were provided, the override size.
        $isExactSize = is_null($min) && is_null($max) && ! is_null($size);

        $fakeFactoriesByType = [
            'integer' => function () use ($size, $isExactSize, $max, $faker, $min) {
                if ($isExactSize) {
                    return $size;
                }

                return $max ? $faker->numberBetween((int) $min, (int) $max) : $faker->numberBetween(1, 20);
            },
            'number' => function () use ($size, $isExactSize, $max, $faker, $min) {
                if ($isExactSize) {
                    return $size;
                }

                return $max ? $faker->numberBetween((int) $min, (int) $max) : $faker->randomFloat();
            },
            'boolean' => fn () => $faker->boolean(),
            'string' => fn () => $size ? $faker->lexify(str_repeat('?', $size)) : $faker->word(),
            'object' => fn () => [],
            'file' => fn () => UploadedFile::fake()->create('test.jpg')->size($size ?: 10),
        ];

        return $fakeFactoriesByType[$baseType] ?? $fakeFactoriesByType['string'];
    }

    protected function isSupportedTypeInDocBlocks(string $type): bool
    {
        $types = [
            'integer',
            'int',
            'number',
            'float',
            'double',
            'boolean',
            'bool',
            'string',
            'object',
        ];

        return in_array(str_replace('[]', '', $type), $types);
    }

    /**
     * Cast a value to a specified type.
     *
     * @param  mixed  $value
     * @return mixed
     */
    protected function castToType($value, string $type)
    {
        if ($value === null) {
            return null;
        }

        if ($type === 'array') {
            $type = 'string[]';
        }

        if (Str::endsWith($type, '[]')) {
            $baseType = mb_strtolower(mb_substr($type, 0, mb_strlen($type) - 2));

            return is_array($value) ? array_map(function ($v) use ($baseType) {
                return $this->castToType($v, $baseType);
            }, $value) : json_decode($value);
        }

        if ($type === 'object') {
            return is_array($value) ? $value : json_decode($value, true);
        }

        $casts = [
            'integer' => 'intval',
            'int' => 'intval',
            'float' => 'floatval',
            'number' => 'floatval',
            'double' => 'floatval',
            'boolean' => 'boolval',
            'bool' => 'boolval',
        ];

        // First, we handle booleans. We can't use a regular cast,
        // because PHP considers string 'false' as true.
        if ($value === 'false' && ($type === 'boolean' || $type === 'bool')) {
            return false;
        }

        if (isset($casts[$type])) {
            return $casts[$type]($value);
        }

        // Return the value unchanged if there's no applicable cast
        return $value;
    }

    /**
     * Allows users to specify that we shouldn't generate an example for the parameter
     * by writing 'No-example'.
     *
     * @return bool if true, don't generate an example for this
     */
    protected function shouldExcludeExample(string $description): bool
    {
        return mb_strpos($description, ' No-example') !== false;
    }

    /**
     * Allows users to specify an example for the parameter by writing 'Example: the-example',
     * to be used in example requests and response calls.
     *
     * @param  string  $type  The type of the parameter. Used to cast the example provided, if any.
     * @return array the description and included example
     */
    protected function parseExampleFromParamDescription(string $description, string $type): array
    {
        $exampleWasSpecified = false;
        $example = null;
        $enumValues = [];

        if (preg_match('/(.*)\bExample:\s*([\s\S]+)\s*/s', $description, $content)) {
            $exampleWasSpecified = true;
            $description = mb_trim($content[1]);

            if ($content[2] === 'null') {
                // If we intentionally put null as example we return null as example
                $example = null;
            } else {
                // Examples are parsed as strings by default, we need to cast them properly
                $example = $this->castToType($content[2], $type);
            }
        }

        if (preg_match('/(.*)\bEnum:\s*([\s\S]+)\s*/s', $description, $content)) {
            $description = mb_trim($content[1]);

            $enumValues = array_map(
                fn ($value) => $this->castToType(mb_trim($value), $type),
                explode(',', mb_rtrim(mb_trim($content[2]), '.'))
            );
        }

        return [$description, $example, $enumValues, $exampleWasSpecified];
    }

    private function getDummyDataGeneratorBetween(string $type, $min, $max = 90, ?string $fieldName = null): \Closure
    {
        $hints = [
            'name' => $fieldName,
            'size' => $this->getFaker()->numberBetween($min, $max),
            'min' => $min,
            'max' => $max,
        ];

        return $this->getDummyValueGenerator($type, $hints);
    }
}
