<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Database;

use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\ConfigException;
use CodeIgniter\Exceptions\RuntimeException;
use CodeIgniter\I18n\Time;
use Config\Database;
use Config\Migrations as MigrationsConfig;
use stdClass;

/**
 * Class MigrationRunner
 */
class MigrationRunner
{
    /**
     * Whether or not migrations are allowed to run.
     *
     * @var bool
     */
    protected $enabled = false;

    /**
     * Name of table to store meta information
     *
     * @var string
     */
    protected $table;

    /**
     * The Namespace where migrations can be found.
     * `null` is all namespaces.
     *
     * @var string|null
     */
    protected $namespace = APP_NAMESPACE;

    /**
     * The database Group to migrate.
     *
     * @var string
     */
    protected $group;

    /**
     * The migration name.
     *
     * @var string
     */
    protected $name;

    /**
     * The pattern used to locate migration file versions.
     *
     * @var string
     */
    protected $regex = '/\A(\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6})_(\w+)\z/';

    /**
     * The main database connection. Used to store
     * migration information in.
     *
     * @var BaseConnection
     */
    protected $db;

    /**
     * If true, will continue instead of throwing
     * exceptions.
     *
     * @var bool
     */
    protected $silent = false;

    /**
     * used to return messages for CLI.
     *
     * @var array
     */
    protected $cliMessages = [];

    /**
     * Tracks whether we have already ensured
     * the table exists or not.
     *
     * @var bool
     */
    protected $tableChecked = false;

    /**
     * Lock the migration table.
     */
    protected bool $lock = false;

    /**
     * Tracks whether we have already ensured
     * the lock table exists or not.
     */
    protected bool $lockTableChecked = false;

    /**
     * The full path to locate migration files.
     *
     * @var string
     */
    protected $path;

    /**
     * The database Group filter.
     *
     * @var string|null
     */
    protected $groupFilter;

    /**
     * Used to skip current migration.
     *
     * @var bool
     */
    protected $groupSkip = false;

    /**
     * The migration can manage multiple databases. So it should always use the
     * default DB group so that it creates the `migrations` table in the default
     * DB group. Therefore, passing $db is for testing purposes only.
     *
     * @param array|ConnectionInterface|string|null $db DB group. For testing purposes only.
     *
     * @throws ConfigException
     */
    public function __construct(MigrationsConfig $config, $db = null)
    {
        $this->enabled = $config->enabled;
        $this->table   = $config->table;
        $this->lock    = $config->lock ?? false; // @phpstan-ignore nullCoalesce.property

        // Even if a DB connection is passed, since it is a test,
        // it is assumed to use the default group name
        $this->group = is_string($db) ? $db : config(Database::class)->defaultGroup;

        $this->db = db_connect($db);
    }

    /**
     * Locate and run all new migrations
     *
     * @return bool
     *
     * @throws ConfigException
     * @throws RuntimeException
     */
    public function latest(?string $group = null)
    {
        if (! $this->enabled) {
            throw ConfigException::forDisabledMigrations();
        }

        $this->ensureTable();

        // Try to acquire lock - exit gracefully if another process is running migrations
        if ($this->lock && ! $this->acquireMigrationLock()) {
            $message             = lang('Migrations.locked');
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');

            return true;
        }

        try {
            if ($group !== null) {
                $this->groupFilter = $group;
                $this->setGroup($group);
            }

            $migrations = $this->findMigrations();

            if ($migrations === []) {
                return true;
            }

            foreach ($this->getHistory((string) $group) as $history) {
                unset($migrations[$this->getObjectUid($history)]);
            }

            $batch = $this->getLastBatch() + 1;

            foreach ($migrations as $migration) {
                if ($this->migrate('up', $migration)) {
                    if ($this->groupSkip === true) {
                        $this->groupSkip = false;

                        continue;
                    }

                    $this->addHistory($migration, $batch);
                } else {
                    $this->regress(-1);

                    $message = lang('Migrations.generalFault');

                    if ($this->silent) {
                        $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                        return false;
                    }

                    throw new RuntimeException($message);
                }
            }

            $data           = get_object_vars($this);
            $data['method'] = 'latest';
            Events::trigger('migrate', $data);

            return true;
        } finally {
            if ($this->lock) {
                $this->releaseMigrationLock();
            }
        }
    }

    /**
     * Migrate down to a previous batch
     *
     * Calls each migration step required to get to the provided batch
     *
     * @param int         $targetBatch Target batch number, or negative for a relative batch, 0 for all
     * @param string|null $group       Deprecated. The designation has no effect.
     *
     * @return bool True on success, FALSE on failure or no migrations are found
     *
     * @throws ConfigException
     * @throws RuntimeException
     */
    public function regress(int $targetBatch = 0, ?string $group = null)
    {
        if (! $this->enabled) {
            throw ConfigException::forDisabledMigrations();
        }

        $this->ensureTable();

        // Try to acquire lock - exit gracefully if another process is running migrations
        if ($this->lock && ! $this->acquireMigrationLock()) {
            $message             = lang('Migrations.locked');
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');

            return true;
        }

        try {
            $batches = $this->getBatches();

            if ($targetBatch < 0) {
                $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0;
            }

            if ($batches === [] && $targetBatch === 0) {
                return true;
            }

            if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) {
                $message = lang('Migrations.batchNotFound') . $targetBatch;

                if ($this->silent) {
                    $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                    return false;
                }

                throw new RuntimeException($message);
            }

            $tmpNamespace = $this->namespace;

            $this->namespace = null;
            $allMigrations   = $this->findMigrations();

            $migrations = [];

            while ($batch = array_pop($batches)) {
                if ($batch <= $targetBatch) {
                    break;
                }

                foreach ($this->getBatchHistory($batch, 'desc') as $history) {
                    $uid = $this->getObjectUid($history);

                    if (! isset($allMigrations[$uid])) {
                        $message = lang('Migrations.gap') . ' ' . $history->version;

                        if ($this->silent) {
                            $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                            return false;
                        }

                        throw new RuntimeException($message);
                    }

                    $migration          = $allMigrations[$uid];
                    $migration->history = $history;
                    $migrations[]       = $migration;
                }
            }

            foreach ($migrations as $migration) {
                if ($this->migrate('down', $migration)) {
                    $this->removeHistory($migration->history);
                } else {
                    $message = lang('Migrations.generalFault');

                    if ($this->silent) {
                        $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                        return false;
                    }

                    throw new RuntimeException($message);
                }
            }

            $data           = get_object_vars($this);
            $data['method'] = 'regress';
            Events::trigger('migrate', $data);

            $this->namespace = $tmpNamespace;

            return true;
        } finally {
            if ($this->lock) {
                $this->releaseMigrationLock();
            }
        }
    }

    /**
     * Migrate a single file regardless of order or batches.
     * Method "up" or "down" determined by presence in history.
     * NOTE: This is not recommended and provided mostly for testing.
     *
     * @param string $path Full path to a valid migration file
     * @param string $path Namespace of the target migration
     *
     * @return bool
     */
    public function force(string $path, string $namespace, ?string $group = null)
    {
        if (! $this->enabled) {
            throw ConfigException::forDisabledMigrations();
        }

        $this->ensureTable();

        // Try to acquire lock - exit gracefully if another process is running migrations
        if ($this->lock && ! $this->acquireMigrationLock()) {
            $message             = lang('Migrations.locked');
            $this->cliMessages[] = "\t" . CLI::color($message, 'yellow');

            return true;
        }

        try {
            if ($group !== null) {
                $this->groupFilter = $group;
                $this->setGroup($group);
            }

            $migration = $this->migrationFromFile($path, $namespace);
            if ($migration === false) {
                $message = lang('Migrations.notFound');

                if ($this->silent) {
                    $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                    return false;
                }

                throw new RuntimeException($message);
            }

            $method = 'up';
            $this->setNamespace($migration->namespace);

            foreach ($this->getHistory($this->group) as $history) {
                if ($this->getObjectUid($history) === $migration->uid) {
                    $method             = 'down';
                    $migration->history = $history;
                    break;
                }
            }

            if ($method === 'up') {
                $batch = $this->getLastBatch() + 1;

                if ($this->migrate('up', $migration) && $this->groupSkip === false) {
                    $this->addHistory($migration, $batch);

                    return true;
                }

                $this->groupSkip = false;
            } elseif ($this->migrate('down', $migration)) {
                $this->removeHistory($migration->history);

                return true;
            }

            $message = lang('Migrations.generalFault');

            if ($this->silent) {
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                return false;
            }

            throw new RuntimeException($message);
        } finally {
            if ($this->lock) {
                $this->releaseMigrationLock();
            }
        }
    }

    /**
     * Retrieves list of available migration scripts
     *
     * @return array List of all located migrations by their UID
     */
    public function findMigrations(): array
    {
        $namespaces = $this->namespace !== null ? [$this->namespace] : array_keys(service('autoloader')->getNamespace());
        $migrations = [];

        foreach ($namespaces as $namespace) {
            if (ENVIRONMENT !== 'testing' && $namespace === 'Tests\Support') {
                continue;
            }

            foreach ($this->findNamespaceMigrations($namespace) as $migration) {
                $migrations[$migration->uid] = $migration;
            }
        }

        // Sort migrations ascending by their UID (version)
        ksort($migrations);

        return $migrations;
    }

    /**
     * Retrieves a list of available migration scripts for one namespace
     */
    public function findNamespaceMigrations(string $namespace): array
    {
        $migrations = [];
        $locator    = service('locator', true);

        if (! empty($this->path)) {
            helper('filesystem');
            $dir   = rtrim($this->path, DIRECTORY_SEPARATOR) . '/';
            $files = get_filenames($dir, true, false, false);
        } else {
            $files = $locator->listNamespaceFiles($namespace, '/Database/Migrations/');
        }

        foreach ($files as $file) {
            $file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file);

            if ($migration = $this->migrationFromFile($file, $namespace)) {
                $migrations[] = $migration;
            }
        }

        return $migrations;
    }

    /**
     * Create a migration object from a file path.
     *
     * @param string $path Full path to a valid migration file.
     *
     * @return false|object Returns the migration object, or false on failure
     */
    protected function migrationFromFile(string $path, string $namespace)
    {
        if (! str_ends_with($path, '.php')) {
            return false;
        }

        $filename = basename($path, '.php');

        if (preg_match($this->regex, $filename) !== 1) {
            return false;
        }

        $locator = service('locator', true);

        $migration = new stdClass();

        $migration->version   = $this->getMigrationNumber($filename);
        $migration->name      = $this->getMigrationName($filename);
        $migration->path      = $path;
        $migration->class     = $locator->getClassname($path);
        $migration->namespace = $namespace;
        $migration->uid       = $this->getObjectUid($migration);

        return $migration;
    }

    /**
     * Allows other scripts to modify on the fly as needed.
     *
     * @return MigrationRunner
     */
    public function setNamespace(?string $namespace)
    {
        $this->namespace = $namespace;

        return $this;
    }

    /**
     * Allows other scripts to modify on the fly as needed.
     *
     * @return MigrationRunner
     */
    public function setGroup(string $group)
    {
        $this->group = $group;

        return $this;
    }

    /**
     * @return MigrationRunner
     */
    public function setName(string $name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * If $silent == true, then will not throw exceptions and will
     * attempt to continue gracefully.
     *
     * @return MigrationRunner
     */
    public function setSilent(bool $silent)
    {
        $this->silent = $silent;

        return $this;
    }

    /**
     * Extracts the migration number from a filename
     *
     * @param string $migration A migration filename w/o path.
     */
    protected function getMigrationNumber(string $migration): string
    {
        preg_match($this->regex, $migration, $matches);

        return $matches !== [] ? $matches[1] : '0';
    }

    /**
     * Extracts the migration name from a filename
     *
     * Note: The migration name should be the classname, but maybe they are
     *       different.
     *
     * @param string $migration A migration filename w/o path.
     */
    protected function getMigrationName(string $migration): string
    {
        preg_match($this->regex, $migration, $matches);

        return $matches !== [] ? $matches[2] : '';
    }

    /**
     * Uses the non-repeatable portions of a migration or history
     * to create a sortable unique key
     *
     * @param object $object migration or $history
     */
    public function getObjectUid($object): string
    {
        return preg_replace('/[^0-9]/', '', $object->version) . $object->class;
    }

    /**
     * Retrieves messages formatted for CLI output
     */
    public function getCliMessages(): array
    {
        return $this->cliMessages;
    }

    /**
     * Clears any CLI messages.
     *
     * @return MigrationRunner
     */
    public function clearCliMessages()
    {
        $this->cliMessages = [];

        return $this;
    }

    /**
     * Truncates the history table.
     *
     * @return void
     */
    public function clearHistory()
    {
        if ($this->db->tableExists($this->table)) {
            $this->db->table($this->table)->truncate();
        }
    }

    /**
     * Add a history to the table.
     *
     * @param object $migration
     *
     * @return void
     */
    protected function addHistory($migration, int $batch)
    {
        $this->db->table($this->table)->insert([
            'version'   => $migration->version,
            'class'     => $migration->class,
            'group'     => $this->group,
            'namespace' => $migration->namespace,
            'time'      => Time::now()->getTimestamp(),
            'batch'     => $batch,
        ]);

        if (is_cli()) {
            $this->cliMessages[] = sprintf(
                "\t%s(%s) %s_%s",
                CLI::color(lang('Migrations.added'), 'yellow'),
                $migration->namespace,
                $migration->version,
                $migration->class,
            );
        }
    }

    /**
     * Removes a single history
     *
     * @param object $history
     *
     * @return void
     */
    protected function removeHistory($history)
    {
        $this->db->table($this->table)->where('id', $history->id)->delete();

        if (is_cli()) {
            $this->cliMessages[] = sprintf(
                "\t%s(%s) %s_%s",
                CLI::color(lang('Migrations.removed'), 'yellow'),
                $history->namespace,
                $history->version,
                $history->class,
            );
        }
    }

    /**
     * Grabs the full migration history from the database for a group
     */
    public function getHistory(string $group = 'default'): array
    {
        $this->ensureTable();

        $builder = $this->db->table($this->table);

        // If group was specified then use it
        if ($group !== '') {
            $builder->where('group', $group);
        }

        // If a namespace was specified then use it
        if ($this->namespace !== null) {
            $builder->where('namespace', $this->namespace);
        }

        $query = $builder->orderBy('id', 'ASC')->get();

        return empty($query) ? [] : $query->getResultObject();
    }

    /**
     * Returns the migration history for a single batch.
     *
     * @param string $order
     */
    public function getBatchHistory(int $batch, $order = 'asc'): array
    {
        $this->ensureTable();

        $query = $this->db->table($this->table)
            ->where('batch', $batch)
            ->orderBy('id', $order)
            ->get();

        return empty($query) ? [] : $query->getResultObject();
    }

    /**
     * Returns all the batches from the database history in order
     */
    public function getBatches(): array
    {
        $this->ensureTable();

        $batches = $this->db->table($this->table)
            ->select('batch')
            ->distinct()
            ->orderBy('batch', 'asc')
            ->get()
            ->getResultArray();

        return array_map(intval(...), array_column($batches, 'batch'));
    }

    /**
     * Returns the value of the last batch in the database.
     */
    public function getLastBatch(): int
    {
        $this->ensureTable();

        $batch = $this->db->table($this->table)
            ->selectMax('batch')
            ->get()
            ->getResultObject();

        $batch = is_array($batch) && $batch !== []
            ? end($batch)->batch
            : 0;

        return (int) $batch;
    }

    /**
     * Returns the version number of the first migration for a batch.
     * Mostly just for tests.
     */
    public function getBatchStart(int $batch): string
    {
        if ($batch < 0) {
            $batches = $this->getBatches();
            $batch   = $batches[count($batches) - 1] ?? 0;
        }

        $migration = $this->db->table($this->table)
            ->where('batch', $batch)
            ->orderBy('id', 'asc')
            ->limit(1)
            ->get()
            ->getResultObject();

        return $migration !== [] ? $migration[0]->version : '0';
    }

    /**
     * Returns the version number of the last migration for a batch.
     * Mostly just for tests.
     */
    public function getBatchEnd(int $batch): string
    {
        if ($batch < 0) {
            $batches = $this->getBatches();
            $batch   = $batches[count($batches) - 1] ?? 0;
        }

        $migration = $this->db->table($this->table)
            ->where('batch', $batch)
            ->orderBy('id', 'desc')
            ->limit(1)
            ->get()
            ->getResultObject();

        return $migration === [] ? '0' : $migration[0]->version;
    }

    /**
     * Ensures that we have created our migrations table
     * in the database.
     *
     * @return void
     */
    public function ensureTable()
    {
        if ($this->tableChecked || $this->db->tableExists($this->table)) {
            return;
        }

        $forge = Database::forge($this->db);

        $forge->addField([
            'id' => [
                'type'           => 'BIGINT',
                'constraint'     => 20,
                'unsigned'       => true,
                'auto_increment' => true,
            ],
            'version' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
                'null'       => false,
            ],
            'class' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
                'null'       => false,
            ],
            'group' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
                'null'       => false,
            ],
            'namespace' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
                'null'       => false,
            ],
            'time' => [
                'type'       => 'INT',
                'constraint' => 11,
                'null'       => false,
            ],
            'batch' => [
                'type'       => 'INT',
                'constraint' => 11,
                'unsigned'   => true,
                'null'       => false,
            ],
        ]);

        $forge->addPrimaryKey('id');
        $forge->createTable($this->table, true);

        $this->tableChecked = true;
    }

    /**
     * Ensures that we have created our migration
     * lock table in the database.
     *
     * @return string The lock table name
     */
    protected function ensureLockTable(): string
    {
        $lockTable = $this->table . '_lock';

        if ($this->lockTableChecked || $this->db->tableExists($lockTable)) {
            $this->lockTableChecked = true;

            return $lockTable;
        }

        $forge = Database::forge($this->db);

        $forge->addField([
            'id' => [
                'type'           => 'BIGINT',
                'auto_increment' => true,
            ],
            'lock_name' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
                'null'       => false,
                'unique'     => true,
            ],
            'acquired_at' => [
                'type' => 'INTEGER',
                'null' => false,
            ],
        ]);

        $forge->addPrimaryKey('id');
        $forge->createTable($lockTable, true);

        $this->lockTableChecked = true;

        return $lockTable;
    }

    /**
     * Acquire exclusive lock on migrations to prevent concurrent execution
     *
     * @return bool True if lock was acquired, false if another process holds the lock
     */
    protected function acquireMigrationLock(): bool
    {
        $lockTable = $this->ensureLockTable();

        try {
            $this->db->table($lockTable)->insert([
                'lock_name'   => 'migration_process',
                'acquired_at' => Time::now()->getTimestamp(),
            ]);

            return $this->db->insertID() > 0;
        } catch (DatabaseException) {
            // Lock already exists or other error
            return false;
        }
    }

    /**
     * Release migration lock
     *
     * @return bool True if successfully released, false on error
     */
    protected function releaseMigrationLock(): bool
    {
        $lockTable = $this->ensureLockTable();

        $result = $this->db->table($lockTable)
            ->where('lock_name', 'migration_process')
            ->delete();

        if ($result === false) {
            log_message('warning', 'Failed to release migration lock');
        }

        return $result;
    }

    /**
     * Handles the actual running of a migration.
     *
     * @param string $direction "up" or "down"
     * @param object $migration The migration to run
     */
    protected function migrate($direction, $migration): bool
    {
        include_once $migration->path;

        $class = $migration->class;
        $this->setName($migration->name);

        // Validate the migration file structure
        if (! class_exists($class, false)) {
            $message = sprintf(lang('Migrations.classNotFound'), $class);

            if ($this->silent) {
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                return false;
            }

            throw new RuntimeException($message);
        }

        /** @var Migration $instance */
        $instance = new $class(Database::forge($this->db));
        $group    = $instance->getDBGroup() ?? $this->group;

        if (ENVIRONMENT !== 'testing' && $group === 'tests' && $this->groupFilter !== 'tests') {
            // @codeCoverageIgnoreStart
            $this->groupSkip = true;

            return true;
            // @codeCoverageIgnoreEnd
        }

        if ($direction === 'up' && $this->groupFilter !== null && $this->groupFilter !== $group) {
            $this->groupSkip = true;

            return true;
        }

        if (! is_callable([$instance, $direction])) {
            $message = sprintf(lang('Migrations.missingMethod'), $direction);

            if ($this->silent) {
                $this->cliMessages[] = "\t" . CLI::color($message, 'red');

                return false;
            }

            throw new RuntimeException($message);
        }

        $instance->{$direction}();

        return true;
    }
}
