<?php

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

namespace Icinga\Application\Hook;

use Countable;
use DateTime;
use DirectoryIterator;
use Exception;
use Icinga\Application\ClassLoader;
use Icinga\Application\Hook\Common\DbMigrationStep;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Application\Modules\Module;
use Icinga\Model\Schema;
use Icinga\Web\Session;
use ipl\I18n\Translation;
use ipl\Orm\Query;
use ipl\Sql\Adapter\Pgsql;
use ipl\Sql\Connection;
use ipl\Stdlib\Filter;
use PDO;
use SplFileInfo;
use stdClass;

/**
 * Allows you to automatically perform database migrations.
 *
 * The version numbers of the sql migrations are determined by extracting the respective migration script names.
 * It's required to place the sql migrate scripts below the respective following directories:
 *
 *   `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
 */
abstract class DbMigrationHook implements Countable
{
    use Translation;

    public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';

    public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';

    /** @var string Fakes a module when this hook is implemented by the framework itself */
    public const DEFAULT_MODULE = 'icingaweb2';

    /** @var string Migration hook param name */
    public const MIGRATION_PARAM = 'migration';

    public const ALL_MIGRATIONS = 'all-migrations';

    /** @var ?array<string, DbMigrationStep> All pending database migrations of this hook */
    protected $migrations;

    /** @var ?string The current version of this hook */
    protected $version;

    /**
     * Get whether the specified table exists in the given database
     *
     * @param Connection $conn
     * @param string $table
     *
     * @return bool
     */
    public static function tableExists(Connection $conn, string $table): bool
    {
        /** @var false|int $exists */
        $exists = $conn->prepexec(
            'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
            $table
        )->fetchColumn();

        return (bool) $exists;
    }

    /**
     * Get whether the specified column exists in the provided table
     *
     * @param Connection $conn
     * @param string $table
     * @param string $column
     *
     * @return ?string
     */
    public static function getColumnType(Connection $conn, string $table, string $column): ?string
    {
        $pdoStmt = $conn->prepexec(
            sprintf(
                'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns'
                . ' WHERE table_name = ? AND column_name = ?',
                $conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type',
                $conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL'
            ),
            [$table, $column]
        );

        /** @var false|stdClass $result */
        $result = $pdoStmt->fetch(PDO::FETCH_OBJ);
        if ($result === false) {
            return null;
        }

        if ($result->column_length !== null) {
            $result->column_type .= '(' . $result->column_length . ')';
        }

        return $result->column_type;
    }

    /**
     * Get the mysql collation name of the given column of the specified table
     *
     * @param Connection $conn
     * @param string $table
     * @param string $column
     *
     * @return ?string
     */
    public static function getColumnCollation(Connection $conn, string $table, string $column): ?string
    {
        if ($conn->getAdapter() instanceof Pgsql) {
            return null;
        }

        /** @var false|string $collation */
        $collation = $conn->prepexec(
            'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
            [$table, $column]
        )->fetchColumn();

        return ! $collation ? null : $collation;
    }

    /**
     * Get statically provided descriptions of the individual migrate scripts
     *
     * @return string[]
     */
    abstract public function providedDescriptions(): array;

    /**
     * Get the full name of the component this hook is implemented by
     *
     * @return string
     */
    abstract public function getName(): string;

    /**
     * Get the current schema version of this migration hook
     *
     * @return string
     */
    abstract public function getVersion(): string;

    /**
     * Get a database connection
     *
     * @return Connection
     */
    abstract public function getDb(): Connection;

    /**
     * Get all the pending migrations of this hook
     *
     * @return DbMigrationStep[]
     */
    public function getMigrations(): array
    {
        if ($this->migrations === null) {
            $this->migrations = [];

            $this->load();
        }

        return $this->migrations ?? [];
    }

    /**
     * Get the latest migrations limited by the given number
     *
     * @param int $limit
     *
     * @return DbMigrationStep[]
     */
    public function getLatestMigrations(int $limit): array
    {
        $migrations = $this->getMigrations();
        if ($limit > 0) {
            $migrations = array_slice($migrations, -$limit, null, true);
        }

        return array_reverse($migrations);
    }

    /**
     * Apply all pending migrations of this hook
     *
     * @param ?Connection $conn Use the provided database connection to apply the migrations.
     *   Is only used to elevate database users with insufficient privileges.
     *
     * @return bool Whether the migration(s) have been successfully applied
     */
    final public function run(?Connection $conn = null): bool
    {
        if (! $conn) {
            $conn = $this->getDb();
        }

        foreach ($this->getMigrations() as $migration) {
            try {
                $migration->apply($conn);

                $this->version = $migration->getVersion();
                unset($this->migrations[$migration->getVersion()]);

                $data = [
                    'name'  => $this->getName(),
                    'version' => $migration->getVersion()
                ];
                AuditHook::logActivity(
                    'migrations',
                    'Migrated database schema of {{name}} to version {{version}}',
                    $data
                );

                $this->storeState($migration->getVersion(), null);
            } catch (Exception $e) {
                Logger::error(
                    "Failed to apply %s pending migration version %s \n%s",
                    $this->getName(),
                    $migration->getVersion(),
                    $e
                );
                Logger::debug($e->getTraceAsString());

                static::insertFailedEntry(
                    $conn,
                    $migration->getVersion(),
                    $e->getMessage() . PHP_EOL . $e->getTraceAsString()
                );

                return false;
            }
        }

        return true;
    }

    /**
     * Get whether this hook is implemented by a module
     *
     * @return bool
     */
    public function isModule(): bool
    {
        return ClassLoader::classBelongsToModule(static::class);
    }

    /**
     * Get the name of the module this hook is implemented by
     *
     * @return string
     */
    public function getModuleName(): string
    {
        if (! $this->isModule()) {
            return static::DEFAULT_MODULE;
        }

        return ClassLoader::extractModuleName(static::class);
    }

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

    /**
     * Get a schema version query
     *
     * @return Query
     */
    abstract protected function getSchemaQuery(): Query;

    protected function load(): void
    {
        $upgradeDir = static::MYSQL_UPGRADE_DIR;
        if ($this->getDb()->getAdapter() instanceof Pgsql) {
            $upgradeDir = static::PGSQL_UPGRADE_DIR;
        }

        if (! $this->isModule()) {
            $path = Icinga::app()->getBaseDir();
        } else {
            $path = Module::get($this->getModuleName())->getBaseDir();
        }

        $descriptions = $this->providedDescriptions();
        $version = $this->getVersion();
        /** @var SplFileInfo $file */
        foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) {
            if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) {
                [$_, $_, $migrateVersion, $description] = array_pad($m, 4, null);
                /** @var string $migrateVersion */
                if ($migrateVersion && version_compare($migrateVersion, $version, '>')) {
                    $migration = new DbMigrationStep($migrateVersion, $file->getRealPath());
                    if (isset($descriptions[$migrateVersion])) {
                        $migration->setDescription($descriptions[$migrateVersion]);
                    } elseif ($description) {
                        $migration->setDescription(str_replace('_', ' ', $description));
                    }

                    $migration->setLastState($this->loadLastState($migrateVersion));

                    $this->migrations[$migrateVersion] = $migration;
                }
            }
        }

        if ($this->migrations) {
            // Sort all the migrations by their version numbers in ascending order.
            uksort($this->migrations, function ($a, $b) {
                return version_compare($a, $b);
            });
        }
    }

    /**
     * Insert failed migration entry into the database or to the session
     *
     * @param Connection $conn
     * @param string $version
     * @param string $reason
     *
     * @return $this
     */
    protected function insertFailedEntry(Connection $conn, string $version, string $reason): self
    {
        $schemaQuery = $this->getSchemaQuery()
            ->filter(Filter::equal('version', $version));

        if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
            $this->storeState($version, $reason);
        } else {
            /** @var Schema $schema */
            $schema = $schemaQuery->first();
            if ($schema) {
                $conn->update($schema->getTableName(), [
                    'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
                    'success'   => 'n',
                    'reason'    => $reason
                ], ['id = ?' => $schema->id]);
            } else {
                $conn->insert($schemaQuery->getModel()->getTableName(), [
                    'version'   => $version,
                    'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
                    'success'   => 'n',
                    'reason'    => $reason
                ]);
            }
        }

        return $this;
    }

    /**
     * Store a failed state message in the session for the given version
     *
     * @param string $version
     * @param ?string $reason
     *
     * @return $this
     */
    protected function storeState(string $version, ?string $reason): self
    {
        $session = Session::getSession()->getNamespace('migrations');
        /** @var array<string, string> $states */
        $states = $session->get($this->getModuleName(), []);
        $states[$version] = $reason;

        $session->set($this->getModuleName(), $states);

        return $this;
    }

    /**
     * Load last failed state from database/session for the given version
     *
     * @param string $version
     *
     * @return ?string
     */
    protected function loadLastState(string $version): ?string
    {
        $session = Session::getSession()->getNamespace('migrations');
        /** @var array<string, string> $states */
        $states = $session->get($this->getModuleName(), []);
        if (! isset($states[$version])) {
            $schemaQuery = $this->getSchemaQuery()
                ->filter(Filter::equal('version', $version))
                ->filter(Filter::all(Filter::equal('success', 'n')));

            if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
                /** @var Schema $schema */
                $schema = $schemaQuery->first();
                if ($schema) {
                    return $schema->reason;
                }
            }

            return null;
        }

        return $states[$version];
    }
}
