<?php

declare(strict_types=1);

/**
 * This file is part of phpDocumentor.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @link https://phpdoc.org
 */

namespace phpDocumentor\Transformer\Template;

use DirectoryIterator;
use InvalidArgumentException;
use phpDocumentor\Configuration\TemplateDefinition;
use phpDocumentor\FileSystem\Dsn;
use phpDocumentor\FileSystem\FileSystem;
use phpDocumentor\FileSystem\FlySystemFactory;
use phpDocumentor\FileSystem\MountManager;
use phpDocumentor\FileSystem\Path;
use phpDocumentor\Transformer\Template;
use phpDocumentor\Transformer\Transformation;
use phpDocumentor\Transformer\Writer\Collection as WriterCollection;
use RecursiveDirectoryIterator;
use RuntimeException;
use SimpleXMLElement;
use Symfony\Component\Stopwatch\Stopwatch;
use Webmozart\Assert\Assert;

use function array_merge;
use function file_exists;
use function in_array;
use function is_readable;
use function rtrim;

use const DIRECTORY_SEPARATOR;

class Factory
{
    final public const TEMPLATE_DEFINITION_FILENAME = 'template.xml';

    /**
     * Constructs a new template factory with its dependencies.
     */
    public function __construct(
        private readonly WriterCollection $writerCollection,
        private readonly FlySystemFactory $flySystemFactory,
        private readonly string $globalTemplatesPath,
    ) {
    }

    /**
     * Attempts to find, construct and return a template object with the given template name or (relative/absolute)
     * path.
     *
     * @param list<TemplateDefinition> $templates
     */
    public function getTemplates(array $templates): Collection
    {
        $stopWatch = new Stopwatch();
        $loadedTemplates = [];

        foreach ($templates as $template) {
            $stopWatch->start('load template');

            $templateNameOrLocation = $template->location instanceof Path
                ? ($template->location . '/' . $template->name)
                : $template->name;

            $loadedTemplates[$template->name] = $this->loadTemplate(
                $templateNameOrLocation,
                $template->parameters,
            );
            $stopWatch->stop('load template');
        }

        return new Collection($loadedTemplates);
    }

    /** @param array<string, string> $parameters */
    private function loadTemplate(string $template, array $parameters): Template
    {
        $template = $this->createTemplateFromXml($template, $parameters);

        if ($template->getExtends() !== null) {
            $parentTemplate = $this->loadTemplate($template->getExtends(), $parameters);
            $template->merge($parentTemplate);
        }

        $template->propagateParameters();

        /** @var Transformation $transformation */
        foreach ($template as $transformation) {
            $writer = $this->writerCollection->get($transformation->getWriter());
            $writer->checkRequirements();
        }

        return $template;
    }

    /**
     * Returns a list of all template names.
     *
     * @return string[]
     */
    public function getAllNames(): array
    {
        /** @var RecursiveDirectoryIterator $files */
        $files = new DirectoryIterator($this->getTemplatesPath());

        $templateNames = [];
        while ($files->valid()) {
            $name = $files->getBasename();

            // skip abstract files
            if (! $files->isDir() || in_array($name, ['.', '..'], true)) {
                $files->next();
                continue;
            }

            $templateNames[] = $name;
            $files->next();
        }

        return $templateNames;
    }

    /**
     * Returns the path where all templates are stored.
     */
    public function getTemplatesPath(): string
    {
        return $this->globalTemplatesPath;
    }

    /**
     * Creates and returns a template object based on the provided template definition.
     *
     * @param array<string, string> $templateParams
     */
    private function createTemplateFromXml(
        string $nameOrPath,
        array $templateParams,
    ): Template {
        // create the filesystems that a template needs to be able to manipulate, the source folder containing this
        // template its files; the destination to where it can write its files and a global templates folder where to
        // get global template files from

        $templateFileSystem = $this->resolve($nameOrPath);

        $xml = $templateFileSystem->read(self::TEMPLATE_DEFINITION_FILENAME);
        Assert::string($xml);

        $xml = new SimpleXMLElement($xml);
        $template = new Template(
            (string) $xml->name,
            new MountManager(
                [
                    'templates' => $this->getTemplatesDirectory(),
                    'template' => $templateFileSystem,
                ],
                'template',
            ),
        );
        $template->setAuthor((string) $xml->author . ((string) $xml->email ? ' <' . $xml->email . '>' : ''));
        $template->setVersion((string) $xml->version);
        $template->setCopyright((string) $xml->copyright);
        $template->setDescription((string) $xml->description);
        $template->setExtends(isset($xml->extends) ? (string) $xml->extends : null);

        if ($xml->parameters) {
            foreach ($xml->parameters->children() as $parameter) {
                $parameterObject = new Parameter((string) $parameter->attributes()->key, (string) $parameter);
                $template->setParameter($parameterObject->key(), $parameterObject);
            }
        }

        foreach ($templateParams as $key => $value) {
            $parameterObject = new Parameter($key, $value);
            $template->setParameter($parameterObject->key(), $parameterObject);
        }

        $i = 0;
        foreach ($xml->transformations->transformation as $transformation) {
            $transformationObject = new Transformation(
                $template,
                (string) $transformation->attributes()->query,
                (string) $transformation->attributes()->writer,
                (string) $transformation->attributes()->source,
                (string) $transformation->attributes()->artifact,
            );
            $parameters = [];
            foreach ($transformation->parameter as $parameter) {
                $parameterObject = new Parameter((string) $parameter->attributes()->key, (string) $parameter);
                $parameters[$parameterObject->key()] = $parameterObject;
            }

            $transformationObject->setParameters(array_merge($parameters, $template->getParameters()));

            $template[$i++] = $transformationObject;
        }

        return $template;
    }

    private function resolve(string $nameOrPath): FileSystem
    {
        $configPath = rtrim($nameOrPath, DIRECTORY_SEPARATOR) .
            DIRECTORY_SEPARATOR . self::TEMPLATE_DEFINITION_FILENAME;
        if (file_exists($configPath) && is_readable($configPath)) {
            return $this->flySystemFactory->create(
                Dsn::createFromString(rtrim($nameOrPath, DIRECTORY_SEPARATOR)),
            );
        }

        // if we load a global template
        $globalTemplatesFilesystem = $this->getTemplatesDirectory();
        if ($globalTemplatesFilesystem->has($nameOrPath)) {
            $templateFilesystem = $this->createNewFilesystemFromSubfolder($nameOrPath);

            if (! $templateFilesystem->has(self::TEMPLATE_DEFINITION_FILENAME)) {
                throw new TemplateNotFound($nameOrPath);
            }

            return $templateFilesystem;
        }

        throw new TemplateNotFound($nameOrPath);
    }

    private function getTemplatesDirectory(): Filesystem
    {
        $dsnString = $this->getTemplatesPath();
        try {
            $filesystem = $this->flySystemFactory->create(Dsn::createFromString($dsnString));
        } catch (InvalidArgumentException) {
            throw new RuntimeException(
                'Unable to access the folder with the global templates, received DSN is: ' . $dsnString,
            );
        }

        return $filesystem;
    }

    private function createNewFilesystemFromSubfolder(
        string $subfolder,
    ): Filesystem {
        $dsnString = $this->getTemplatesPath();
        $globalDsn = Dsn::createFromString($dsnString);
        $template = $globalDsn->withPath(new Path($globalDsn->getPath() . '/' . $subfolder));

        return $this->flySystemFactory->create($template);
    }
}
