<?php

declare(strict_types=1);

namespace Gls\GlsPoland\PrestaShop\Installer;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\DBAL\Schema\Comparator;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Gls\GlsPoland\Configuration\ConfigurationInterface;
use Gls\GlsPoland\Translation\TranslatorAwareTrait;

final class DatabaseInstaller implements InstallerInterface, UninstallerInterface
{
    use TranslatorAwareTrait;

    private const SCHEMA_VERSION_CONFIG_KEY = 'GLS_POLAND_DB_SCHEMA_VERSION';

    private $connection;
    private $migrations;
    private $configuration;

    private $schema;
    private $comparator;

    /**
     * @param iterable<DatabaseMigrationInterface> $migrations
     */
    public function __construct(Connection $connection, iterable $migrations, ConfigurationInterface $configuration)
    {
        $this->connection = $connection;
        $this->migrations = $migrations;
        $this->configuration = $configuration;
    }

    public function install(\Module $module): void
    {
        $currentVersion = $this->configuration->get(self::SCHEMA_VERSION_CONFIG_KEY) ?? '0';
        $downMigrations = [];

        foreach ($this->getSortedMigrations() as $migration) {
            if (\Tools::version_compare($currentVersion, $version = $migration->getVersion(), '>=')) {
                continue;
            }

            if (\Tools::version_compare($module->version, $version)) {
                $downMigrations[] = $migration;
            } else {
                $this->migrateUp($migration);
            }
        }

        $this->migrateDown(...$downMigrations);
    }

    public function uninstall(\Module $module, bool $isReset): void
    {
        if ($isReset) {
            return;
        }

        $this->migrateDown(...$this->getSortedMigrations());
    }

    private function getSortedMigrations(): array
    {
        $migrations = $this->migrations;
        if ($migrations instanceof \Traversable) {
            $migrations = iterator_to_array($migrations);
        }

        usort($migrations, static function (DatabaseMigrationInterface $m1, DatabaseMigrationInterface $m2): int {
            return (int) version_compare($m1->getVersion(), $m2->getVersion());
        });

        return $migrations;
    }

    private function migrateUp(DatabaseMigrationInterface $migration): void
    {
        $this->migrate($migration, 'up', $migration->getVersion());
    }

    private function migrateDown(DatabaseMigrationInterface ...$migrations): void
    {
        while (null !== $migration = array_pop($migrations)) {
            $previous = end($migrations);
            $this->migrate($migration, 'down', $previous ? $previous->getVersion() : null);
        }
    }

    private function migrate(DatabaseMigrationInterface $migration, string $direction, ?string $version): void
    {
        $fromSchema = $this->getSchema();
        $toSchema = clone $fromSchema;

        try {
            $migration->$direction($toSchema);

            foreach ($this->getMigrationSQL($fromSchema, $toSchema) as $sql) {
                if (is_callable([$this->connection, 'executeStatement'])) {
                    $this->connection->executeStatement($sql);
                } else {
                    $this->connection->exec($sql);
                }
            }

            $this->updateSchemaVersion($version);
            $this->schema = $toSchema;
        } catch (\Exception $e) {
            $message = 'up' === $direction
                ? $this->getTranslator()->trans('Could not update database schema (to version #version#).', ['#version#' => $migration->getVersion()], 'Modules.Glspoland.Installer')
                : $this->getTranslator()->trans('Could not update database schema (down from version #version#).', ['#version#' => $migration->getVersion()], 'Modules.Glspoland.Installer');

            throw new InstallerException($message, 0, $e);
        }
    }

    private function updateSchemaVersion(?string $version): void
    {
        $this->configuration->set(self::SCHEMA_VERSION_CONFIG_KEY, $version);
    }

    private function getSchema(): Schema
    {
        return $this->schema ?? ($this->schema = $this->createSchema());
    }

    /**
     * @see AbstractSchemaManager::listTableColumns() might fail if no mapping to a DBAL type is configured for a colum type.
     * To deal with such cases, if an exception occurs, we try to limit schema information to only tables essential for out migrations.
     */
    private function createSchema(): Schema
    {
        if (!class_exists(Exception::class)) {
            class_alias(DBALException::class, Exception::class);
        }

        $schemaManager = $this->connection->getSchemaManager();

        try {
            return $schemaManager->createSchema();
        } catch (Exception $e) {
            $tableNames = array_filter($schemaManager->listTableNames(), static function (string $name): bool {
                return str_contains($name, 'gls_poland_');
            });

            $tables = array_map(static function (string $name) use ($schemaManager): Table {
                return $schemaManager->listTableDetails($name);
            }, $tableNames);

            return new Schema($tables, [], $schemaManager->createSchemaConfig());
        }
    }

    /**
     * @return string[]
     */
    private function getMigrationSQL(Schema $fromSchema, Schema $toSchema): array
    {
        if (!isset($this->comparator)) {
            $this->comparator = new Comparator();
        }

        return $this->comparator
            ->compare($fromSchema, $toSchema)
            ->toSql($this->connection->getDatabasePlatform());
    }
}
