<?php

namespace Knuckles\Scribe\Extracting;

use Faker\Factory;
use Illuminate\Http\Testing\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Extraction\Metadata;
use Knuckles\Camel\Extraction\Parameter;
use Knuckles\Camel\Extraction\ResponseCollection;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Extracting\Strategies\StaticData;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\RoutePatternMatcher;

class Extractor
{
    use ParamHelpers;

    private DocumentationConfig $config;

    private static ?Route $routeBeingProcessed = null;

    public function __construct(?DocumentationConfig $config = null)
    {
        // If no config is injected, pull from global
        $this->config = $config ?: new DocumentationConfig(config('scribe'));
    }

    /**
     * External interface that allows users to know what route is currently being processed.
     */
    public static function getRouteBeingProcessed(): ?Route
    {
        return self::$routeBeingProcessed;
    }

    /**
     * @param  array  $routeRules  Rules to apply when generating documentation for this route. Deprecated. Use strategy config instead.
     */
    public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData
    {
        self::$routeBeingProcessed = $route;

        $endpointData = ExtractedEndpointData::fromRoute($route);

        $inheritedDocsOverrides = [];
        if ($endpointData->controller->hasMethod('inheritedDocsOverrides')) {
            $inheritedDocsOverrides = call_user_func([$endpointData->controller->getName(), 'inheritedDocsOverrides']);
            $inheritedDocsOverrides = $inheritedDocsOverrides[$endpointData->method->getName()] ?? [];
        }

        $this->fetchMetadata($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('metadata', $endpointData, $inheritedDocsOverrides);

        $this->fetchUrlParameters($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('urlParameters', $endpointData, $inheritedDocsOverrides);
        $endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters);

        $this->addAuthField($endpointData);

        $this->fetchQueryParameters($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('queryParameters', $endpointData, $inheritedDocsOverrides);
        $endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters);

        $this->fetchRequestHeaders($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('headers', $endpointData, $inheritedDocsOverrides);

        $this->fetchBodyParameters($endpointData, $routeRules);
        $endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters);
        $this->mergeInheritedMethodsData('bodyParameters', $endpointData, $inheritedDocsOverrides);

        if (count($endpointData->cleanBodyParameters) && ! isset($endpointData->headers['Content-Type'])) {
            // Set content type if the user forgot to set it
            $endpointData->headers['Content-Type'] = 'application/json';
        }
        // We need to do all this so response calls can work correctly,
        // even though they're only needed for output
        // Note that this
        [$files, $regularParameters] = OutputEndpointData::splitIntoFileAndRegularParameters($endpointData->cleanBodyParameters);
        if (count($files)) {
            $endpointData->headers['Content-Type'] = 'multipart/form-data';
        }
        $endpointData->fileParameters = $files;
        $endpointData->cleanBodyParameters = $regularParameters;

        $this->fetchResponses($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('responses', $endpointData, $inheritedDocsOverrides);

        $this->fetchResponseFields($endpointData, $routeRules);
        $this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides);

        self::$routeBeingProcessed = null;

        return $endpointData;
    }

    public function shouldSkipRoute($route, array $routesToExclude, array $routesToInclude): bool
    {
        if (! empty($routesToExclude) && RoutePatternMatcher::matches($route, $routesToExclude)) {
            return true;
        }
        if (! empty($routesToInclude) && ! RoutePatternMatcher::matches($route, $routesToInclude)) {
            return true;
        }

        return false;
    }

    /**
     * This method prepares and simplifies request parameters for use in example requests and response calls.
     * It takes in an array with rich details about a parameter eg
     *   ['age' => new Parameter([
     *     'description' => 'The age',
     *     'example' => 12,
     *     'required' => false,
     *   ])]
     * And transforms them into key-example pairs : ['age' => 12]
     * It also filters out parameters which have null values and have 'required' as false.
     * It converts all file params that have string examples to actual files (instances of UploadedFile).
     *
     * @param  array<string,Parameter>  $parameters
     */
    public static function cleanParams(array $parameters): array
    {
        $cleanParameters = [];

        /**
         * @var string $paramName
         * @var Parameter $details
         */
        foreach ($parameters as $paramName => $details) {
            // Remove params which have no intentional examples and are optional.
            if (! $details->exampleWasSpecified) {
                if (is_null($details->example) && $details->required === false) {
                    continue;
                }
            }

            if ($details->type === 'file') {
                if (is_string($details->example)) {
                    $details->example = self::convertStringValueToUploadedFileInstance($details->example);
                } elseif (is_null($details->example)) {
                    $details->example = (new self)->generateDummyValue($details->type);
                }
            }

            if (Str::startsWith($paramName, '[].')) { // Entire body is an array
                if (empty($parameters['[]'])) { // Make sure there's a parent
                    $cleanParameters['[]'] = [[], []];
                    $parameters['[]'] = new Parameter([
                        'name' => '[]',
                        'type' => 'object[]',
                        'description' => '',
                        'required' => true,
                        'example' => [$paramName => $details->example],
                    ]);
                }
            }

            if (Str::contains($paramName, '.')) { // Object field (or array of objects)
                self::setObject($cleanParameters, $paramName, $details->example, $parameters, $details->required);
            } else {
                $cleanParameters[$paramName] = $details->example;
            }
        }

        // Finally, if the body is an array, flatten it.
        if (isset($cleanParameters['[]'])) {
            $cleanParameters = $cleanParameters['[]'];
        }

        return $cleanParameters;
    }

    public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
    {
        $parts = explode('.', $path);

        array_pop($parts); // Get rid of the field name

        $baseName = implode('.', $parts);
        // For array fields, the type should be indicated in the source object by now;
        // eg test.items[] would actually be described as name: test.items, type: object[]
        // So we get rid of that ending []
        // For other fields (eg test.items[].name), it remains as-is
        $baseNameInOriginalParams = $baseName;
        while (Str::endsWith($baseNameInOriginalParams, '[]')) {
            $baseNameInOriginalParams = mb_substr($baseNameInOriginalParams, 0, -2);
        }
        // When the body is an array, param names will be  "[].paramname",
        // so $baseNameInOriginalParams here will be empty
        if (Str::startsWith($path, '[].')) {
            $baseNameInOriginalParams = '[]';
        }

        if (Arr::has($source, $baseNameInOriginalParams)) {
            /** @var Parameter $parentData */
            $parentData = Arr::get($source, $baseNameInOriginalParams);
            // Path we use for data_set
            $dotPath = str_replace('[]', '.0', $path);

            // Don't overwrite parent if there's already data there

            if ($parentData->type === 'object') {
                $parentPath = explode('.', $dotPath);
                $property = array_pop($parentPath);
                $parentPath = implode('.', $parentPath);

                $exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$property] ?? null;
                if (empty($exampleFromParent)) {
                    Arr::set($results, $dotPath, $value);
                }
            } elseif ($parentData->type === 'object[]') {
                // When the body is an array, param names will be  "[].paramname", so dot paths won't work correctly with "[]"
                if (Str::startsWith($path, '[].')) {
                    $valueDotPath = mb_substr($dotPath, 3); // Remove initial '.0.'
                    if (isset($results['[]'][0]) && ! Arr::has($results['[]'][0], $valueDotPath)) {
                        Arr::set($results['[]'][0], $valueDotPath, $value);
                    }
                } else {
                    $parentPath = explode('.', $dotPath);
                    $index = (int) array_pop($parentPath);
                    $parentPath = implode('.', $parentPath);

                    $exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$index] ?? null;
                    if (empty($exampleFromParent)) {
                        Arr::set($results, $dotPath, $value);
                    }
                }
            }
        }
    }

    public function addAuthField(ExtractedEndpointData $endpointData): void
    {
        $isApiAuthed = $this->config->get('auth.enabled', false);
        if (! $isApiAuthed || ! $endpointData->metadata->authenticated) {
            return;
        }

        $strategy = $this->config->get('auth.in');
        $parameterName = $this->config->get('auth.name');

        $faker = Factory::create();
        if ($seed = $this->config->get('examples.faker_seed')) {
            $faker->seed($seed);
        }
        $token = $faker->shuffleString('abcdefghkvaZVDPE1864563');
        $valueToUse = $this->config->get('auth.use_value');
        $valueToDisplay = $this->config->get('auth.placeholder');

        switch ($strategy) {
            case 'query':
            case 'query_or_body':
                $endpointData->auth = ['queryParameters', $parameterName, $valueToUse ?: $token];
                $endpointData->queryParameters[$parameterName] = new Parameter([
                    'name' => $parameterName,
                    'type' => 'string',
                    'example' => $valueToDisplay ?: $token,
                    'description' => 'Authentication key.',
                    'required' => true,
                ]);

                return;

            case 'body':
                $endpointData->auth = ['bodyParameters', $parameterName, $valueToUse ?: $token];
                $endpointData->bodyParameters[$parameterName] = new Parameter([
                    'name' => $parameterName,
                    'type' => 'string',
                    'example' => $valueToDisplay ?: $token,
                    'description' => 'Authentication key.',
                    'required' => true,
                ]);

                return;

            case 'bearer':
                $endpointData->auth = ['headers', 'Authorization', 'Bearer '.($valueToUse ?: $token)];
                $endpointData->headers['Authorization'] = 'Bearer '.($valueToDisplay ?: $token);

                return;

            case 'basic':
                $endpointData->auth = ['headers', 'Authorization', 'Basic '.($valueToUse ?: base64_encode($token))];
                $endpointData->headers['Authorization'] = 'Basic '.($valueToDisplay ?: base64_encode($token));

                return;

            case 'header':
                $endpointData->auth = ['headers', $parameterName, $valueToUse ?: $token];
                $endpointData->headers[$parameterName] = $valueToDisplay ?: $token;

                return;
        }
    }

    public static function transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyName, $strategySettings = [])
    {
        if ($stage === 'responses' && Str::is('*ResponseCalls*', $strategyName) && ! empty($rulesToApply['response_calls'])) {
            // Transform `methods` to `only`
            $strategySettings = [];

            if (isset($rulesToApply['response_calls']['methods'])) {
                if (empty($rulesToApply['response_calls']['methods'])) {
                    // This means all routes are forbidden
                    $strategySettings['except'] = ['*'];
                } else {
                    $strategySettings['only'] = array_map(
                        fn ($method) => "{$method} *",
                        $rulesToApply['response_calls']['methods']
                    );
                }
            }

            // Copy all other keys over as is
            foreach (array_keys($rulesToApply['response_calls']) as $key) {
                if ($key === 'methods') {
                    continue;
                }
                $strategySettings[$key] = $rulesToApply['response_calls'][$key];
            }
        }

        return $strategySettings;
    }

    protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $endpointData->metadata = new Metadata([
            'groupName' => $this->config->get('groups.default', ''),
            'authenticated' => $this->config->get('auth.default', false),
        ]);

        $this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                $hadPreviousValue = ! is_null($endpointData->metadata->{$key});
                $noNewValueSet = is_null($item) || $item === '';
                if ($hadPreviousValue && $noNewValueSet) {
                    continue;
                }
                $endpointData->metadata->{$key} = $item;
            }
        });
    }

    protected function fetchUrlParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('urlParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                if (empty($item['name'])) {
                    $item['name'] = $key;
                }
                $endpointData->urlParameters[$key] = Parameter::create($item, $endpointData->urlParameters[$key] ?? []);
            }
        });
    }

    protected function fetchQueryParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('queryParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                if (empty($item['name'])) {
                    $item['name'] = $key;
                }
                $endpointData->queryParameters[$key] = Parameter::create($item, $endpointData->queryParameters[$key] ?? []);
            }
        });
    }

    protected function fetchBodyParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('bodyParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                if (empty($item['name'])) {
                    $item['name'] = $key;
                }
                $endpointData->bodyParameters[$key] = Parameter::create($item, $endpointData->bodyParameters[$key] ?? []);
            }
        });
    }

    protected function fetchResponses(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('responses', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            // Responses from different strategies are all added, not overwritten
            $endpointData->responses->concat($results);
        });
        // Ensure 200 responses come first
        $endpointData->responses = new ResponseCollection($endpointData->responses->sortBy('status')->values());
    }

    protected function fetchResponseFields(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('responseFields', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                $endpointData->responseFields[$key] = Parameter::create($item, $endpointData->responseFields[$key] ?? []);
            }
        });
    }

    protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, array $rulesToApply): void
    {
        $this->iterateThroughStrategies('headers', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
            foreach ($results as $key => $item) {
                if ($item) {
                    $endpointData->headers[$key] = $item;
                }
            }
        });
    }

    /**
     * Iterate through all defined strategies for this stage.
     * A strategy may return an array of attributes
     * to be added to that stage data, or it may modify the stage data directly.
     *
     * @param  array  $rulesToApply  Deprecated. Use strategy config instead.
     * @param  callable  $handler  function to run after each strategy returns its results (an array)
     */
    protected function iterateThroughStrategies(
        string $stage,
        ExtractedEndpointData $endpointData,
        array $rulesToApply,
        callable $handler,
    ): void {
        $strategies = $this->config->get("strategies.{$stage}", []);

        foreach ($strategies as $strategyClassOrTuple) {
            if (is_array($strategyClassOrTuple)) {
                [$strategyClass, &$settings] = $strategyClassOrTuple;
                if ($strategyClass === 'override') {
                    c::warn("The 'override' strategy was renamed to 'static_data', and will stop working in the future. Please replace 'override' in your config file with 'static_data'.");
                    $strategyClass = 'static_data';
                }
                if ($strategyClass === 'static_data') {
                    $strategyClass = StaticData::class;
                    // Static data can be short: ['static_data', ['key' => 'value']],
                    // or extended ['static_data', ['data' => ['key' => 'value'], 'only' => ['GET *'], 'except' => []]],
                    $settingsFormat = array_key_exists('data', $settings) ? 'extended' : 'short';
                    $settings = match ($settingsFormat) {
                        'extended' => $settings,
                        'short' => ['data' => $settings],
                    };
                }
            } else {
                $strategyClass = $strategyClassOrTuple;
                $settings = [];
            }

            $routesToExclude = Arr::wrap($settings['except'] ?? []);
            $routesToInclude = Arr::wrap($settings['only'] ?? []);

            if ($this->shouldSkipRoute($endpointData->route, $routesToExclude, $routesToInclude)) {
                continue;
            }

            $strategy = new $strategyClass($this->config);
            $results = $strategy($endpointData, $settings);
            if (is_array($results)) {
                $handler($results);
            }
        }
    }

    protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
    {
        $fileName = basename($filePath);

        return new File($fileName, fopen($filePath, 'r'));
    }

    protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void
    {
        $overrides = $inheritedDocsOverrides[$stage] ?? [];
        $normalizeParamData = fn ($data, $key) => array_merge($data, ['name' => $key]);
        if (is_array($overrides)) {
            foreach ($overrides as $key => $item) {
                switch ($stage) {
                    case 'responses':
                        $endpointData->responses->concat($overrides);
                        $endpointData->responses->sortBy('status');

                        break;

                    case 'urlParameters':
                    case 'bodyParameters':
                    case 'queryParameters':
                        $endpointData->{$stage}[$key] = Parameter::make($normalizeParamData($item, $key));

                        break;

                    case 'responseFields':
                        $endpointData->{$stage}[$key] = ResponseField::make($normalizeParamData($item, $key));

                        break;

                    default:
                        $endpointData->{$stage}[$key] = $item;
                }
            }
        } elseif (is_callable($overrides)) {
            $results = $overrides($endpointData);

            $endpointData->{$stage} = match ($stage) {
                'responses' => ResponseCollection::make($results),
                'urlParameters', 'bodyParameters', 'queryParameters' => collect($results)->map(fn ($param, $name) => Parameter::make($normalizeParamData($param, $name)))->all(),
                'responseFields' => collect($results)->map(fn ($field, $name) => ResponseField::make($normalizeParamData($field, $name)))->all(),
                default => $results,
            };
        }
    }
}
