<?php

// SPDX-FileCopyrightText: 2023 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Application;

use Countable;
use Generator;
use Icinga\Application\Hook\DbMigrationHook;
use Icinga\Exception\NotFoundError;
use Icinga\Module\Setup\Utils\DbTool;
use Icinga\Module\Setup\WebWizard;
use ipl\I18n\Translation;
use ipl\Sql;
use ReflectionClass;

/**
 * Migration manager allows you to manage all pending migrations in a structured way.
 */
final class MigrationManager implements Countable
{
    use Translation;

    /** @var array<string, DbMigrationHook> All pending migration hooks */
    protected $pendingMigrations;

    /** @var MigrationManager */
    private static $instance;

    private function __construct()
    {
    }

    /**
     * Get the instance of this manager
     *
     * @return $this
     */
    public static function instance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    /**
     * Get all pending migrations
     *
     * @return array<string, DbMigrationHook>
     */
    public function getPendingMigrations(): array
    {
        if ($this->pendingMigrations === null) {
            $this->load();
        }

        return $this->pendingMigrations;
    }

    /**
     * Get whether there are any pending migrations
     *
     * @return bool
     */
    public function hasPendingMigrations(): bool
    {
        return $this->count() > 0;
    }

    public function hasMigrations(string $module): bool
    {
        if (! $this->hasPendingMigrations()) {
            return false;
        }

        return isset($this->getPendingMigrations()[$module]);
    }

    /**
     * Get pending migration matching the given module name
     *
     * @param string $module
     *
     * @return DbMigrationHook
     *
     * @throws NotFoundError When there are no pending migrations matching the given module name
     */
    public function getMigration(string $module): DbMigrationHook
    {
        if (! $this->hasMigrations($module)) {
            throw new NotFoundError('There are no pending migrations matching the given name: %s', $module);
        }

        return $this->getPendingMigrations()[$module];
    }

    /**
     * Get the number of all pending migrations
     *
     * @return int
     */
    public function count(): int
    {
        return count($this->getPendingMigrations());
    }

    /**
     * Apply all pending migrations matching the given migration module name
     *
     * @param string $module
     *
     * @return bool
     */
    public function applyByName(string $module): bool
    {
        $migration = $this->getMigration($module);
        if ($migration->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
            return false;
        }

        return $this->apply($migration);
    }

    /**
     * Apply the given migration hook
     *
     * @param DbMigrationHook $hook
     * @param ?array<string, string> $elevateConfig
     *
     * @return bool
     */
    public function apply(DbMigrationHook $hook, ?array $elevateConfig = null): bool
    {
        if ($hook->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
            Logger::error(
                'Please apply the Icinga Web pending migration(s) first or apply all the migrations instead'
            );

            return false;
        }

        $conn = $hook->getDb();
        if ($elevateConfig && ! $this->checkRequiredPrivileges($conn)) {
            $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
        }

        if ($hook->run($conn)) {
            unset($this->pendingMigrations[$hook->getModuleName()]);

            Logger::info('Applied pending %s migrations successfully', $hook->getName());

            return true;
        }

        return false;
    }

    /**
     * Apply all pending modules/framework migrations
     *
     * @param ?array<string, string> $elevateConfig
     *
     * @return bool
     */
    public function applyAll(?array $elevateConfig = null): bool
    {
        $default = DbMigrationHook::DEFAULT_MODULE;
        if ($this->hasMigrations($default)) {
            $migration = $this->getMigration($default);
            if (! $this->apply($migration, $elevateConfig)) {
                return false;
            }
        }

        $succeeded = true;
        foreach ($this->getPendingMigrations() as $migration) {
            if (! $this->apply($migration, $elevateConfig) && $succeeded) {
                $succeeded = false;
            }
        }

        return $succeeded;
    }

    /**
     * Yield module and framework pending migrations separately
     *
     * @param bool $modules
     *
     * @return Generator<DbMigrationHook>
     */
    public function yieldMigrations(bool $modules = false): Generator
    {
        foreach ($this->getPendingMigrations() as $migration) {
            if ($modules === $migration->isModule()) {
                yield $migration;
            }
        }
    }

    /**
     * Get the required database privileges for database migrations
     *
     * @return string[]
     */
    public function getRequiredDatabasePrivileges(): array
    {
        return ['CREATE','SELECT','INSERT','UPDATE','DELETE','DROP','ALTER','CREATE VIEW','INDEX','EXECUTE','USAGE'];
    }

    /**
     * Verify whether all database users of all pending migrations do have the required SQL privileges
     *
     * @param ?array<string, string> $elevateConfig
     * @param bool $canIssueGrant
     *
     * @return bool
     */
    public function validateDatabasePrivileges(?array $elevateConfig = null, bool $canIssueGrant = false): bool
    {
        if (! $this->hasPendingMigrations()) {
            return true;
        }

        foreach ($this->getPendingMigrations() as $migration) {
            if (! $this->checkRequiredPrivileges($migration->getDb(), $elevateConfig, $canIssueGrant)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Check if there are missing grants for the Icinga Web database and fix them
     *
     * This fixes the following problems on existing installations:
     * - Setups made by the wizard have no access to `icingaweb_schema`
     * - Setups made by the wizard have no DDL grants
     * - Setups done manually using the advanced documentation chapter have no DDL grants
     *
     * @param Sql\Connection $db
     * @param array<string, string> $elevateConfig
     */
    public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void
    {
        $wizardProperties = (new ReflectionClass(WebWizard::class))
            ->getDefaultProperties();
        /** @var array<int, string> $privileges */
        $privileges = $wizardProperties['databaseUsagePrivileges'];
        /** @var array<int, string> $tables */
        $tables = $wizardProperties['databaseTables'];

        $actualUsername = $db->getConfig()->username;
        $db = $this->elevateDatabaseConnection($db, $elevateConfig);
        $tool = $this->createDbTool($db);
        $tool->connectToDb();

        $isPgsql = $db->getAdapter() instanceof Sql\Adapter\Pgsql;
        // PgSQL doesn't have SELECT privilege on a database level and granting the CREATE,CONNECT, and TEMPORARY
        // privileges on a database doesn't permit a user to read data from a table. Hence, we have to grant the
        // required database,schema and table privileges simultaneously.
        if (! $isPgsql && $tool->checkPrivileges(['SELECT'], [], $actualUsername)) {
            // Checks only database level grants. If this succeeds, the grants were issued manually.
            if (! $tool->checkPrivileges($privileges, [], $actualUsername) && $tool->isGrantable($privileges)) {
                // Any missing grant is now granted on database level as well, not to mix things up
                $tool->grantPrivileges($privileges, [], $actualUsername);
            }
        } elseif (! $tool->checkPrivileges($privileges, $tables, $actualUsername) && $tool->isGrantable($privileges)) {
            // The above ensures that if this fails, we can safely apply table level grants, as it's
            // very likely that the existing grants were issued by the setup wizard
            $tool->grantPrivileges($privileges, $tables, $actualUsername);
        }
    }

    /**
     * Create and return a DbTool instance
     *
     * @param Sql\Connection $db
     *
     * @return DbTool
     */
    private function createDbTool(Sql\Connection $db): DbTool
    {
        $config = $db->getConfig();

        return new DbTool(array_merge([
            'db' => $config->db,
            'host' => $config->host,
            'port' => $config->port,
            'dbname' => $config->dbname,
            'username' => $config->username,
            'password' => $config->password,
            'charset'  => $config->charset
        ], $db->getAdapter()->getOptions($config)));
    }

    protected function load(): void
    {
        $this->pendingMigrations = [];

        /** @var DbMigrationHook $hook */
        foreach (Hook::all('DbMigration') as $hook) {
            if (empty($hook->getMigrations())) {
                continue;
            }

            $this->pendingMigrations[$hook->getModuleName()] = $hook;
        }

        ksort($this->pendingMigrations);
    }

    /**
     * Check the required SQL privileges of the given connection
     *
     * @param Sql\Connection $conn
     * @param ?array<string, string> $elevateConfig
     * @param bool $canIssueGrants
     *
     * @return bool
     */
    protected function checkRequiredPrivileges(
        Sql\Connection $conn,
        ?array $elevateConfig = null,
        bool $canIssueGrants = false
    ): bool {
        if ($elevateConfig) {
            $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
        }

        $wizardProperties = (new ReflectionClass(WebWizard::class))
            ->getDefaultProperties();
        /** @var array<int, string> $tables */
        $tables = $wizardProperties['databaseTables'];

        $dbTool = $this->createDbTool($conn);
        $dbTool->connectToDb();

        $isPgsql = $conn->getAdapter() instanceof Sql\Adapter\Pgsql;
        $privileges = $this->getRequiredDatabasePrivileges();
        $dbPrivilegesGranted = $dbTool->checkPrivileges($privileges);
        $tablePrivilegesGranted = $dbTool->checkPrivileges($privileges, $tables);
        if (! $dbPrivilegesGranted && ($isPgsql || ! $tablePrivilegesGranted)) {
            return false;
        }

        if ($isPgsql && ! $tablePrivilegesGranted) {
            return false;
        }

        if ($canIssueGrants && ! $dbTool->isGrantable($privileges)) {
            return false;
        }

        return true;
    }

    /**
     * Override the database config of the given connection by the specified new config
     *
     * Overrides only the username and password of existing database connection.
     *
     * @param Sql\Connection $conn
     * @param array<string, string> $elevateConfig
     * @return Sql\Connection
     */
    protected function elevateDatabaseConnection(Sql\Connection $conn, array $elevateConfig): Sql\Connection
    {
        $config = clone $conn->getConfig();
        $config->username = $elevateConfig['username'];
        $config->password = $elevateConfig['password'];

        return new Sql\Connection($config);
    }

    /**
     * Get all pending migrations as an array
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        $framework = [];
        $serialize = function (DbMigrationHook $hook): array {
            $serialized = [
                'name'             => $hook->getName(),
                'module'           => $hook->getModuleName(),
                'isModule'         => $hook->isModule(),
                'migrated_version' => $hook->getVersion(),
                'migrations'       => []
            ];

            foreach ($hook->getMigrations() as $migration) {
                $serialized['migrations'][$migration->getVersion()] = [
                    'path'  => $migration->getScriptPath(),
                    'error' => $migration->getLastState()
                ];
            }

            return $serialized;
        };

        foreach ($this->yieldMigrations() as $migration) {
            $framework[] = $serialize($migration);
        }

        $modules = [];
        foreach ($this->yieldMigrations(true) as $migration) {
            $modules[] = $serialize($migration);
        }

        return ['System'  => $framework, 'Modules' => $modules];
    }
}
