<?php namespace RainLab\Builder\Classes;

use Db;
use Str;
use File;
use Doctrine\DBAL\Schema\Comparator;
use Doctrine\DBAL\Schema\TableDiff;
use October\Rain\Parse\Bracket as TextParser;

/**
 * Generates migration code for creating, updates and deleting tables.
 *
 * @package rainlab\builder
 * @author Alexey Bobkov, Samuel Georges
 */
class TableMigrationCodeGenerator extends BaseModel
{
    const COLUMN_MODE_CREATE = 'create';
    const COLUMN_MODE_CHANGE = 'change';
    const COLUMN_MODE_REVERT = 'revert';

    protected $indent = '    ';

    protected $eol = PHP_EOL;

    /**
     * Generates code for creating or updating a database table.
     * @param \Doctrine\DBAL\Schema\Table $updatedTable Specifies the updated table schema.
     * @param \Doctrine\DBAL\Schema\Table $existingTable Specifies the existing table schema, if applicable.
     * @param string $newTableName An updated name of the theme.
     * @return string|boolean Returns the migration up() and down() methods code.
     * Returns false if there the table was not changed.
     */
    public function createOrUpdateTable($updatedTable, $existingTable, $newTableName)
    {
        $tableDiff = false;

        // The table already exists
        if ($existingTable !== null) {
            $comparator = new Comparator;
            $tableDiff = $comparator->diffTable($existingTable, $updatedTable);

            // Remove database prefix
            $existingTableName = substr($existingTable->getName(), mb_strlen(Db::getTablePrefix()));

            if ($newTableName !== $existingTableName) {
                if (!$tableDiff) {
                    $tableDiff = new TableDiff($existingTableName);
                }

                $tableDiff->newName = $newTableName;
            }
        }
        // The table doesn't exist
        else {
            $tableDiff = new TableDiff(
                $updatedTable->getName(),
                $updatedTable->getColumns(),
                [], // Changed columns
                [], // Removed columns
                $updatedTable->getIndexes() // Added indexes
            );

            $tableDiff->fromTable = $updatedTable;
        }

        if (!$tableDiff) {
            return false;
        }

        if (!$this->tableHasNameOrColumnChanges($tableDiff) && !$this->tableHasPrimaryKeyChanges($tableDiff)) {
            return false;
        }

        return $this->generateCreateOrUpdateCode($tableDiff, !$existingTable, $updatedTable);
    }

    /**
     * Wrap migration's up() and down() functions into a complete migration class declaration
     * @param string $scriptFilename Specifies the migration script file name
     * @param string $code Specifies the migration code
     * @param PluginCode $pluginCodeObj The plugin code object
     * @return TextParser
     */
    public function wrapMigrationCode($scriptFilename, $code, $pluginCodeObj)
    {
        $templatePath = '$/rainlab/builder/classes/databasetablemodel/templates/full-migration-code.php.tpl';
        $templatePath = File::symbolizePath($templatePath);

        $fileContents = File::get($templatePath);

        return TextParser::parse($fileContents, [
            'className' => Str::studly($scriptFilename),
            'migrationCode' => $this->indent($code),
            'namespace' => $pluginCodeObj->toUpdatesNamespace()
        ]);
    }

    /**
     * Generates code for dropping a database table.
     * @param \Doctrine\DBAL\Schema\Table $existingTable Specifies the existing table schema.
     * @return string Returns the migration up() and down() methods code.
     */
    public function dropTable($existingTable)
    {
        return $this->generateMigrationCode(
            $this->generateDropUpCode($existingTable),
            $this->generateDropDownCode($existingTable)
        );
    }

    protected function generateCreateOrUpdateCode($tableDiff, $isNewTable, $newOrUpdatedTable)
    {
        /*
         * Although it might seem that a reverse diff could be used
         * for the down() method, that's not so. The up and down operations
         * are not fully symmetrical.
         */

        return $this->generateMigrationCode(
            $this->generateCreateOrUpdateUpCode($tableDiff, $isNewTable, $newOrUpdatedTable),
            $this->generateCreateOrUpdateDownCode($tableDiff, $isNewTable, $newOrUpdatedTable)
        );
    }

    protected function generateMigrationCode($upCode, $downCode)
    {
        $templatePath = '$/rainlab/builder/classes/databasetablemodel/templates/migration-code.php.tpl';
        $templatePath = File::symbolizePath($templatePath);

        $fileContents = File::get($templatePath);

        return TextParser::parse($fileContents, [
            'upCode' => $upCode,
            'downCode' => $downCode
        ]);
    }

    protected function generateCreateOrUpdateUpCode($tableDiff, $isNewTable, $newOrUpdatedTable)
    {
        $result = null;

        $hasColumnChanges = $this->tableHasNameOrColumnChanges($tableDiff, true);
        $changedPrimaryKey = $this->getChangedOrRemovedPrimaryKey($tableDiff);
        $addedPrimaryKey = $this->findPrimaryKeyIndex($tableDiff->addedIndexes, $newOrUpdatedTable);

        if ($tableDiff->getNewName()) {
            $result .= $this->generateTableRenameCode($tableDiff->name, $tableDiff->newName);

            if ($hasColumnChanges || $changedPrimaryKey) {
                $result .= $this->eol;
            }
        }

        if (!$hasColumnChanges && !$changedPrimaryKey && !$addedPrimaryKey) {
            return $this->makeTabs($result);
        }

        $tableName = $tableDiff->getNewName() ? $tableDiff->newName : $tableDiff->name;
        $result .= $this->generateSchemaTableMethodStart($tableName, $isNewTable);

        if ($changedPrimaryKey) {
            $result .= $this->generatePrimaryKeyDrop($tableDiff->fromTable);
        }

        foreach ($tableDiff->addedColumns as $column) {
            $result .= $this->generateColumnCode($column, self::COLUMN_MODE_CREATE);
        }

        foreach ($tableDiff->changedColumns as $columnDiff) {
            $result .= $this->generateColumnCode($columnDiff, self::COLUMN_MODE_CHANGE);
        }

        foreach ($tableDiff->renamedColumns as $oldName => $column) {
            $result .= $this->generateColumnRenameCode($oldName, $column->getName());
        }

        foreach ($tableDiff->removedColumns as $name => $column) {
            $result .= $this->generateColumnRemoveCode($name);
        }

        $primaryKey = $changedPrimaryKey ?
            $this->findPrimaryKeyIndex($tableDiff->changedIndexes, $newOrUpdatedTable) :
            $this->findPrimaryKeyIndex($tableDiff->addedIndexes, $newOrUpdatedTable);

        if ($primaryKey) {
            $result .= $this->generatePrimaryKeyCode($primaryKey, self::COLUMN_MODE_CREATE);
        }

        $result .= $this->generateSchemaTableMethodEnd();

        return $this->makeTabs($result);
    }

    protected function generateCreateOrUpdateDownCode($tableDiff, $isNewTable, $newOrUpdatedTable)
    {
        $result = '';

        if ($isNewTable) {
            $result = $this->generateTableDropCode($tableDiff->name);
        } else {
            $changedPrimaryKey = $this->getChangedOrRemovedPrimaryKey($tableDiff);
            $addedPrimaryKey = $this->findPrimaryKeyIndex($tableDiff->addedIndexes, $newOrUpdatedTable);

            if ($this->tableHasNameOrColumnChanges($tableDiff) || $changedPrimaryKey || $addedPrimaryKey) {
                $hasColumnChanges = $this->tableHasNameOrColumnChanges($tableDiff, true);

                if ($tableDiff->getNewName()) {
                    $result .= $this->generateTableRenameCode($tableDiff->newName, $tableDiff->name);

                    if ($hasColumnChanges || $changedPrimaryKey || $addedPrimaryKey) {
                        $result .= $this->eol;
                    }
                }

                if (!$hasColumnChanges && !$changedPrimaryKey && !$addedPrimaryKey) {
                    return $this->makeTabs($result);
                }

                $result .= $this->generateSchemaTableMethodStart($tableDiff->name, $isNewTable);

                if ($changedPrimaryKey || $addedPrimaryKey) {
                    $result .= $this->generatePrimaryKeyDrop($newOrUpdatedTable);
                }

                foreach ($tableDiff->addedColumns as $column) {
                    $result .= $this->generateColumnDrop($column);
                }

                foreach ($tableDiff->changedColumns as $columnDiff) {
                    $result .= $this->generateColumnCode($columnDiff, self::COLUMN_MODE_REVERT);
                }

                foreach ($tableDiff->renamedColumns as $oldName => $column) {
                    $result .= $this->generateColumnRenameCode($column->getName(), $oldName);
                }

                foreach ($tableDiff->removedColumns as $name => $column) {
                    $result .= $this->generateColumnCode($column, self::COLUMN_MODE_CREATE);
                }

                if ($changedPrimaryKey || $addedPrimaryKey) {
                    $primaryKey = $this->findPrimaryKeyIndex($tableDiff->fromTable->getIndexes(), $tableDiff->fromTable);
                    if ($primaryKey) {
                        $result .= $this->generatePrimaryKeyCode($primaryKey, self::COLUMN_MODE_CREATE);
                    }
                }

                $result .= $this->generateSchemaTableMethodEnd();
            }
        }

        return $this->makeTabs($result);
    }

    protected function generateDropUpCode($table)
    {
        $result = $this->generateTableDropCode($table->getName());
        return $this->makeTabs($result);
    }

    protected function generateDropDownCode($table)
    {
        $tableDiff = new TableDiff(
            $table->getName(),
            $table->getColumns(),
            [], // Changed columns
            [], // Removed columns
            $table->getIndexes() // Added indexes
        );

        return $this->generateCreateOrUpdateUpCode($tableDiff, true, $table);
    }

    protected function formatLengthParameters($column, $method)
    {
        $length = $column->getLength();
        $precision = $column->getPrecision();
        $scale = $column->getScale();

        if (!strlen($length) && !strlen($precision)) {
            return null;
        }

        if ($method == MigrationColumnType::TYPE_STRING) {
            if (!strlen($length)) {
                return null;
            }

            return ', '.$length;
        }

        if ($method == MigrationColumnType::TYPE_DECIMAL || $method == MigrationColumnType::TYPE_DOUBLE) {
            if (!strlen($precision)) {
                return null;
            }

            if (strlen($scale)) {
                return ', '.$precision.', '.$scale;
            }

            return ', '.$precision;
        }
    }

    protected function applyMethodIncrements($method, $column)
    {
        if (!$column->getAutoincrement()) {
            return $method;
        }

        if ($method == MigrationColumnType::TYPE_BIGINTEGER) {
            return 'bigIncrements';
        }

        return 'increments';
    }

    protected function generateSchemaTableMethodStart($tableName, $isNewTable)
    {
        $tableFunction = $isNewTable ? 'create' : 'table';
        $result = sprintf('\tSchema::%s(\'%s\', function($table)', $tableFunction, $tableName).$this->eol;
        $result .= '\t{'.$this->eol;

        if ($isNewTable) {
            $result .= '\t\t$table->engine = \'InnoDB\';'.$this->eol;
        }

        return $result;
    }

    protected function generateSchemaTableMethodEnd()
    {
        return '\t});';
    }

    protected function generateColumnDrop($column)
    {
        return sprintf('\t\t$table->dropColumn(\'%s\');', $column->getName()).$this->eol;
    }

    protected function generateIndexDrop($index)
    {
        return sprintf('\t\t$table->dropIndex(\'%s\');', $index->getName()).$this->eol;
    }

    protected function generatePrimaryKeyDrop($table)
    {
        $index = $this->findPrimaryKeyIndex($table->getIndexes(), $table);
        if (!$index) {
            return;
        }

        $indexColumns = $index->getColumns();
        return sprintf('\t\t$table->dropPrimary([%s]);', $this->implodeColumnList($indexColumns)).$this->eol;
    }

    protected function generateColumnCode($columnData, $mode)
    {
        $forceFlagsChange = false;

        switch ($mode) {
            case self::COLUMN_MODE_CREATE:
                $column = $columnData;
                $changeMode = false;
                break;
            case self::COLUMN_MODE_CHANGE:
                $column = $columnData->column;
                $changeMode = true;

                $forceFlagsChange = in_array('type', $columnData->changedProperties);
                break;
            case self::COLUMN_MODE_REVERT:
                $column = $columnData->fromColumn;
                $changeMode = true;

                $forceFlagsChange = in_array('type', $columnData->changedProperties);
                break;
        }

        $result = $this->generateColumnMethodCall($column);
        $result .= $this->generateNullable($column, $changeMode, $columnData, $forceFlagsChange);
        $result .= $this->generateUnsigned($column, $changeMode, $columnData, $forceFlagsChange);
        $result .= $this->generateDefault($column, $changeMode, $columnData, $forceFlagsChange);
        $result .= $this->generateComment($column, $changeMode, $columnData, $forceFlagsChange);

        if ($changeMode) {
            $result .= '->change()';
        }

        $result .= ';'.$this->eol;

        return $result;
    }

    protected function generateColumnRenameCode($fromName, $toName)
    {
        return sprintf('\t\t$table->renameColumn(\'%s\', \'%s\');', $fromName, $toName).$this->eol;
    }

    protected function generateTableRenameCode($fromName, $toName)
    {
        return sprintf('\tSchema::rename(\'%s\', \'%s\');', $fromName, $toName);
    }

    protected function generateTableDropCode($name)
    {
        return sprintf('\tSchema::dropIfExists(\'%s\');', $name);
    }

    protected function generateColumnRemoveCode($name)
    {
        return sprintf('\t\t$table->dropColumn(\'%s\');', $name).$this->eol;
    }

    protected function generateColumnMethodCall($column)
    {
        $columnName = $column->getName();
        $typeName = $column->getType()->getName();

        $method = MigrationColumnType::toMigrationMethodName($typeName, $columnName);
        $method = $this->applyMethodIncrements($method, $column);

        $lengthStr = $this->formatLengthParameters($column, $method);

        return sprintf('\t\t$table->%s(\'%s\'%s)', $method, $columnName, $lengthStr);
    }

    protected function generateNullable($column, $changeMode, $columnData, $forceFlagsChange)
    {
        $result = null;

        if (!$changeMode) {
            if (!$column->getNotnull()) {
                $result = $this->generateBooleanMethod('nullable', true);
            }
        }
        elseif (in_array('notnull', $columnData->changedProperties) || $forceFlagsChange) {
            $result = $this->generateBooleanMethod('nullable', !$column->getNotnull());
        }

        return $result;
    }

    protected function generateUnsigned($column, $changeMode, $columnData, $forceFlagsChange)
    {
        $result = null;

        if (!$changeMode) {
            if ($column->getUnsigned()) {
                $result = $this->generateBooleanMethod('unsigned', true);
            }
        }
        elseif (in_array('unsigned', $columnData->changedProperties) || $forceFlagsChange) {
            $result = $this->generateBooleanMethod('unsigned', $column->getUnsigned());
        }

        return $result;
    }

    protected function generateDefault($column, $changeMode, $columnData, $forceFlagsChange)
    {
        /*
         * See a note about empty strings as default values in
         * DatabaseTableSchemaCreator::formatOptions() method.
         */
        $result = null;
        $default = $column->getDefault();

        if (!$changeMode) {
            if (strlen($default)) {
                $result = $this->generateDefaultMethodCall($default, $column);
            }
        } elseif (in_array('default', $columnData->changedProperties) || $forceFlagsChange) {
            if (strlen($default)) {
                $result = $this->generateDefaultMethodCall($default, $column);
            } elseif ($changeMode) {
                $result = sprintf('->default(null)');
            }
        }

        return $result;
    }

    protected function generateDefaultMethodCall($default, $column)
    {
        $columnName = $column->getName();
        $typeName = $column->getType()->getName();

        $type = MigrationColumnType::toMigrationMethodName($typeName, $columnName);

        if (in_array($type, MigrationColumnType::getIntegerTypes()) ||
            in_array($type, MigrationColumnType::getDecimalTypes()) ||
            $type == MigrationColumnType::TYPE_BOOLEAN) {
            return sprintf('->default(%s)', $default);
        }

        return sprintf('->default(\'%s\')', $this->quoteParameter($default));
    }

    protected function generateComment($column, $changeMode, $columnData, $forceFlagsChange)
    {
        $result = null;
        $default = $column->getComment();

        if (!$changeMode) {
            if (strlen($default)) {
                $result = $this->generateCommentMethodCall($default, $column);
            }
        }
        elseif (in_array('comment', $columnData->changedProperties) || $forceFlagsChange) {
            if (strlen($default)) {
                $result = $this->generateCommentMethodCall($default, $column);
            }
            elseif ($changeMode) {
                $result = sprintf('->comment(null)');
            }
        }

        return $result;
    }

    protected function generateCommentMethodCall($default, $column)
    {
        return sprintf('->comment(\'%s\')', $this->quoteParameter($default));
    }

    protected function generatePrimaryKeyCode($index)
    {
        $columns = $index->getColumns();

        return sprintf('\t\t$table->primary([%s]);', $this->implodeColumnList($columns)).$this->eol;
    }

    protected function generateBooleanString($value)
    {
        $result = $value ? 'true' : 'false';

        return $result;
    }

    protected function generateBooleanMethod($methodName, $value)
    {
        if ($value) {
            return '->'.$methodName.'()';
        }

        return '->'.$methodName.'('.$this->generateBooleanString($value).')';
    }

    protected function quoteParameter($str)
    {
        return str_replace("'", "\'", $str);
    }

    protected function makeTabs($str)
    {
        return str_replace('\t', '    ', $str);
    }

    protected function indent($str)
    {
        return $this->indent . str_replace($this->eol, $this->eol . $this->indent, $str);
    }

    protected function implodeColumnList($columnNames)
    {
        foreach ($columnNames as &$columnName) {
            $columnName = '\''.$columnName.'\'';
        }

        return implode(',', $columnNames);
    }

    protected function tableHasNameOrColumnChanges($tableDiff, $columnChangesOnly = false)
    {
        $result = $tableDiff->addedColumns
                || $tableDiff->changedColumns
                || $tableDiff->removedColumns
                || $tableDiff->renamedColumns;

        if ($columnChangesOnly) {
            return $result;
        }

        return $result || $tableDiff->getNewName();
    }

    protected function tableHasPrimaryKeyChanges($tableDiff)
    {
        return $this->findPrimaryKeyIndex($tableDiff->addedIndexes, $tableDiff->fromTable) ||
            $this->findPrimaryKeyIndex($tableDiff->changedIndexes, $tableDiff->fromTable) ||
            $this->findPrimaryKeyIndex($tableDiff->removedIndexes, $tableDiff->fromTable);
    }

    protected function getChangedOrRemovedPrimaryKey($tableDiff)
    {
        foreach ($tableDiff->changedIndexes as $index) {
            if ($index->isPrimary()) {
                return $index;
            }
        }

        foreach ($tableDiff->removedIndexes as $index) {
            if ($index->isPrimary()) {
                return $index;
            }
        }

        return null;
    }

    protected function findPrimaryKeyIndex($indexes, $table)
    {
        /*
         * This method ignores auto-increment primary keys
         * as they are managed with the increments() method
         * instead of the primary().
         */
        foreach ($indexes as $index) {
            if (!$index->isPrimary()) {
                continue;
            }

            if ($this->indexHasAutoincrementColumns($index, $table)) {
                continue;
            }

            return $index;
        }

        return null;
    }

    protected function indexHasAutoincrementColumns($index, $table)
    {
        $indexColumns = $index->getColumns();

        foreach ($indexColumns as $indexColumn) {
            if (!$table->hasColumn($indexColumn)) {
                continue;
            }

            $tableColumn = $table->getColumn($indexColumn);
            if ($tableColumn->getAutoincrement()) {
                return true;
            }
        }

        return false;
    }
}
