#!/usr/bin/env php
<?php

namespace OpenAPIExtractor;

foreach ([__DIR__ . "/../../autoload.php", __DIR__ . "/vendor/autoload.php"] as $file) {
	if (file_exists($file)) {
		require_once $file;
		break;
	}
}

use Ahc\Cli\Input\Command;
use DirectoryIterator;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Throw_;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\ConstExprParser;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TokenIterator;
use PHPStan\PhpDocParser\Parser\TypeParser;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use stdClass;

$command = new Command("generate-spec", "Extract OpenAPI specs from the Nextcloud source code");
$command
	->arguments("dir out")
	->option('--first-status-code', 'Only output the first status code')
	->option('--first-content-type', 'Only output the first content type')
	->option('--allow-missing-docs', 'Allow missing documentation fields')
	->option('--no-tags', 'Use no tags')
	->option('--continue-on-error', 'Continue on error')
	->option('--openapi-version', 'OpenAPI version to use', null, '3.0.3')
	->option('--verbose', 'Verbose logging')
	->parse($_SERVER["argv"]);

$dir = $command->dir ?? "";
$out = $command->out ?? "";
$firstStatusCode = $command->firstStatusCode ?? false;
$firstContentType = $command->firstContentType ?? false;
$allowMissingDocs = $command->allowMissingDocs ?? false;
$useTags = $command->tags ?? true;
Logger::$exitOnError = !($command->continueOnError ?? false);
Logger::$verbose = $command->verbose ?? false;
$openapiVersion = $command->openapiVersion ?? '3.0.3';

if ($dir == "") {
	$dir = ".";
}
if ($out == "") {
	$out = "openapi.json";
}

$astParser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$nodeFinder = new NodeFinder;

$lexer = new Lexer();
$constExprParser = new ConstExprParser();
$typeParser = new TypeParser($constExprParser);
$phpDocParser = new PhpDocParser($typeParser, $constExprParser);

$infoXMLPath = $dir . "/appinfo/info.xml";

if (file_exists($infoXMLPath)) {
	$xml = simplexml_load_string(file_get_contents($infoXMLPath));
	if ($xml === false) {
		Logger::panic("appinfo", "info.xml file at " . $infoXMLPath . " is not parsable");
	}

	$appIsCore = false;
	$appID = (string)$xml->id;
	if ($xml->namespace) {
		$readableAppID = (string)$xml->namespace;
	} else {
		$readableAppID = Helpers::generateReadableAppID($appID);
	}
	$appSummary = (string)$xml->summary;
	$appVersion = (string)$xml->version;
	$appLicence = (string)$xml->licence;
} else {
	$versionPHPPath = $dir . "/../version.php";

	if (!file_exists($versionPHPPath)) {
		Logger::panic("appinfo", "Neither " . $infoXMLPath . " nor " . $versionPHPPath . " exists");
	}

	// Includes https://github.com/nextcloud/server/blob/master/version.php when running inside https://github.com/nextcloud/server/tree/master/core
	include($versionPHPPath);
	if (!isset($OC_VersionString)) {
		Logger::panic("appinfo", "Unable to figure out core version");
	}

	$appIsCore = true;
	$appID = "core";
	$readableAppID = "Core";
	$appSummary = "Core functionality of Nextcloud";
	$appVersion = $OC_VersionString;
	$appLicence = "agpl";
}

$sourceDir = $appIsCore ? $dir : $dir . "/lib";
$appinfoDir = $appIsCore ? $dir : $dir . "/appinfo";

$openapi = [
	"openapi" => $openapiVersion,
	"info" => [
		"title" => $appID,
		"version" => "0.0.1", // This marks the document version and not the implementation version
		"description" => $appSummary,
		"license" => Helpers::license($openapiVersion, $appLicence),
	],
	"components" => [
		"securitySchemes" => Helpers::securitySchemes(),
		"schemas" => [],
	],
	"paths" => [],
];

Logger::info("app", "Extracting OpenAPI spec for " . $appID . " " . $appVersion);

$schemas = [];
$tags = [];

$definitions = [];
$definitionsPath = $sourceDir . "/ResponseDefinitions.php";
if (file_exists($definitionsPath)) {
	foreach ($nodeFinder->findInstanceOf($astParser->parse(file_get_contents($definitionsPath)), Class_::class) as $node) {
		$doc = $node->getDocComment()?->getText();
		if ($doc != null) {
			$docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children;
			foreach ($docNodes as $docNode) {
				if ($docNode instanceof PhpDocTagNode && $docNode->value instanceof TypeAliasTagValueNode) {
					if (!str_starts_with($docNode->value->alias, $readableAppID)) {
						Logger::error("Response definitions", "Type alias '" . $docNode->value->alias . "' has to start with '" . $readableAppID . "'");
					}
					$definitions[$docNode->value->alias] = $docNode->value->type;
				}
			}
		}
	}
	foreach (array_keys($definitions) as $name) {
		$schemas[Helpers::cleanSchemaName($name)] = OpenApiType::resolve("Response definitions", $definitions, $definitions[$name])->toArray($openapiVersion);
	}
} else {
	Logger::debug("Response definitions", "No response definitions were loaded");
}

$capabilities = null;
$publicCapabilities = null;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sourceDir));
$capabilitiesFiles = [];
foreach ($iterator as $file) {
	$path = $file->getPathname();
	if (!str_ends_with($path, ".php")) {
		continue;
	}
	$capabilitiesFiles[] = $path;
}
sort($capabilitiesFiles);
foreach ($capabilitiesFiles as $path) {
	/**
	 * @var Class_ $node
	 */
	foreach ($nodeFinder->findInstanceOf($astParser->parse(file_get_contents($path)), Class_::class) as $node) {
		$implementsCapability = count(array_filter($node->implements, fn (Name $name) => $name->getLast() == "ICapability")) > 0;
		$implementsPublicCapability = count(array_filter($node->implements, fn (Name $name) => $name->getLast() == "IPublicCapability")) > 0;
		if (!$implementsCapability && !$implementsPublicCapability) {
			continue;
		}

		$capabilitiesMethod = null;
		/** @var ClassMethod $classMethod */
		foreach ($nodeFinder->findInstanceOf($node->stmts, ClassMethod::class) as $classMethod) {
			if ($classMethod->name == "getCapabilities") {
				$capabilitiesMethod = $classMethod;
				break;
			}
		}
		if ($capabilitiesMethod == null) {
			Logger::error($path, "Unable to read capabilities method");
			continue;
		}

		$doc = $capabilitiesMethod->getDocComment()?->getText();
		if ($doc == null) {
			Logger::error($path, "Unable to read capabilities docs");
			continue;
		}

		$type = null;
		$docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children;
		foreach ($docNodes as $docNode) {
			if ($docNode instanceof PhpDocTagNode && $docNode->value instanceof ReturnTagValueNode) {
				$type = OpenApiType::resolve($path, $definitions, $docNode->value->type);
				break;
			}
		}
		if ($type == null) {
			Logger::error($path, "No return value");
			continue;
		}

		$schema = $type->toArray($openapiVersion);

		if ($implementsPublicCapability) {
			$publicCapabilities = $publicCapabilities == null ? $schema : Helpers::mergeSchemas([$publicCapabilities, $schema]);
		} else {
			$capabilities = $capabilities == null ? $schema : Helpers::mergeSchemas([$capabilities, $schema]);
		}
	}
}
if ($capabilities != null) {
	$schemas["Capabilities"] = $capabilities;
}
if ($publicCapabilities != null) {
	$schemas["PublicCapabilities"] = $publicCapabilities;
}
if ($capabilities == null && $publicCapabilities == null) {
	Logger::debug("Capabilities", "No capabilities were loaded");
}

$parsedRoutes = file_exists($appinfoDir . "/routes.php") ? Route::parseRoutes($appinfoDir . "/routes.php") : [];
if (count($parsedRoutes) == 0) {
	Logger::warning("Routes", "No routes were loaded");
}

$controllers = [];
$controllersDir = $sourceDir . "/Controller";
if (file_exists($controllersDir)) {
	$dir = new DirectoryIterator($controllersDir);
	foreach ($dir as $file) {
		$filePath = $file->getPathname();
		if (!str_ends_with($filePath, "Controller.php")) {
			continue;
		}

		$controllers[basename($filePath, "Controller.php")] = $astParser->parse(file_get_contents($filePath));
	}
}

$routes = [];
foreach ($parsedRoutes as $key => $value) {
	$isOCS = $key === "ocs";
	$isIndex = $key === "routes";

	if (!$isOCS && !$isIndex) {
		continue;
	}

	foreach ($value as $route) {
		$routeName = $route["name"];

		$postfix = array_key_exists("postfix", $route) ? $route["postfix"] : null;
		$verb = array_key_exists("verb", $route) ? $route["verb"] : "GET";
		$requirements = array_key_exists("requirements", $route) ? $route["requirements"] : [];
		$defaults = array_key_exists("defaults", $route) ? $route["defaults"] : [];
		$root = array_key_exists("root", $route) ? $route["root"] : ($appIsCore ? "" : "/apps/" . $appID);
		$url = $route["url"];
		if (!str_starts_with($url, "/")) {
			$url = "/" . $url;
		}
		if (str_ends_with($url, "/")) {
			$url = substr($url, 0, -1);
		}
		if ($isIndex) {
			$url = "/index.php" . $root . $url;
		}
		if ($isOCS) {
			$url = "/ocs/v2.php" . $root . $url;
		}

		$methodName = lcfirst(str_replace("_", "", ucwords(explode("#", $routeName)[1], "_")));
		if ($methodName == "preflightedCors") {
			continue;
		}

		$controllerName = ucfirst(str_replace("_", "", ucwords(explode("#", $routeName)[0], "_")));
		$controllerClass = null;
		/** @var Class_ $class */
		foreach ($nodeFinder->findInstanceOf($controllers[$controllerName] ?? [], Class_::class) as $class) {
			if ($class->name == $controllerName . "Controller") {
				$controllerClass = $class;
				break;
			}
		}
		if ($controllerClass == null) {
			Logger::error($routeName, "Controller '" . $controllerName . "' not found");
			continue;
		}

		$controllerScopes = Helpers::getOpenAPIAttributeScopes($controllerClass, $routeName);
		if (Helpers::classMethodHasAnnotationOrAttribute($controllerClass, "IgnoreOpenAPI")) {
			if (count($controllerScopes) === 0 || (in_array('ignore', $controllerScopes, true) && count($controllerScopes) === 1)) {
				Logger::info($routeName, "Controller '" . $controllerName . "' ignored because of IgnoreOpenAPI attribute");
				continue;
			}

			Logger::panic($routeName, "Controller '" . $controllerName . "' is marked as ignore but also has other scopes");
		}

		if (in_array('ignore', $controllerScopes, true)) {
			if (count($controllerScopes) === 1) {
				Logger::info($routeName, "Controller '" . $controllerName . "' ignored because of OpenAPI attribute");
				continue;
			}

			Logger::panic($routeName, "Controller '" . $controllerName . "' is marked as ignore but also has other scopes");
		}

		$tagName = implode("_", array_map(fn (string $s) => strtolower($s), Helpers::splitOnUppercaseFollowedByNonUppercase($controllerName)));
		$doc = $controllerClass->getDocComment()?->getText();
		if ($doc != null && count(array_filter($tags, fn (array $tag) => $tag["name"] == $tagName)) == 0) {
			$classDescription = [];

			$docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children;
			foreach ($docNodes as $docNode) {
				if ($docNode instanceof PhpDocTextNode) {
					$block = Helpers::cleanDocComment($docNode->text);
					if ($block == "") {
						continue;
					}
					$classDescription[] = $block;
				}
			}

			if (count($classDescription) > 0) {
				$tags[] = [
					"name" => $tagName,
					"description" => join("\n", $classDescription),
				];
			}
		}

		$methodFunction = null;
		/** @var ClassMethod $classMethod */
		foreach ($nodeFinder->findInstanceOf($controllerClass->stmts, ClassMethod::class) as $classMethod) {
			if ($classMethod->name == $methodName) {
				$methodFunction = $classMethod;
				break;
			}
		}
		if ($methodFunction == null) {
			Logger::panic($routeName, 'Missing controller method');
		}

		$isCSRFRequired = !Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "NoCSRFRequired");
		if ($isCSRFRequired && !$isOCS) {
			Logger::debug($routeName, "Route ignored because of required CSRF in a non-OCS controller");
			continue;
		}

		$isCORS = Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "CORS");
		$isPublic = Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "PublicPage");
		$isAdmin = !Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "NoAdminRequired") && !$isPublic;
		$isDeprecated = Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "deprecated");
		$isIgnored = Helpers::classMethodHasAnnotationOrAttribute($methodFunction, "IgnoreOpenAPI");
		$scopes = Helpers::getOpenAPIAttributeScopes($classMethod, $routeName);

		if ($isIgnored) {
			if (count($scopes) === 0 || (in_array('ignore', $scopes, true) && count($scopes) === 1)) {
				Logger::debug($routeName, "Route ignored because of IgnoreOpenAPI attribute");
				continue;
			}

			Logger::panic($routeName, "Route is marked as ignore but also has other scopes");
		}

		if (in_array('ignore', $scopes, true)) {
			if (count($scopes) === 1) {
				Logger::info($routeName, "Route ignored because of OpenAPI attribute");
				continue;
			}

			Logger::panic($routeName, "Route is marked as ignore but also has other scopes");
		}

		if (empty($scopes)) {
			if (!empty($controllerScopes)) {
				$scopes = $controllerScopes;
			} elseif ($isAdmin) {
				$scopes = ['administration'];
			} else {
				$scopes = ['default'];
			}
		}

		$routeTags = Helpers::getOpenAPIAttributeTagsByScope($classMethod, $routeName, $tagName, reset($scopes));

		if ($isOCS && !array_key_exists("OCSMeta", $schemas)) {
			$schemas["OCSMeta"] = [
				"type" => "object",
				"required" => [
					"status",
					"statuscode",
				],
				"properties" => [
					"status" => ["type" => "string"],
					"statuscode" => ["type" => "integer"],
					"message" => ["type" => "string"],
					"totalitems" => ["type" => "string"],
					"itemsperpage" => ["type" => "string"],
				],
			];
		}

		$classMethodInfo = ControllerMethod::parse($routeName, $definitions, $methodFunction, $isAdmin, $isDeprecated);
		if (count($classMethodInfo->returns) > 0) {
			Logger::error($routeName, "Returns an invalid response");
			continue;
		}
		if (count($classMethodInfo->responses) == 0) {
			Logger::error($routeName, "Returns no responses");
			continue;
		}

		$codeStatusCodes = [];
		/* @var Throw_ $throwStatement */
		foreach ($nodeFinder->findInstanceOf($methodFunction->stmts, Throw_::class) as $throwStatement) {
			if ($throwStatement->expr instanceof New_ && $throwStatement->expr->class instanceof Name) {
				$type = $throwStatement->expr->class->getLast();
				$statusCode = StatusCodes::resolveException($routeName, $type);
				if ($statusCode != null) {
					$codeStatusCodes[] = $statusCode;
				}
			}
		}

		$docStatusCodes = array_map(fn (ControllerMethodResponse $response) => $response->statusCode, array_filter($classMethodInfo->responses, fn (?ControllerMethodResponse $response) => $response != null));
		$missingDocStatusCodes = array_unique(array_filter(array_diff($codeStatusCodes, $docStatusCodes), fn (int $code) => $code < 500));

		if (count($missingDocStatusCodes) > 0) {
			Logger::error($routeName, "Returns undocumented status codes: " . implode(", ", $missingDocStatusCodes));
			continue;
		}

		foreach ($scopes as $scope) {
			$routes[$scope] ??= [];
			$routes[$scope][] = new Route(
				$routeName,
				$routeTags[$scope] ?? [$tagName],
				$methodName,
				$postfix,
				$verb,
				$url,
				$requirements,
				$defaults,
				$classMethodInfo,
				$isOCS,
				$isCORS,
				$isCSRFRequired,
				$isPublic,
			);
		}

		Logger::debug($routeName, "Route generated");
	}
}

$tagNames = [];
if ($useTags) {
	foreach ($routes as $scope => $scopeRoutes) {
		foreach ($scopeRoutes as $route) {
			foreach ($route->tags as $tag) {
				if (!in_array($tag, $tagNames)) {
					$tagNames[] = $tag;
				}
			}
		}
	}
}

$scopePaths = [];

foreach ($routes as $scope => $scopeRoutes) {
	foreach ($scopeRoutes as $route) {
		$pathParameters = [];
		$urlParameters = [];

		preg_match_all("/{[^}]*}/", $route->url, $urlParameters);
		$urlParameters = array_map(fn (string $name) => substr($name, 1, -1), $urlParameters[0]);

		foreach ($urlParameters as $urlParameter) {
			$matchingParameters = array_filter($route->controllerMethod->parameters, function (ControllerMethodParameter $param) use ($urlParameter) {
				return $param->name == $urlParameter;
			});
			$requirement = array_key_exists($urlParameter, $route->requirements) ? $route->requirements[$urlParameter] : null;
			if (count($matchingParameters) == 1) {
				$parameter = $matchingParameters[array_keys($matchingParameters)[0]];
				if ($parameter?->methodParameter == null && ($route->requirements == null || !array_key_exists($urlParameter, $route->requirements))) {
					Logger::error($route->name, "Unable to find parameter for '" . $urlParameter . "'");
					continue;
				}

				$schema = $parameter->type->toArray($openapiVersion, true);
				$description = $parameter?->docType != null && $parameter->docType->description != "" ? Helpers::cleanDocComment($parameter->docType->description) : null;
			} else {
				$schema = [
					"type" => "string",
				];
				$description = null;
			}

			if ($requirement != null) {
				if (!str_starts_with($requirement, "^")) {
					$requirement = "^" . $requirement;
				}
				if (!str_ends_with($requirement, "$")) {
					$requirement = $requirement . "$";
				}
			}

			if ($schema["type"] == "string") {
				if ($urlParameter == "apiVersion") {
					if ($requirement == null) {
						Logger::error($route->name, "Missing requirement for apiVersion");
						continue;
					}
					preg_match("/^\^\(([v0-9-.|]*)\)\\$$/m", $requirement, $matches);
					if (count($matches) == 2) {
						$enum = explode("|", $matches[1]);
					} else {
						Logger::error($route->name, "Invalid requirement for apiVersion");
						continue;
					}
					$schema["enum"] = $enum;
					$schema["default"] = end($enum);
				} elseif ($requirement != null) {
					$schema["pattern"] = $requirement;
				}
			}

			if (array_key_exists($urlParameter, $route->defaults)) {
				$schema["default"] = $route->defaults[$urlParameter];
			}

			$pathParameters[] = array_merge(
				[
					"name" => $urlParameter,
					"in" => "path",
				],
				$description != null ? ["description" => $description] : [],
				[
					"required" => true,
					"schema" => $schema,
				],
			);
		}

		$queryParameters = [];
		foreach ($route->controllerMethod->parameters as $parameter) {
			$alreadyInPath = false;
			foreach ($pathParameters as $pathParameter) {
				if ($pathParameter["name"] == $parameter->name) {
					$alreadyInPath = true;
					break;
				}
			}
			if (!$alreadyInPath) {
				$queryParameters[] = $parameter;
			}
		}

		$mergedResponses = [];
		foreach (array_unique(array_map(fn (ControllerMethodResponse $response) => $response->statusCode, array_filter($route->controllerMethod->responses, fn (?ControllerMethodResponse $response) => $response != null))) as $statusCode) {
			if ($firstStatusCode && count($mergedResponses) > 0) {
				break;
			}

			$statusCodeResponses = array_filter($route->controllerMethod->responses, fn (?ControllerMethodResponse $response) => $response != null && $response->statusCode == $statusCode);
			$headers = [];
			foreach ($statusCodeResponses as $response) {
				if ($response->headers !== null) {
					$headers = array_merge($headers, $response->headers);
				}
			}

			$mergedContentTypeResponses = [];
			foreach (array_unique(array_map(fn (ControllerMethodResponse $response) => $response->contentType, array_filter($statusCodeResponses, fn (ControllerMethodResponse $response) => $response->contentType != null))) as $contentType) {
				if ($firstContentType && count($mergedContentTypeResponses) > 0) {
					break;
				}

				/** @var ControllerMethodResponse[] $contentTypeResponses */
				$contentTypeResponses = array_values(array_filter($statusCodeResponses, fn (ControllerMethodResponse $response) => $response->contentType == $contentType));

				$hasEmpty = count(array_filter($contentTypeResponses, fn (ControllerMethodResponse $response) => $response->type == null)) > 0;
				$uniqueResponses = array_values(array_intersect_key($contentTypeResponses, array_unique(array_map(fn (ControllerMethodResponse $response) => $response->type->toArray($openapiVersion), array_filter($contentTypeResponses, fn (ControllerMethodResponse $response) => $response->type != null)), SORT_REGULAR)));
				if (count($uniqueResponses) == 1) {
					if ($hasEmpty) {
						$mergedContentTypeResponses[$contentType] = [];
					} else {
						$schema = Helpers::cleanEmptyResponseArray($contentTypeResponses[0]->type->toArray($openapiVersion));
						$mergedContentTypeResponses[$contentType] = ["schema" => Helpers::wrapOCSResponse($route, $contentTypeResponses[0], $schema)];
					}
				} else {
					$mergedContentTypeResponses[$contentType] = [
						"schema" => [
							[$hasEmpty ? "anyOf" : "oneOf" => array_map(function (ControllerMethodResponse $response) use ($route, $openapiVersion) {
								$schema = Helpers::cleanEmptyResponseArray($response->type->toArray($openapiVersion));
								return Helpers::wrapOCSResponse($route, $response, $schema);
							}, $uniqueResponses)],
						],
					];
				}
			}

			$mergedResponses[$statusCode] = array_merge(
				[
					"description" => array_key_exists($statusCode, $route->controllerMethod->responseDescription) ? $route->controllerMethod->responseDescription[$statusCode] : "",
				],
				count($headers) > 0 ? [
					"headers" => array_combine(
						array_keys($headers),
						array_map(
							fn (OpenApiType $type) => [
								"schema" => $type->toArray($openapiVersion),
							],
							array_values($headers),
						),
					),
				] : [],
				count($mergedContentTypeResponses) > 0 ? [
					"content" => $mergedContentTypeResponses,
				] : [],
			);
		}

		$operationId = [...$route->tags, ...Helpers::splitOnUppercaseFollowedByNonUppercase($route->methodName)];
		if ($route->postfix != null) {
			$operationId[] = $route->postfix;
		}

		$security = [];
		if ($route->isPublic) {
			// Add empty authentication, meaning that it's optional. We can't know if there is a difference in behaviour for authenticated vs. unauthenticated access on public pages (e.g. capabilities)
			$security[] = new stdClass();
		}
		if (!$route->isCORS) {
			// Bearer auth is not allowed on CORS routes
			$security[] = ["bearer_auth" => []];
		}
		if (!$route->isCSRFRequired || $route->isOCS) {
			// Add basic auth last, so it's only fallback if bearer is available
			$security[] = ["basic_auth" => []];
		}

		$operation = array_merge(
			["operationId" => strtolower(implode("-", $operationId))],
			$route->controllerMethod->summary != null ? ["summary" => $route->controllerMethod->summary] : [],
			count($route->controllerMethod->description) > 0 ? ["description" => implode("\n", $route->controllerMethod->description)] : [],
			$route->controllerMethod->isDeprecated ? ["deprecated" => true] : [],
			$useTags ? ["tags" => $route->tags] : [],
			count($security) > 0 ? ["security" => $security] : [],
			count($queryParameters) > 0 || count($pathParameters) > 0 || $route->isOCS ? [
				"parameters" => array_merge(
					array_map(fn (ControllerMethodParameter $parameter) => array_merge(
						[
							"name" => $parameter->name . ($parameter->type->type == "array" ? "[]" : ""),
							"in" => "query",
						],
						$parameter->docType != null && $parameter->docType->description != "" ? ["description" => Helpers::cleanDocComment($parameter->docType->description)] : [],
						!$parameter->type->nullable && !$parameter->type->hasDefaultValue ? ["required" => true] : [],
						["schema" => $parameter->type->toArray($openapiVersion, true),],
					), $queryParameters),
					$pathParameters,
					$route->isOCS ? [[
						"name" => "OCS-APIRequest",
						"in" => "header",
						"description" => "Required to be true for the API request to pass",
						"required" => true,
						"schema" => [
							"type" => "boolean",
							"default" => true,
						],
					]] : [],
				),
			] : [],
			["responses" => $mergedResponses],
		);

		$scopePaths[$scope] ??= [];
		$scopePaths[$scope][$route->url] ??= [];

		if (!array_key_exists($route->url, $openapi["paths"])) {
			$openapi["paths"][$route->url] = [];
		}

		$verb = strtolower($route->verb);
		if (array_key_exists($verb, $scopePaths[$scope][$route->url])) {
			Logger::error($route->name, "Operation '" . $route->verb . "' already set for path '" . $route->url . "'");
		}

		$scopePaths[$scope][$route->url][$verb] = $operation;
	}
}

if ($appIsCore) {
	$schemas["Status"] = [
		"type" => "object",
		"required" => [
			"installed",
			"maintenance",
			"needsDbUpgrade",
			"version",
			"versionstring",
			"edition",
			"productname",
			"extendedSupport"
		],
		"properties" => [
			"installed" => [
				"type" => "boolean",
			],
			"maintenance" => [
				"type" => "boolean",
			],
			"needsDbUpgrade" => [
				"type" => "boolean",
			],
			"version" => [
				"type" => "string",
			],
			"versionstring" => [
				"type" => "string",
			],
			"edition" => [
				"type" => "string",
			],
			"productname" => [
				"type" => "string",
			],
			"extendedSupport" => [
				"type" => "boolean",
			],
		],
	];
	$scopePaths['default']['/status.php'] = [
		"get" => [
			"operationId" => "get-status",
			"responses" => [
				200 => [
					"description" => "Status returned",
					"content" => [
						"application/json" => [
							"schema" => [
								"\$ref" => "#/components/schemas/Status"
							],
						],
					],
				],
			],
		],
	];
}

if (count($schemas) == 0 && count($routes) == 0) {
	Logger::error("app", "No spec generated");
}

ksort($schemas);

if ($useTags) {
	$openapi["tags"] = $tags;
}

$hasSingleScope = count($scopePaths) <= 1;
$fullScopePathArrays = [];

if (!$hasSingleScope) {
	$scopePaths['full'] = [];
} elseif (count($scopePaths) === 0) {
	if (isset($schemas['Capabilities']) || isset($schemas['PublicCapabilities'])) {
		Logger::info('app', 'Generating default scope without routes to populate capabilities');
		$scopePaths['default'] = [];
	} else {
		Logger::panic('app', 'No routes or capabilities defined');
	}
}

foreach ($scopePaths as $scope => $paths) {
	$openapiScope = $openapi;

	$scopeSuffix = ($hasSingleScope || $scope === 'default') ? '' : '-' . $scope;
	$openapiScope['info']['title'] .= $scopeSuffix;
	$openapiScope['paths'] = $paths;

	if ($scope !== 'full' && !$hasSingleScope) {
		$fullScopePathArrays[] = $paths;
	}

	if ($scope === 'full') {
		$openapiScope['paths'] = array_merge(...$fullScopePathArrays);
		$openapiScope['components']['schemas'] = $schemas;
	} else {
		$usedSchemas = [];
		foreach ($paths as $url => $urlRoutes) {
			foreach ($urlRoutes as $httpMethod => $routeData) {
				foreach ($routeData['responses'] as $statusCode => $responseData) {
					if (empty($responseData['content'])) {
						continue;
					}

					foreach ($responseData['content'] as $contentType => $contentData) {
						if (isset($contentData['schema']) && is_array($contentData['schema'])) {
							$newSchemas = Helpers::collectUsedRefs($contentData['schema']);
							$usedSchemas = array_merge($usedSchemas, $newSchemas);
						}
					}
				}
			}
		}

		$scopedSchemas = [];
		while ($usedSchema = array_shift($usedSchemas)) {
			if (!str_starts_with($usedSchema, '#/components/schemas/')) {
				continue;
			}

			$schemaName = substr($usedSchema, strlen('#/components/schemas/'));

			if (!isset($schemas[$schemaName])) {
				Logger::error("app", "Schema $schemaName used by scope $scope is not defined");
			}

			$newRefs = Helpers::collectUsedRefs($schemas[$schemaName]);
			foreach ($newRefs as $newRef) {
				if (!isset($scopedSchemas[substr($newRef, strlen('#/components/schemas/'))])) {
					$usedSchemas[] = $newRef;
				}
			}

			$scopedSchemas[$schemaName] = $schemas[$schemaName];
		}

		if (isset($schemas['Capabilities'])) {
			$scopedSchemas['Capabilities'] = $schemas['Capabilities'];
		}
		if (isset($schemas['PublicCapabilities'])) {
			$scopedSchemas['PublicCapabilities'] = $schemas['PublicCapabilities'];
		}

		if (count($scopedSchemas) === 0) {
			$scopedSchemas = new \stdClass();
		} else {
			ksort($scopedSchemas);
		}

		$openapiScope['components']['schemas'] = $scopedSchemas;
	}

	$pathsCount = count($openapiScope['paths']);
	if ($pathsCount === 0) {
		// Make sure the paths array is always a dictionary
		$openapiScope['paths'] = new \stdClass();
	}

	$startExtension = strrpos($out, '.');
	if ($startExtension !== false) {
		// Path + filename (without extension)
		$path = substr($out, 0, $startExtension);
		// Extension
		$extension = substr($out, $startExtension);
		$scopeOut = $path . $scopeSuffix . $extension;
	} else {
		$scopeOut = $out . $scopeSuffix;
	}

	file_put_contents($scopeOut, json_encode($openapiScope, Helpers::jsonFlags()));

	Logger::info('app', 'Generated scope ' . $scope . ' with ' . $pathsCount . ' routes!');
}
