<?php

/**
 * MIT License. This file is part of the Propel package.
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace Propel\Generator\Util;

use Exception;
use PDO;
use Propel\Generator\Builder\Om\TableMapBuilder;
use Propel\Generator\Builder\Util\SchemaReader;
use Propel\Generator\Config\GeneratorConfigInterface;
use Propel\Generator\Config\QuickGeneratorConfig;
use Propel\Generator\Exception\BuildException;
use Propel\Generator\Model\Database;
use Propel\Generator\Model\Diff\DatabaseComparator;
use Propel\Generator\Model\Table;
use Propel\Generator\Platform\PlatformInterface;
use Propel\Generator\Platform\SqlitePlatform;
use Propel\Generator\Reverse\SchemaParserInterface;
use Propel\Runtime\Adapter\AdapterInterface;
use Propel\Runtime\Adapter\Pdo\SqliteAdapter;
use Propel\Runtime\Connection\ConnectionInterface;
use Propel\Runtime\Connection\ConnectionWrapper;
use Propel\Runtime\Connection\PdoConnection;
use Propel\Runtime\Connection\StatementInterface;
use Propel\Runtime\Propel;
use RuntimeException;

class QuickBuilder
{
    use VfsTrait;

    /**
     * Used to globally disable building to Vfs
     *
     * @var bool
     */
    public static bool $disableVfs = false;

    /**
     * The Xml.
     *
     * @var string
     */
    protected $schema = '';

    /**
     * The Database Schema.
     *
     * @var string
     */
    protected $schemaName = '';

    /**
     * @var \Propel\Generator\Platform\PlatformInterface|null
     */
    protected $platform;

    /**
     * @var \Propel\Generator\Config\GeneratorConfigInterface|null
     */
    protected $config;

    /**
     * @var \Propel\Generator\Model\Database|null
     */
    protected $database;

    /**
     * @var \Propel\Generator\Reverse\SchemaParserInterface|null
     */
    protected $parser;

    /**
     * @var array
     */
    protected $classTargets = ['tablemap', 'object', 'objectstub', 'collection', 'query', 'querystub'];

    /**
     * Identifier quoting for reversed database.
     *
     * @var bool
     */
    protected $identifierQuoting = false;

    /**
     * If use the virtual or physical filesystem.
     * Default to virtual.
     *
     * @var bool
     */
    protected $vfs = true;

    /**
     * @param string $schema
     *
     * @return void
     */
    public function setSchema(string $schema): void
    {
        $this->schema = $schema;
    }

    /**
     * @return string
     */
    public function getSchema(): string
    {
        return $this->schema;
    }

    /**
     * @param string $schemaName
     *
     * @return void
     */
    public function setSchemaName(string $schemaName): void
    {
        $this->schemaName = $schemaName;
    }

    /**
     * @return string
     */
    public function getSchemaName(): string
    {
        return $this->schemaName;
    }

    /**
     * @param \Propel\Generator\Reverse\SchemaParserInterface $parser
     *
     * @return void
     */
    public function setParser(SchemaParserInterface $parser): void
    {
        $this->parser = $parser;
    }

    /**
     * @return \Propel\Generator\Reverse\SchemaParserInterface|null
     */
    public function getParser(): ?SchemaParserInterface
    {
        return $this->parser;
    }

    /**
     * Setter for the platform property
     *
     * @param \Propel\Generator\Platform\PlatformInterface $platform
     *
     * @return void
     */
    public function setPlatform(PlatformInterface $platform): void
    {
        $this->platform = $platform;
    }

    /**
     * Getter for the platform property
     *
     * @return \Propel\Generator\Platform\PlatformInterface
     */
    public function getPlatform(): PlatformInterface
    {
        if ($this->platform === null) {
            $this->platform = new SqlitePlatform();
        }

        $this->platform->setIdentifierQuoting($this->identifierQuoting);

        return $this->platform;
    }

    /**
     * Setter for the config property
     *
     * @param \Propel\Generator\Config\GeneratorConfigInterface $config
     *
     * @return void
     */
    public function setConfig(GeneratorConfigInterface $config): void
    {
        $this->config = $config;
    }

    /**
     * Getter for the config property
     *
     * @return \Propel\Generator\Config\GeneratorConfigInterface
     */
    public function getConfig(): GeneratorConfigInterface
    {
        if ($this->config === null) {
            $this->config = new QuickGeneratorConfig();
        }

        return $this->config;
    }

    /**
     * @return bool
     */
    public function isVfs(): bool
    {
        return !static::$disableVfs && $this->vfs;
    }

    /**
     * @param bool $vfs
     *
     * @return void
     */
    public function setVfs(bool $vfs): void
    {
        $this->vfs = $vfs;
    }

    /**
     * @param string $schema
     * @param string|null $dsn
     * @param string|null $user
     * @param string|null $pass
     * @param \Propel\Runtime\Adapter\AdapterInterface|null $adapter
     * @param bool $vfs
     *
     * @return \Propel\Runtime\Connection\ConnectionWrapper
     */
    public static function buildSchema(
        string $schema,
        ?string $dsn = null,
        ?string $user = null,
        ?string $pass = null,
        ?AdapterInterface $adapter = null,
        bool $vfs = true
    ): ConnectionWrapper {
        $builder = new self();
        $builder->setSchema($schema);
        $builder->setVfs($vfs);

        return $builder->build($dsn, $user, $pass, $adapter);
    }

    /**
     * Create Database object from schema.
     * Does not create classes or DB tables.
     *
     * @param string $schema
     *
     * @return \Propel\Generator\Model\Database|null
     */
    public static function parseSchema(string $schema): ?Database
    {
        $builder = new self();
        $builder->setSchema($schema);

        return $builder->getDatabase();
    }

    /**
     * @param string|null $dsn
     * @param string|null $user
     * @param string|null $pass
     * @param \Propel\Runtime\Adapter\AdapterInterface|null $adapter
     * @param array|null $classTargets
     *
     * @return \Propel\Runtime\Connection\ConnectionWrapper
     */
    public function build(
        ?string $dsn = null,
        ?string $user = null,
        ?string $pass = null,
        ?AdapterInterface $adapter = null,
        ?array $classTargets = null
    ): ConnectionWrapper {
        $dsn = $dsn ?? 'sqlite::memory:';
        $adapter = $adapter ?? new SqliteAdapter();
        $classTargets = $classTargets ?? $this->classTargets;

        $pdo = new PdoConnection($dsn, $user, $pass);
        $con = new ConnectionWrapper($pdo);
        $con->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
        /** @phpstan-var \Propel\Runtime\Adapter\Pdo\SqliteAdapter $adapter */
        $adapter->initConnection($con, []);
        $this->buildSQL($con);
        $this->buildClasses($classTargets);
        $name = (string)$this->getDatabase()->getName();
        Propel::getServiceContainer()->setAdapter($name, $adapter);
        Propel::getServiceContainer()->setConnection($name, $con);

        return $con;
    }

    /**
     * @return \Propel\Generator\Model\Database|null
     */
    public function getDatabase(): ?Database
    {
        if ($this->database === null) {
            $xtad = new SchemaReader($this->getPlatform());
            $xtad->setGeneratorConfig($this->getConfig());
            $appData = $xtad->parseString($this->schema);
            $this->database = $appData->getDatabase(); // does final initialization
        }

        return $this->database;
    }

    /**
     * @param \Propel\Runtime\Connection\ConnectionInterface $con
     *
     * @throws \Exception
     *
     * @return int The number of statements executed
     */
    public function buildSQL(ConnectionInterface $con): int
    {
        $sql = $this->getSQL();
        $statements = SqlParser::parseString($sql);
        foreach ($statements as $statement) {
            if (strpos($statement, 'DROP') === 0) {
                // drop statements cause errors since the table doesn't exist
                continue;
            }
            try {
                $stmt = $con->prepare($statement);
                if ($stmt instanceof StatementInterface) {
                    // only execute if has no error
                    $stmt->execute();
                }
            } catch (Exception $e) {
                throw new Exception('SQL failed: ' . $statement, 0, $e);
            }
        }

        return count($statements);
    }

    /**
     * @param \Propel\Runtime\Connection\ConnectionInterface $con
     *
     * @throws \Propel\Generator\Exception\BuildException
     * @throws \RuntimeException
     *
     * @return \Propel\Generator\Model\Database|null
     */
    public function updateDB(ConnectionInterface $con): ?Database
    {
        $database = $this->readConnectedDatabase();
        $diff = DatabaseComparator::computeDiff($database, $this->database);

        if ($diff === false) {
            return null;
        }
        /** @var \Propel\Generator\Platform\DefaultPlatform $platform */
        $platform = $this->database->getPlatform();
        $sql = $platform->getModifyDatabaseDDL($diff);

        $statements = SqlParser::parseString($sql);
        foreach ($statements as $statement) {
            try {
                $stmt = $con->prepare($statement);

                if ($stmt === false) {
                    throw new RuntimeException('PdoConnection::prepare() failed and did not return statement object for execution.');
                }

                $stmt->execute();
            } catch (Exception $e) {
                //echo $sql; //uncomment for better debugging
                throw new BuildException(sprintf(
                    "Cannot execute SQL: \n%s\nFrom database: \n%s\n\nTo database: \n%s\n\nDiff:\n%s",
                    $statement,
                    $this->database,
                    $database,
                    $diff,
                ), $e->getCode(), $e);
            }
        }

        return $database;
    }

    /**
     * @return \Propel\Generator\Model\Database
     */
    public function readConnectedDatabase(): Database
    {
        $this->getDatabase();
        $database = new Database();
        $database->setSchema($this->database->getSchema());
        $database->setName($this->database->getName());
        $database->setPlatform($this->getPlatform());
        $this->getParser()->parse($database);

        return $database;
    }

    /**
     * @return string
     */
    public function getSQL(): string
    {
        /** @var \Propel\Generator\Platform\DefaultPlatform $platform */
        $platform = $this->getPlatform();

        return $platform->getAddTablesDDL($this->getDatabase());
    }

    /**
     * @param array|null $classTargets
     *
     * @return string
     */
    public function getBuildName(?array $classTargets = null): string
    {
        $tables = [];
        foreach ($this->getDatabase()->getTables() as $table) {
            if (count($tables) > 3) {
                break;
            }
            $tables[] = $table->getName();
        }
        $name = implode('_', $tables);
        if (!$classTargets || count($classTargets) === 5) {
            $name .= '-all';
        } else {
            $name .= '-' . implode('_', $classTargets);
        }

        return $name;
    }

    /**
     * Build the classes files and include them.
     *
     * When generated to virtual filesystem, the classes reside in a unique file. When they're are built to
     * physical filesystem, which is supposed to be for debugging purpose, the classes reside on separate file,
     * for easier debug.
     *
     * @param array<string>|null $classTargets array('tablemap', 'object', 'query', 'objectstub', 'querystub')
     *
     * @return void
     */
    public function buildClasses(?array $classTargets = null): void
    {
        $classes = $classTargets ?? ['tablemap', 'object', 'objectstub', 'collection', 'query', 'querystub'];
        $tables = $this->getDatabase()->getTables();

        $includes = $this->isVfs()
            ? $this->buildClassesToVirtual($classes, $tables)
            : $this->buildClassesToPhysical($classes, $tables);

        foreach ($includes as $tempFile) {
            include $tempFile;
        }

        if (in_array('tablemap', $classes, true)) {
            $this->registerTableMaps();
        }
    }

    /**
     * @param array<string>|null $classTargets
     *
     * @return string
     */
    public function getClasses(?array $classTargets = null): string
    {
        $script = '';
        foreach ($this->getDatabase()->getTables() as $table) {
            $script .= $this->getClassesForTable($table, $classTargets);
        }

        return $script;
    }

    /**
     * @param \Propel\Generator\Model\Table $table
     * @param array<string>|null $classTargets
     *
     * @return string
     */
    public function getClassesForTable(Table $table, ?array $classTargets = null): string
    {
        $classTargets = $classTargets ?? $this->classTargets;
        $script = '';

        foreach ($classTargets as $target) {
            /** @var \Propel\Generator\Builder\Om\AbstractOMBuilder $abstractBuilder */
            $abstractBuilder = $this->getConfig()->getConfiguredBuilder($table, $target);
            $class = $abstractBuilder->build();
            $script .= $this->fixNamespaceDeclarations($class);
            $script .= $this->buildInheritanceClassesCode($target, $table);

            if ($target === 'object' && $table->getInterface()) {
                $interface = $this->getConfig()->getConfiguredBuilder($table, 'interface')->build();
                $script .= $this->fixNamespaceDeclarations($interface);
            }
        }

        if ($table->hasAdditionalBuilders()) { // will be built multiple times if this method is called for individual targets (buildToPhysical())
            foreach ($table->getAdditionalBuilders() as $builderClass) {
                $builder = new $builderClass($table);
                $class = $builder->build();
                $script .= $this->fixNamespaceDeclarations($class);
            }
        }

        $script = str_replace('<?php', '', $script);

        return $script;
    }

    /**
     * Build code for classes defined via inheritance in schema.xml.
     *
     * @param string $target
     * @param \Propel\Generator\Model\Table $table
     *
     * @return string
     */
    protected function buildInheritanceClassesCode(string $target, Table $table): string
    {
        $childTarget = match ($target) {
            'query' => 'queryinheritance',
            'objectstub' => 'objectmultiextend',
            'querystub' => 'queryinheritancestub',
            default => null
        };
        $column = $table->getChildrenColumn();
        if (!$childTarget || !$column?->isEnumeratedClasses()) {
            return '';
        }

        $code = '';
        foreach ($column->getChildren() as $child) {
            if (!$child->getAncestor()) {
                continue;
            }

            /** @var \Propel\Generator\Builder\Om\AbstractOMBuilder&\Propel\Generator\Builder\Om\ExtensionBuilderInterface $builder */
            $builder = $this->getConfig()->getConfiguredBuilder($table, $childTarget);
            $builder->setChild($child);
            $class = $builder->build();
            $code .= $this->fixNamespaceDeclarations($class);
        }

        return $code;
    }

    /**
     * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/ClassLoader/ClassCollectionLoader.php
     *
     * @param string $source
     *
     * @return string
     */
    public function fixNamespaceDeclarations(string $source): string
    {
        $cooperativeLexems = [T_WHITESPACE, T_NS_SEPARATOR, T_STRING, T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED];

        $source = $this->forceNamespace($source);

        if (!function_exists('token_get_all')) {
            return $source;
        }

        $output = '';
        $inNamespace = false;
        $tokens = token_get_all($source);

        for ($i = 0, $max = count($tokens); $i < $max; $i++) {
            $token = $tokens[$i];
            if (is_string($token)) {
                $output .= $token;
            } elseif (in_array($token[0], [T_COMMENT, T_DOC_COMMENT])) {
                // strip comments
                $output .= $token[1];
            } elseif ($token[0] === T_NAMESPACE) {
                if ($inNamespace) {
                    $output .= "}\n";
                }
                $output .= $token[1];

                // namespace name and whitespaces
                while (($t = $tokens[++$i]) && is_array($t) && in_array($t[0], $cooperativeLexems)) {
                    $output .= $t[1];
                }
                if ($t === '{') {
                    $inNamespace = false;
                    --$i;
                } else {
                    $output .= "\n{";
                    $inNamespace = true;
                }
            } else {
                $output .= $token[1];
            }
        }

        if ($inNamespace) {
            $output .= "}\n";
        }

        return $output;
    }

    /**
     * Prevent generated class without namespace to fail.
     *
     * @param string $code
     *
     * @return string
     */
    protected function forceNamespace(string $code): string
    {
        if (preg_match('/\nnamespace/', $code) === 0) {
            $lines = explode(PHP_EOL, $code);
            $isUseStatement = fn (string $s) => str_starts_with($s, 'use ') && !str_contains($s, '\\');
            $useStatements = array_filter($lines, $isUseStatement);

            $code = str_replace($useStatements, '', $code);

            return "\nnamespace\n{\n" . $code . "\n}\n";
        }

        return $code;
    }

    /**
     * @return bool
     */
    public function isIdentifierQuotingEnabled(): bool
    {
        return $this->identifierQuoting;
    }

    /**
     * @param bool $identifierQuoting
     *
     * @return void
     */
    public function setIdentifierQuoting(bool $identifierQuoting): void
    {
        $this->identifierQuoting = $identifierQuoting;
    }

    /**
     * Create separate classes to write to physical filesystem.
     *
     * @param array<string> $classes
     * @param array<\Propel\Generator\Model\Table> $tables Array of Table objects
     *
     * @return array<string> The files to include
     */
    private function buildClassesToPhysical(array $classes, array $tables): array
    {
        $includes = [];
        $hashFromCwd = substr(sha1((string)getcwd()), 0, 10);
        $dirName = sys_get_temp_dir() . '/propelQuickBuild-' . Propel::VERSION . '-' . $hashFromCwd . '/';
        if (!is_dir($dirName)) {
            mkdir($dirName);
        }
        foreach ($tables as $table) {
            foreach ($classes as $class) {
                $code = $this->getClassesForTable($table, [$class]);
                $tempFile = $dirName . str_replace('\\', '-', $table->getPhpName()) . "-$class.php";
                file_put_contents($tempFile, "<?php\n" . $code);
                $includes[] = $tempFile;
            }
        }

        return $includes;
    }

    /**
     * Create an all-classes file to write to virtual filesystem.
     *
     * @param array<string> $classes
     * @param array<\Propel\Generator\Model\Table> $tables Array of Table objects
     *
     * @return array<string> The one element array, containing the file to include
     */
    private function buildClassesToVirtual(array $classes, array $tables): array
    {
        $allCode = '';
        $allCodeName = [];
        $includes = [];

        foreach ($tables as $table) {
            if (count($allCodeName) < 5) {
                $allCodeName[] = $table->getPhpName();
            }
            $allCode .= $this->getClassesForTable($table, $classes);
        }

        $tempFile = $this->newFile('propelQuickBuild/' . implode('_', $allCodeName) . '.php');
        file_put_contents($tempFile->url(), "<?php\n" . $allCode);
        $includes[] = $tempFile->url();

        return $includes;
    }

    /**
     * @return void
     */
    protected function registerTableMaps(): void
    {
        $serviceContainer = Propel::getServiceContainer();
        $serviceContainer->initDatabaseMaps();
        $db = $this->getDatabase();
        $dbName = $db->getName();
        $dbMap = $serviceContainer->getDatabaseMap($dbName);
        $builder = new TableMapBuilder(new Table(''));
        $builder->setGeneratorConfig($this->config);
        foreach ($db->getTables() as $table) {
            $builder->setTable($table);
            /** @phpstan-var class-string<\Propel\Runtime\Map\TableMap> $mapClass */
            $mapClass = $builder->getFullyQualifiedClassName();
            $dbMap->addTableFromMapClass($mapClass);
        }
    }
}
