<?php

namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;

use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Camel;
use Knuckles\Camel\Extraction\Response;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Camel\Output\Parameter;
use Knuckles\Scribe\Tools\Utils;
use Knuckles\Scribe\Writing\OpenAPISpecWriter;

/**
 * The main generator for Open API Spec. It adds the minimum needed information to the spec.
 */
class BaseGenerator extends OpenApiGenerator
{
    public function root(array $root, array $groupedEndpoints): array
    {
        return array_merge($root, [
            'openapi' => $this->config->get('openapi.version', OpenAPISpecWriter::SPEC_VERSION),
            'info' => [
                'title' => $this->config->get('title') ?: config('app.name', ''),
                'description' => $this->config->get('description', ''),
                'version' => '1.0.0',
            ],
            'servers' => [
                [
                    'url' => mb_rtrim($this->config->get('base_url') ?? config('app.url'), '/'),
                ],
            ],
            'tags' => array_values(array_map(function (array $group) {
                return [
                    'name' => $group['name'],
                    'description' => $group['description'],
                ];
            }, $groupedEndpoints)),
        ]);
    }

    public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array
    {
        $spec = [
            'summary' => $endpoint->metadata->title,
            'operationId' => $this->operationId($endpoint),
            'description' => $endpoint->metadata->description,
            'parameters' => $this->generateEndpointParametersSpec($endpoint),
            'responses' => $this->generateEndpointResponsesSpec($endpoint),
            'tags' => [Arr::first($groupedEndpoints, function ($group) use ($endpoint) {
                return Camel::doesGroupContainEndpoint($group, $endpoint);
            })['name']],
        ];

        if ($endpoint->metadata->deprecated) {
            $spec['deprecated'] = true;
        }

        if (count($endpoint->bodyParameters)) {
            $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint);
        }

        return array_merge($pathItem, $spec);
    }

    public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array
    {
        foreach ($urlParameters as $name => $details) {
            $parameterData = $this->urlParamToOpenApiParameterObject($name, $details);
            $parameters[$name] = $parameterData;
        }

        return $parameters;
    }

    /**
     * @param  array|Parameter  $field
     */
    public function generateFieldData($field): array
    {
        if (is_array($field)) {
            $field = new Parameter($field);
        }

        if ($field->type === 'file') {
            // See https://swagger.io/docs/specification/describing-request-body/file-upload/
            $fieldData = [
                'type' => 'string',
                'format' => 'binary',
                'description' => $field->description ?: '',
            ];
            $this->applyNullable($fieldData, $field->nullable);

            return $fieldData;
        }
        if (Utils::isArrayType($field->type)) {
            $baseType = Utils::getBaseTypeFromArrayType($field->type);
            $baseItem = ($baseType === 'file') ? [
                'type' => 'string',
                'format' => 'binary',
            ] : ['type' => $baseType];

            if (! empty($field->enumValues)) {
                $baseItem['enum'] = $field->enumValues;
            }

            $this->applyNullable($baseItem, $field->nullable);

            $fieldData = [
                'type' => 'array',
                'description' => $field->description ?: '',
                'example' => $field->example,
                'items' => Utils::isArrayType($baseType)
                    ? $this->generateFieldData([
                        'name' => '',
                        'type' => $baseType,
                        'example' => ($field->example ?: [null])[0],
                        'nullable' => $field->nullable,
                    ])
                    : $baseItem,
            ];
            if (str_replace('[]', '', $field->type) === 'file') {
                // Don't include example for file params in OAS; it's hard to translate it correctly
                unset($fieldData['example']);
            }

            if ($baseType === 'object' && ! empty($field->__fields)) {
                if ($fieldData['items']['type'] === 'object') {
                    $fieldData['items']['properties'] = [];
                }
                foreach ($field->__fields as $fieldSimpleName => $subfield) {
                    $fieldData['items']['properties'][$fieldSimpleName] = $this->generateFieldData($subfield);
                    if ($subfield['required']) {
                        $fieldData['items']['required'][] = $fieldSimpleName;
                    }
                }
            }

            return $fieldData;
        }
        if ($field->type === 'object') {
            $data = [
                'type' => 'object',
                'description' => $field->description ?: '',
                'example' => $field->example,
                'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) {
                    return [$subfieldName => $this->generateFieldData($subfield)];
                })->all()),
                'required' => collect($field->__fields)->filter(fn ($f) => $f['required'])->keys()->toArray(),
            ];
            $this->applyNullable($data, $field->nullable);
            // The spec doesn't allow for an empty `required` array. Must have something there.
            if (empty($data['required'])) {
                unset($data['required']);
            }

            return $data;
        }
        $schema = [
            'type' => static::normalizeTypeName($field->type),
            'description' => $field->description ?: '',
            'example' => $field->example,
        ];
        if (! empty($field->enumValues)) {
            $schema['enum'] = $field->enumValues;
        }
        $this->applyNullable($schema, $field->nullable);

        return $schema;
    }

    /**
     * Given a value, generate the schema for it. The schema consists of: {type:, example:, properties: (if value is an
     * object)}, and possibly a description for each property. The $endpoint and $path are used for looking up response
     * field descriptions.
     */
    public function generateSchemaForResponseValue(mixed $value, OutputEndpointData $endpoint, string $path): array
    {
        if ($value instanceof \stdClass) {
            $value = (array) $value;
            $properties = [];
            // Recurse into the object
            foreach ($value as $subField => $subValue) {
                $subFieldPath = sprintf('%s.%s', $path, $subField);
                $properties[$subField] = $this->generateSchemaForResponseValue($subValue, $endpoint, $subFieldPath);
            }
            $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties), $path);

            $schema = [
                'type' => 'object',
                'properties' => $this->objectIfEmpty($properties),
            ];
            if ($required) {
                $schema['required'] = $required;
            }
            $this->setDescription($schema, $endpoint, $path);
            $this->setNullable($schema, $endpoint, $path, $value);

            return $schema;
        }

        $schema = [
            'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)),
            'example' => $value,
        ];
        $this->setDescription($schema, $endpoint, $path);
        $this->setNullable($schema, $endpoint, $path, $value);

        // Set enum values for the property if they exist
        if (! empty($endpoint->responseFields[$path]->enumValues)) {
            $schema['enum'] = $endpoint->responseFields[$path]->enumValues;
        }

        if ($schema['type'] === 'array' && ! empty($value)) {
            $schema['example'] = json_decode(json_encode($schema['example']), true); // Convert stdClass to array

            $sample = $value[0];
            $typeOfEachItem = $this->convertScribeOrPHPTypeToOpenAPIType(gettype($sample));
            $schema['items']['type'] = $typeOfEachItem;

            if ($typeOfEachItem === 'object') {
                $schema['items']['properties'] = collect($sample)->mapWithKeys(function ($v, $k) use ($endpoint, $path) {
                    return [$k => $this->generateSchemaForResponseValue($v, $endpoint, "{$path}.{$k}")];
                })->toArray();

                $required = $this->filterRequiredResponseFields(
                    $endpoint,
                    array_keys($schema['items']['properties']),
                    $path
                );
                if ($required) {
                    $schema['required'] = $required;
                }
            }
        }

        return $schema;
    }

    /**
     * Given an enpoint and a set of object keys at a path, return the properties that are specified as required.
     */
    public function filterRequiredResponseFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array
    {
        $required = [];
        foreach ($properties as $property) {
            $responseField = $endpoint->responseFields["{$path}.{$property}"] ?? $endpoint->responseFields[$property] ?? null;
            if ($responseField && $responseField->required) {
                $required[] = $property;
            }
        }

        return $required;
    }

    protected function operationId(OutputEndpointData $endpoint): string
    {
        if ($endpoint->metadata->title) {
            return preg_replace('/[^\w+]/', '', Str::camel($endpoint->metadata->title));
        }

        $parts = preg_split('/[^\w+]/', $endpoint->uri, -1, PREG_SPLIT_NO_EMPTY);

        return Str::lower($endpoint->httpMethods[0]).implode('', array_map(fn ($part) => ucfirst($part), $parts));
    }

    /**
     * Add query parameters and headers.
     *
     * @return array<int, array<string,mixed>>
     */
    protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): array
    {
        $parameters = [];

        $parameters = $this->generateQueryParams($endpoint, $parameters);

        $parameters = $this->generateHeaders($endpoint, $parameters);

        return $parameters;
    }

    protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint): array|\stdClass
    {
        $body = [];

        if (count($endpoint->bodyParameters)) {
            $schema = [
                'type' => 'object',
                'properties' => [],
            ];

            $hasRequiredParameter = false;
            $hasFileParameter = false;

            foreach ($endpoint->nestedBodyParameters as $name => $details) {
                if ($name === '[]') { // Request body is an array
                    $hasRequiredParameter = true;
                    $schema = $this->generateFieldData($details);

                    break;
                }

                if ($details['required']) {
                    $hasRequiredParameter = true;
                    // Don't declare this earlier.
                    // The spec doesn't allow for an empty `required` array. Must have something there.
                    $schema['required'][] = $name;
                }

                if ($details['type'] === 'file') {
                    $hasFileParameter = true;
                }

                $fieldData = $this->generateFieldData($details);
                if ($details['deprecated']) {
                    $fieldData['deprecated'] = true;
                }

                $schema['properties'][$name] = $fieldData;
            }

            // We remove 'properties' if the request body is an array, so we need to check if it's still there
            if (array_key_exists('properties', $schema)) {
                $schema['properties'] = $this->objectIfEmpty($schema['properties']);
            }
            $body['required'] = $hasRequiredParameter;

            if ($hasFileParameter) {
                // If there are file parameters, content type changes to multipart
                $contentType = 'multipart/form-data';
            } elseif (isset($endpoint->headers['Content-Type'])) {
                $contentType = $endpoint->headers['Content-Type'];
            } else {
                $contentType = 'application/json';
            }

            $body['content'][$contentType]['schema'] = $schema;
        }

        // return object rather than empty array, so can get properly serialised as object
        return $this->objectIfEmpty($body);
    }

    protected function generateEndpointResponsesSpec(OutputEndpointData $endpoint)
    {
        // See https://swagger.io/docs/specification/describing-responses/
        $responses = [];

        foreach ($endpoint->responses as $response) {
            $code = $response->status; // OpenAPI spec requires status codes to be integers
            // OpenAPI groups responses by status code
            // Only one response type per status code, so only the last one will be used
            if ($code === '204') {
                // Must not add content for 204
                $responses[$code] = [
                    'description' => $this->getResponseDescription($response),
                ];
            } elseif (isset($responses[$code])) {
                // If we already have a response for this status code and content type,
                // we change to a `oneOf` which includes all the responses
                $content = $this->generateResponseContentSpec($response->content, $endpoint);
                $contentType = array_keys($content)[0];
                if (isset($responses[$code]['content'][$contentType])) {
                    $newResponseExample = array_replace([
                        'description' => $this->getResponseDescription($response),
                    ], $content[$contentType]['schema']);

                    // If we've already created the oneOf object, add this response
                    if (isset($responses[$code]['content'][$contentType]['schema']['oneOf'])) {
                        $responses[$code]['content'][$contentType]['schema']['oneOf'][] = $newResponseExample;
                    } else {
                        // Create the oneOf object
                        $existingResponseExample = array_replace([
                            'description' => $responses[$code]['description'],
                        ], $responses[$code]['content'][$contentType]['schema']);

                        $responses[$code]['description'] = '';
                        $responses[$code]['content'][$contentType]['schema'] = [
                            'oneOf' => [$existingResponseExample, $newResponseExample],
                        ];
                    }
                }
            } else {
                // Store as the response for this status
                $responses[$code] = [
                    'description' => $this->getResponseDescription($response),
                    'content' => $this->generateResponseContentSpec($response->content, $endpoint),
                ];
            }
        }

        // return object rather than empty array, so can get properly serialised as object
        return $this->objectIfEmpty($responses);
    }

    protected function getResponseDescription(Response $response): string
    {
        if ($response->isBinary()) {
            return mb_trim(str_replace('<<binary>>', '', $response->content));
        }

        $description = (string) ($response->description);
        // Don't include the status code in description; see https://github.com/knuckleswtf/scribe/issues/271
        if (preg_match('/\d{3},\s+(.+)/', $description, $matches)) {
            $description = $matches[1];
        } elseif ($description === (string) ($response->status)) {
            $description = '';
        }

        return $description;
    }

    protected function generateResponseContentSpec(?string $responseContent, OutputEndpointData $endpoint)
    {
        if (Str::startsWith($responseContent, '<<binary>>')) {
            return [
                'application/octet-stream' => [
                    'schema' => [
                        'type' => 'string',
                        'format' => 'binary',
                    ],
                ],
            ];
        }

        if ($responseContent === null) {
            $schema = [
                'type' => 'object',
            ];
            $this->applyNullable($schema, true);

            return [
                'application/json' => [
                    'schema' => $schema,
                ],
            ];
        }

        $decoded = json_decode($responseContent);
        if ($decoded === null) { // Decoding failed, so we return the content string as is
            return [
                'text/plain' => [
                    'schema' => [
                        'type' => 'string',
                        'example' => $responseContent,
                    ],
                ],
            ];
        }

        $response = $endpoint->responses->where('content', $responseContent)->first();
        $contentType = $response->headers['content-type'] ?? $response->headers['Content-Type'] ?? 'application/json';

        switch ($type = gettype($decoded)) {
            case 'string':
            case 'boolean':
            case 'integer':
            case 'double':
                return [
                    $contentType => [
                        'schema' => [
                            'type' => $type === 'double' ? 'number' : $type,
                            'example' => $decoded,
                        ],
                    ],
                ];

            case 'array':
                if (! count($decoded)) {
                    // empty array
                    return [
                        $contentType => [
                            'schema' => [
                                'type' => 'array',
                                'items' => [
                                    'type' => 'object', // No better idea what to put here
                                ],
                                'example' => $decoded,
                            ],
                        ],
                    ];
                }

                // Non-empty array
                if (is_object($decoded[0])) {
                    // If the first item is an object, we assume it's an array of objects'
                    $properties = collect($decoded[0])->mapWithKeys(function ($value, $key) use ($endpoint) {
                        return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)];
                    })->toArray();

                    return [
                        $contentType => [
                            'schema' => [
                                'type' => 'array',
                                'items' => [
                                    'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
                                    'properties' => $this->objectIfEmpty($properties),
                                ],
                                'example' => $decoded,
                            ],
                        ],
                    ];
                }

                return [
                    $contentType => [
                        'schema' => [
                            'type' => 'array',
                            'items' => [
                                'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
                            ],
                            'example' => $decoded,
                        ],
                    ],
                ];

            case 'object':
                $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) {
                    return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)];
                })->toArray();
                $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties));

                $data = [
                    $contentType => [
                        'schema' => [
                            'type' => 'object',
                            'example' => $decoded,
                            'properties' => $this->objectIfEmpty($properties),
                        ],
                    ],
                ];
                if ($required) {
                    $data[$contentType]['schema']['required'] = $required;
                }

                return $data;

            default:
                return [];
        }
    }

    /**
     * Given an array, return an object if the array is empty. To be used with fields that are
     * required by OpenAPI spec to be objects, since empty arrays get serialised as [].
     */
    protected function objectIfEmpty(array $field): array|\stdClass
    {
        return count($field) > 0 ? $field : new \stdClass;
    }

    protected function convertScribeOrPHPTypeToOpenAPIType($type)
    {
        return match ($type) {
            'float', 'double' => 'number',
            'NULL' => 'string',
            default => $type,
        };
    }

    /**
     * Handle nullable fields based on OpenAPI version.
     * In OpenAPI 3.0, use 'nullable: true'.
     * In OpenAPI 3.1, use JSON Schema's type array syntax: 'type: ["string", "null"]'.
     */
    protected function applyNullable(array &$schema, bool $nullable): void
    {
        if (! $nullable) {
            return;
        }

        $schema['nullable'] = true;
    }

    // Set the description for the schema. If the field has a description, it is set in the schema.
    private function setDescription(array &$schema, OutputEndpointData $endpoint, string $path): void
    {
        if (! empty($endpoint->responseFields[$path]->description)) {
            $schema['description'] = $endpoint->responseFields[$path]->description;
        }
    }

    // Set the nullable for the schema. If the field is nullable, it is set in the schema.
    private function setNullable(array &$schema, OutputEndpointData $endpoint, string $path, mixed $value): void
    {
        /** @var null|ResponseField $field */
        $field = $endpoint->responseFields[$path] ?? null;

        // prefer explicit values
        if ($field !== null && $field->nullable !== null) {
            if ($field->nullable) {
                $this->applyNullable($schema, true);
            }

            // false => do not set and do not use example
            return;
        }

        // example is null
        if ($value === null) {
            $this->applyNullable($schema, true);
        }
    }

    protected function generateQueryParams(OutputEndpointData $endpoint, array $parameters): array
    {
        if (count($endpoint->queryParameters)) {
            /**
             * @var string $name
             * @var Parameter $details
             */
            foreach ($endpoint->queryParameters as $name => $details) {
                $parameterData = $this->queryParamToOpenApiParameterObject($name, $details);
                $parameters[] = $parameterData;
            }
        }

        return $parameters;
    }

    protected function generateHeaders(OutputEndpointData $endpoint, mixed $parameters): mixed
    {
        if (count($endpoint->headers)) {
            foreach ($endpoint->headers as $name => $value) {
                if (in_array(mb_strtolower($name), ['content-type', 'accept', 'authorization'])) {
                    // These headers are not allowed in the spec.
                    // https://swagger.io/docs/specification/describing-parameters/#header-parameters
                    continue;
                }

                $parameters[] = $this->headerToOpenApiParameterObject($name, $value);
            }
        }

        return $parameters;
    }

    protected function headerToOpenApiParameterObject(string $name, string $value): array
    {
        return [
            'in' => 'header',
            'name' => $name,
            'description' => '',
            'example' => $value,
            'schema' => [
                'type' => 'string',
            ],
        ];
    }

    protected function queryParamToOpenApiParameterObject(string $name, Parameter $details): array
    {
        $parameterData = [
            'in' => 'query',
            'name' => $name,
            'description' => $details->description,
            'example' => $details->example,
            'required' => $details->required,
            'schema' => $this->generateFieldData($details),
        ];
        if ($details->deprecated) {
            $parameterData['deprecated'] = true;
        }

        return $parameterData;
    }

    protected function urlParamToOpenApiParameterObject(string $name, Parameter $details): array
    {
        $parameterData = [
            'in' => 'path',
            'name' => $name,
            'description' => $details->description,
            'example' => $details->example,
            // Currently, OAS requires path parameters to be required
            'required' => true,
            'schema' => [
                'type' => $details->type,
            ],
        ];
        // Workaround for optional parameters
        if (empty($details->required)) {
            $parameterData['description'] = rtrim('Optional parameter. '.$parameterData['description']);
            $parameterData['examples'] = [
                'omitted' => [
                    'summary' => 'When the value is omitted',
                    'value' => '',
                ],
            ];

            if ($parameterData['example'] !== null) {
                $parameterData['examples']['present'] = [
                    'summary' => 'When the value is present',
                    'value' => $parameterData['example'],
                ];
            }

            // Can't have `example` and `examples`
            unset($parameterData['example']);
        }

        return $parameterData;
    }
}
