<?php defined('BASEPATH') || exit('No direct script access allowed');

/**
 * Migrations Library
 *
 * Migrations provide a simple method to version the contents of your database, and make
 * those changes easily distributable to other developers in different server environments.
 *
 * Migrations are stored in specially-named PHP files under *application/db/migrations/*.
 * Each migration file must be numbered consecutively, starting at *001* and growing larger
 * with each new migration from there. The rest of the filename should tell what the migration does.
 *
 * For example: 001_install_initial_schema.php
 *
 * The class inside of the file must extend the abstract Migration class and implement both the
 * up() and down() methods to install and uninstall the tables/changes. The class itself should be
 * named:
 *
 * :    class Migration_install_initial_schema extends Migration {
 * :
 * :        function up() {}
 * :
 * :        function down() {}
 * :    }
 *
 * @package Bonfire\Modules\Migrations\Libraries\Migrations
 * @author  Mat'as Montes
 * @author  Phil Sturgeon http://philsturgeon.co.uk/
 * @author  Spicer Matthews <spicer@cloudmanic.com> Cloudmanic Labs, LLC http://www.cloudmanic.com/
 * @author  Bonfire Dev Team
 * @link    http://cibonfire.com/docs/developer/migrations
 */
class Migrations
{
    /**
     * The prefix used for app migrations.
     */
    const APP_MIGRATION_PREFIX = 'app_';

    /**
     * Migration type used for core migrations.
     */
    const CORE_MIGRATIONS = 'core';

    /**
     * The maximum migrations schema version currently supported by the library.
     */
    const MAX_SCHEMA_VERSION = 2;

    /** @var object The CodeIgniter instance. */
    protected $_ci;

    /**
     * @var array Errors which occurred during the current execution of the library.
     */
    protected $errors = array();

    /** @var string The name of the schema version/migrations table. */
    protected $migrationsTable = 'schema_version';

    /** @var string Path to the core migrations files. */
    private $coreMigrationsPath;

    /**
     * @var int The version of the schema version/migrations table. 0 indicates
     * the schema version is unknown, 1 indicates the "old" schema, 2 or 3
     * indicates the "new" schema. This is intended to prevent multiple calls to
     * the database to check the schema version.
     */
    private static $migrationsSchemaVersion = 0;

    /** @var array Cache of the current versions. */
    private static $schemaVersion;

    /** @var bool If true, show verbose messages. */
    private $verbose = false;

    //--------------------------------------------------------------------------
    // Public methods
    //--------------------------------------------------------------------------

    /**
     * Initialize the library with the configuration settings.
     *
     * @return void
     */
    public function __construct($params = array())
    {
        // Require the abstract Migration class for use by the migrations.
        require_once(IGOPATH . 'core/Migration.php');

        $this->_ci =& get_instance();

        $this->coreMigrationsPath = isset($params['migrations_path']) ?
            $params['migrations_path'] : (IGOPATH . 'core');

        // Add trailing slash if not set
        if (substr($this->coreMigrationsPath, -1) != '/') {
            $this->coreMigrationsPath .= '/';
        }

        // Sanity check
        if (! is_dir($this->coreMigrationsPath)) {
            $this->setError('Migrations Library is loaded but the migrations path is configured incorrectly, or the configured path is not a valid directory.');
            show_error($this->getErrorMessage());
        }

        // If the table is missing, create it
        if (! $this->createMigrationsTable(self::MAX_SCHEMA_VERSION)) {
            show_error($this->getErrorMessage());
        }

        if (! is_array(self::$schemaVersion)) {
            self::$schemaVersion = array();
        }
    }

    /**
     * Handle auto-running core and/or app migrations on page load.
     *
     * @return void
     */
    public function autoLatest()
    {
        if ($this->_ci->config->item('migrate.auto_core')) {
            $this->install('');
        }

        if ($this->_ci->config->item('migrate.auto_app')) {
            $this->install(self::APP_MIGRATION_PREFIX);
        }
    }

    /**
     * Execute raw SQL migrations.
     *
     * Will manually break the commands on a ';' so multiple commmands can be executed
     * in a single migration. Very handy for using phpMyAdmin dumps.
     *
     * @param string $sql A string with one or more SQL commands to be run.
     *
     * @return void
     */
    public function doSqlMigration($sql = '')
    {
        if (empty($sql)) {
            return;
        }

        // Split the sql into usable commands on ';'
        $queries = explode(';', $sql);
        foreach ($queries as $q) {
            if (trim($q)) {
                if ($this->_ci->db->query(trim($q)) === false) {
                    return false;
                }
            }
        }
    }

    /**
     * Return a list of available migration files in the migrations folder.
     *
     * @param string $type A string that represents the name of the module, or
     * self::APP_MIGRATION_PREFIX for application migrations. If empty, it
     * returns core migrations.
     *
     * @return array An array of migration filenames.
     */
    public function getAvailableVersions($type = '')
    {
        // Get the migrations path for the given type
        $migrationsPath = $this->getMigrationsPath($type);

        // List all *_*.php files in the migrations path
        $files = glob("{$migrationsPath}*_*.php");
        for ($i = 0; $i < count($files); $i++) {
            // Remove path and extension
            $files[$i] = basename($files[$i], '.php');

            // Mark wrongly formatted files as FALSE for later filtering
            if (! preg_match('/^\d{3}_(\w+)$/', $files[$i])) {
                $files[$i] = false;
            }
        }

        $migrations = array_filter($files);
        sort($migrations);

        return $migrations;
    }

    /**
     * Get the most recent error message.
     *
     * @return string The most recent error message.
     */
    public function getErrorMessage()
    {
        if (empty($this->errors)) {
            return '';
        }

        return end($this->errors);
    }

    /**
     * Get all of the errors.
     *
     * @param string $key If set, returns only the error message associated with
     * the given key.
     *
     * @return array|string An array of errors or the error message associated with
     * the given key.
     */
    public function getErrors($key = '')
    {
        if (! empty($key)) {
            return $this->errors[$key];
        }

        return $this->errors;
    }

    /**
     * Retrieve the module versions in a single DB call and set the cache.
     *
     * @return array The module versions. Keys are the module names and values are
     * the versions. If the database query fails, returns an empty array.
     */
    public function getModuleVersions()
    {
        $schemaVersion = $this->getLibraryVersion();
        $query = $this->_ci->db->get($this->migrationsTable);
        if (! $query->num_rows()) {
            $this->setError('Error retrieving module versions');
            return array();
        }

        if ($schemaVersion == 1) {
            $row = $query->row_array();
            // Get each column in the row and set the module versions...
            foreach (array_keys($row) as $key) {
                if ($key == 'version') {
                    continue;
                }
                $type = rtrim($key, 'version');
                self::$schemaVersion[$type] = $row[$key];
            }
        } else {
            foreach ($query->result() as $row) {
                self::$schemaVersion[$row->type] = $row->version;
            }
        }

        return self::$schemaVersion;
    }

    /**
     * Get the schema version from the cache. If a database query is required, cache
     * the result.
     *
     * @param string $type The type for which the version is requested.
     *
     * @return int The version.
     */
    public function getVersion($type = '', $getLatest = false)
    {
        if ($getLatest) {
            return $this->getLatestVersion($type);
        }

        if (isset(self::$schemaVersion[$type])) {
            return self::$schemaVersion[$type];
        }

        if (empty($type) && isset(self::$schemaVersion[self::CORE_MIGRATIONS])) {
            return self::$schemaVersion[self::CORE_MIGRATIONS];
        }

        $type = empty($type) ? self::CORE_MIGRATIONS : $type;
        $row = $this->_ci->db->where('type', $type)
                             ->get($this->migrationsTable)
                             ->row();

        self::$schemaVersion[$type] = isset($row->version) ? $row->version: 0;

        return self::$schemaVersion[$type];
    }

    /**
     * Install the migrations for the given type up to the latest version.
     *
     * @param string $type The name of the module or self::APP_MIGRATION_PREFIX
     * for application migrations. If empty, the core migrations will be installed.
     *
     * @return int The version number for the given type after installing the migrations
     * or 0 on error.
     */
    public function install($type = '')
    {
        $latestVersion = $this->getLatestVersion($type);
        
        if ($latestVersion > 0) {
            return $this->version($latestVersion, $type);
        }

        $this->setError($this->_ci->lang->line('no_migrations_found'));
        return 0;
    }

    /**
     * Set whether there should be verbose output.
     *
     * @param bool $state True to enable verbose output, false to disable.
     *
     * @return void
     */
    public function setVerbose($state)
    {
        $this->verbose = (bool) $state;
    }

    /**
     * Migrate to a specific schema version.
     *
     * Calls each migration step required to get to the specified schema version.
     *
     * @param int    $version An int that is the target migration version.
     * @param string $type    A string which represents the name of the module,
     * or 'app_' for application migrations. If empty, applies core migrations.
     *
     * @return int|bool Returns 0 (int) if failed, true (bool) if already latest,
     * schema version (int) if upgraded.
     */
    public function version($version, $type = '')
    {
        // Reset the error state to prevent invalid errors when auto-migration
        // is enabled.
        $this->setError('');

        $schemaVersion = $this->getVersion($type);
        $start = $schemaVersion;
        $stop  = $version;

        // Get the migrations path for the given type
        $migrationsPath = $this->getMigrationsPath($type);

        if ($version > $schemaVersion) {
            // Moving Up
            $start++;
            $stop++;
            $step = 1;
            $method = 'up';
        } else {
            // Moving Down
            $step = -1;
            $method = 'down';
        }

        $migrations = array();

        // Prepare to actually DO the migrations
        // But first, make sure that everything is the way it should be
        for ($i = $start; $i != $stop; $i += $step) {
            // Get all files in the migrations path matching the current step
            $migrationFiles = glob(sprintf("{$migrationsPath}%03d_*.php", $i));

            /**
             * @internal Note: the section below previously checked
             * count($migrationFiles) > 1, then count($migrationFiles) == 0. If
             * glob() returned false (an error occurred), count($migrationFiles)
             * would be 1, because count(false) returns 1 (indicating the
             * parameter is not an array, does not implement Countable, and is
             * not null).
             *
             * Checking empty($migrationFiles) will catch both false and an
             * empty array. Doing it first prevents an error (though silently
             * ignored) when checking count($migrationFiles) > 1.
             */

            // Migration step not found, or other error retrieving $migrationFiles
            if (empty($migrationFiles)) {
                // If trying to migrate up to a version greater than the last
                // existing one, migrate to the last one.
                if ($step == 1) {
                    break;
                }

                // If trying to migrate down but a step is missing, something
                // must be wrong.
                $this->setError(sprintf($this->_ci->lang->line("migration_not_found"), $i));
                return 0;
            }

            // Only one migration per step is permitted
            if (count($migrationFiles) > 1) {
                $this->setError(sprintf($this->_ci->lang->line("multiple_migrations_version"), $i));
                return 0;
            }

            // Filename without the extension
            $name = basename($migrationFiles[0], '.php');

            // Filename validations
            if (preg_match('/^\d{3}_(\w+)$/', $name, $match)) {
                // Filename without the extension and without the migration
                // number prefix, converted to lowercase
                $match[1] = strtolower($match[1]);

                // Cannot repeat a migration at different steps
                if (isset($migrations[$match[1]])) {
                    $this->setError(sprintf($this->_ci->lang->line("multiple_migrations_name"), $match[1]));
                    return 0;
                }

                // Load the migration, verify the class loaded successfully
                include $migrationFiles[0];

                // Substitute Migration_ for the migration number and capitalize
                // the first letter of the filename
                $class = 'Migration_' . ucfirst($match[1]);
                if (! class_exists($class)) {
                    $this->setError(sprintf($this->_ci->lang->line("migration_class_doesnt_exist"), $class));
                    return 0;
                }

                // Verify the migration contains the required methods
                if (! is_callable(array($class, "up"))
                    || ! is_callable(array($class, "down"))
                ) {
                    $this->setError(sprintf($this->_ci->lang->line('wrong_migration_interface'), $class));
                    return 0;
                }

                // All validations passed, add the file to the list of migrations
                // to execute. Keep the $class as well, since it would otherwise
                // need to be regenerated before execution.
                $migrations[$match[1]] = $class;
            } else {
                // Filename was in the wrong format
                // Make sure the filename used in the error message is accurate
                $file = basename($migrationFiles[0]);
                $this->setError(sprintf($this->_ci->lang->line("invalid_migration_filename"), $file, $migrationsPath));
                return 0;
            }
        }

        // If there is nothing to do, quit
        if ($migrations === array()) {
            if ($this->verbose) {
                echo "Nothing to do here.\n";
            }

            return true;
        }

        if ($this->verbose) {
            // Ensure the version output reflects the migrations found, rather
            // than just parroting the requested version.
            $version = $i + ($step == 1 ? -1 : 0);
            echo "<p>Current schema version: {$schemaVersion}<br />";
            echo "Moving {$method} to version {$version}</p>";
            echo "<hr />";
        }

        // Loop through the migrations and execute them
        $dbForgeLoaded = false;
        foreach ($migrations as $filename => $class) {
            if ($this->verbose) {
                echo "{$filename}:<br />";
                echo "<blockquote>";
            }

            $obj = new $class;

            if ($obj->migration_type == 'forge') {
                if (! $dbForgeLoaded) {
                    $this->_ci->load->dbforge();
                }
                if (call_user_func(array($obj, $method)) === false) {
                    $this->setError("Error calling method '{$method}' in '{$filename}'");
                    if ($this->verbose) {
                        echo $this->getErrorMessage();
                        echo "</blockquote>";
                        echo "<hr />";
                    }
                    break;
                }
            } elseif ($obj->migration_type == 'sql') {
                $sql = $obj->$method();
                if ($this->doSqlMigration($sql) === false) {
                    $this->setError("Error executing SQL Migration for '{$method}' in '{$filename}'");
                    if ($this->verbose) {
                        echo $this->getErrorMessage();
                        echo "</blockquote>";
                        echo "<hr />";
                    }
                    break;
                }
            } else {
                $this->setError("Unsupported migration type '{$obj->migration_type}' in '{$filename}'");
                if ($this->verbose) {
                    echo $this->getErrorMessage();
                    echo "</blockquote>";
                    echo "<hr />";
                }
                break;
            }

            if ($this->verbose) {
                echo "</blockquote>";
                echo "<hr />";
            }

            $schemaVersion += $step;
        }

        // In case the migration is moving through multiple versions, there is
        // no need to update the version in the database on every iteration of
        // the loop, so just do it when the loop is finished (or breaks).
        $this->updateVersion($schemaVersion, $type);

        if ($this->verbose) {
            echo "<p>All done. Schema is at version {$schemaVersion}.</p>";
        }

        return $schemaVersion;
    }

    //--------------------------------------------------------------------------
    // Protected methods
    //--------------------------------------------------------------------------

    /**
     * Get the migrations path for a given migration type.
     *
     * @param string $type The name of the module for module migrations.
     * self::APP_MIGRATION_PREFIX for application migrations.
     * Empty for core migrations.
     *
     * @return string The migrations path for the given migration type.
     */
    protected function getMigrationsPath($type = '')
    {
        switch ($type) {
            // Core migrations
            case '':
            case self::CORE_MIGRATIONS:
                return $this->coreMigrationsPath;

            // Application migrations
            case self::APP_MIGRATION_PREFIX:
                return APPPATH . 'db/migrations/';

            // If it is not a core migration or application migration, it should
            // be the name of a module.
            default:
                return Modules::path(substr($type, 0, -1), 'migrations') . '/';
        }
    }

    /**
     * Set an error message.
     *
     * @param string $value The error message.
     * @param string $key   A key to reference the error message (optional).
     *
     * @return void
     */
    protected function setError($value, $key = '')
    {
        if (empty($key)) {
            $this->errors[] = $value;
        } else {
            $this->errors[$key] = $value;
        }
    }

    //--------------------------------------------------------------------------
    // Private methods
    //--------------------------------------------------------------------------

    /**
     * Create the migrations table.
     *
     * @param int $schemaVersion The version of the schema for which the table
     * will be created.
     *
     * @return bool    True if the table was created successfully or already
     * exists. False if an error occurred.
     */
    private function createMigrationsTable($schemaVersion)
    {
        // If the table already exists, there's nothing to do here
        if ($this->_ci->db->table_exists($this->migrationsTable)) {
            return true;
        }

        // Initialize the variables used to define the table
        $data       = array();
        $fields     = array();
        $primaryKey = '';

        $fields = array(
            'type' => array(
                'type'       => 'varchar',
                'constraint' => 40,
                'null'       => false,
            ),
            'version' => array(
                'type'       => 'int',
                'constraint' => 4,
                'default'    => 0,
            ),
        );
        $primaryKey = 'type';
        $data = array(
            'type'    => self::CORE_MIGRATIONS,
            'version' => 0,
        );
        
        // If $fields and $primaryKey aren't set, the table won't be created.
        if (empty($fields) || empty($primaryKey)) {
            $this->setError('Invalid schema selected for creation of migrations table.');
            return false;
        }

        // Load DBForge and create the schema version table
        $this->_ci->load->dbforge();
        $this->_ci->dbforge->add_field($fields);
        $this->_ci->dbforge->add_key($primaryKey, true);
        $this->_ci->dbforge->create_table($this->migrationsTable, true);

        // If $data has been set, insert it into the table
        if (! empty($data)) {
            $this->_ci->db->insert($this->migrationsTable, $data);
        }

        // Set the schema version for reference later
        self::$migrationsSchemaVersion = $schemaVersion;
        return true;
    }

    /**
     * Retrieve the latest available version.
     *
     * @param string $type A string that represents the name of the module, or
     * 'app_' for application migrations. If empty, it returns core migrations.
     *
     * @return int Version number of the latest available migration file.
     */
    private function getLatestVersion($type = '')
    {
        $migrations = $this->getAvailableVersions($type);
        if (empty($migrations)) {
            return 0;
        }

        $lastMigration = end($migrations);
        if ($lastMigration === false) {
            return 0;
        }

        $lastVersion = substr($lastMigration, 0, 3);

        return intval($lastVersion, 10);
    }

    /**
     * Determine which version of the database table for migrations is currently
     * in use.
     *
     * @return int    A number indicating the version of the table in use
     */
    private function getLibraryVersion()
    {
        return self::$migrationsSchemaVersion;
    }

    /**
     * Store the current schema version in the database.
     *
     * @param int    $version   An integer with the latest Schema version
     * reached
     * @param string $type      A string that is appended with '_schema' to
     * create the field name to store in the database.
     *
     * @return void
     */
    private function updateVersion($version, $type = '')
    {
        logit("[Migrations] Schema {$type} updated to: {$version}");
      
        // default to core
        $type = empty($type) ? self::CORE_MIGRATIONS : $type;

        // Get the current version for this type
        $currentVersion = $this->getVersion($type);

        // Remove the row for this type when moving down to 0
        if ($version == 0) {
            if (empty($currentVersion)) {
                // If the version was not found, just exit
                return;
            }

            // If the version was found, remove it
            $result = $this->_ci->db->delete(
                $this->migrationsTable,
                array('type' => $type)
            );

            // Cache the version
            if ($result) {
                self::$schemaVersion[$type] = $version;
            }

            return $result;
        }

        // When moving to a version other than 0...
        // If the version was not found, insert it
        if (empty($currentVersion) && $currentVersion !== '0') {
            $result = $this->_ci->db->insert(
                $this->migrationsTable,
                array(
                    'type'    => $type,
                    'version' => $version,
                )
            );
        } else {
            // If the version was found, update it
            $result = $this->_ci->db->update(
                $this->migrationsTable,
                array('version' => $version),
                array('type'    => $type)
            );
        }

        // Cache the version
        if ($result) {
            self::$schemaVersion[$type] = $version;
        }

        return $result;
    }   
}
