<?php

namespace Engelsystem\Database\Migration;

use Engelsystem\Application;
use Exception;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;

class Migrate
{
    /** @var string */
    public const UP = 'up';

    /** @var string */
    public const DOWN = 'down';

    /** @var Application */
    protected $app;

    /** @var SchemaBuilder */
    protected $schema;

    /** @var callable */
    protected $output;

    /** @var string */
    protected $table = 'migrations';

    /**
     * Migrate constructor
     *
     * @param SchemaBuilder $schema
     * @param Application   $app
     */
    public function __construct(SchemaBuilder $schema, Application $app)
    {
        $this->app = $app;
        $this->schema = $schema;
        $this->output = function () {
        };
    }

    /**
     * Run a migration
     *
     * @param string $path
     * @param string $type (up|down)
     * @param bool   $oneStep
     * @param bool   $forceMigration
     */
    public function run($path, $type = self::UP, $oneStep = false, $forceMigration = false)
    {
        $this->initMigration();

        $this->lockTable($forceMigration);
        $migrations = $this->mergeMigrations(
            $this->getMigrations($path),
            $this->getMigrated()
        );

        if ($type == self::DOWN) {
            $migrations = $migrations->reverse();
        }

        try {
            foreach ($migrations as $migration) {
                /** @var array $migration */
                $name = $migration['migration'];

                if (
                    ($type == self::UP && isset($migration['id']))
                    || ($type == self::DOWN && !isset($migration['id']))
                ) {
                    ($this->output)('Skipping ' . $name);
                    continue;
                }

                ($this->output)('Migrating ' . $name . ' (' . $type . ')');

                if (isset($migration['path'])) {
                    $this->migrate($migration['path'], $name, $type);
                }
                $this->setMigrated($name, $type);

                if ($oneStep) {
                    break;
                }
            }
        } catch (Throwable $e) {
            $this->unlockTable();

            throw $e;
        }

        $this->unlockTable();
    }

    /**
     * Setup migration tables
     */
    public function initMigration()
    {
        if ($this->schema->hasTable($this->table)) {
            return;
        }

        $this->schema->create($this->table, function (Blueprint $table) {
            $table->increments('id');
            $table->string('migration');
        });
    }

    /**
     * Merge file migrations with already migrated tables
     *
     * @param Collection $migrations
     * @param Collection $migrated
     * @return Collection
     */
    protected function mergeMigrations(Collection $migrations, Collection $migrated)
    {
        $return = $migrated;
        $return->transform(function ($migration) use ($migrations) {
            $migration = (array)$migration;
            if ($migrations->contains('migration', $migration['migration'])) {
                $migration += $migrations
                    ->where('migration', $migration['migration'])
                    ->first();
            }

            return $migration;
        });

        $migrations->each(function ($migration) use ($return) {
            if ($return->contains('migration', $migration['migration'])) {
                return;
            }

            $return->add($migration);
        });

        return $return;
    }

    /**
     * Get all migrated migrations
     *
     * @return Collection
     */
    protected function getMigrated()
    {
        return $this->getTableQuery()
            ->orderBy('id')
            ->where('migration', '!=', 'lock')
            ->get();
    }

    /**
     * Migrate a migration
     *
     * @param string $file
     * @param string $migration
     * @param string $type (up|down)
     */
    protected function migrate($file, $migration, $type = self::UP)
    {
        require_once $file;

        $className = Str::studly(preg_replace('/\d+_/', '', $migration));
        /** @var Migration $class */
        $class = $this->app->make('Engelsystem\\Migrations\\' . $className);

        if (method_exists($class, $type)) {
            $class->{$type}();
        }
    }

    /**
     * Set a migration to migrated
     *
     * @param string $migration
     * @param string $type (up|down)
     */
    protected function setMigrated($migration, $type = self::UP)
    {
        $table = $this->getTableQuery();

        if ($type == self::DOWN) {
            $table->where(['migration' => $migration])->delete();
            return;
        }

        $table->insert(['migration' => $migration]);
    }

    /**
     * Lock the migrations table
     *
     * @param bool $forceMigration
     *
     * @throws Throwable
     */
    protected function lockTable($forceMigration = false)
    {
        $this->schema->getConnection()->transaction(function () use ($forceMigration) {
            $lock = $this->getTableQuery()
                ->where('migration', 'lock')
                ->lockForUpdate()
                ->first();

            if ($lock && !$forceMigration) {
                throw new Exception('Unable to acquire migration table lock');
            }

            $this->getTableQuery()
                ->insert(['migration' => 'lock']);
        });
    }

    /**
     * Unlock a previously locked table
     */
    protected function unlockTable()
    {
        $this->getTableQuery()
            ->where('migration', 'lock')
            ->delete();
    }

    /**
     * Get a list of migration files
     *
     * @param string $dir
     *
     * @return Collection
     */
    protected function getMigrations($dir)
    {
        $files = $this->getMigrationFiles($dir);

        $migrations = new Collection();
        foreach ($files as $dir) {
            $name = str_replace('.php', '', basename($dir));
            $migrations[] = [
                'migration' => $name,
                'path'      => $dir,
            ];
        }

        return $migrations->sortBy(function ($value) {
            return $value['migration'];
        });
    }

    /**
     * List all migration files from the given directory
     *
     * @param string $dir
     *
     * @return array
     */
    protected function getMigrationFiles($dir)
    {
        return glob($dir . '/*_*.php');
    }

    /**
     * Init a table query
     *
     * @return Builder
     */
    protected function getTableQuery()
    {
        return $this->schema->getConnection()->table($this->table);
    }

    /**
     * Set the output function
     *
     * @param callable $output
     */
    public function setOutput(callable $output)
    {
        $this->output = $output;
    }
}
