<?php

declare(strict_types=1);

namespace NunoMaduro\Larastan\Properties;

use Illuminate\Support\Str;
use PhpParser;
use PhpParser\NodeFinder;
use PHPStan\Type\ObjectType;

/**
 * @see https://github.com/psalm/laravel-psalm-plugin/blob/master/src/SchemaAggregator.php
 */
final class SchemaAggregator
{
    /** @var array<string, SchemaTable> */
    public array $tables = [];

    /** @param array<string, SchemaTable> $tables */
    public function __construct(array $tables = [])
    {
        $this->tables = $tables;
    }

    /**
     * @param  array<int, PhpParser\Node\Stmt>  $stmts
     */
    public function addStatements(array $stmts): void
    {
        $nodeFinder = new NodeFinder();

        /** @var PhpParser\Node\Stmt\Class_[] $classes */
        $classes = $nodeFinder->findInstanceOf($stmts, PhpParser\Node\Stmt\Class_::class);

        foreach ($classes as $stmt) {
            $this->addClassStatements($stmt->stmts);
        }
    }

    /**
     * @param  array<int, PhpParser\Node\Stmt>  $stmts
     */
    private function addClassStatements(array $stmts): void
    {
        foreach ($stmts as $stmt) {
            if ($stmt instanceof PhpParser\Node\Stmt\ClassMethod
                && $stmt->name->name !== 'down'
                && $stmt->stmts
            ) {
                $this->addUpMethodStatements($stmt->stmts);
            }
        }
    }

    /**
     * @param  PhpParser\Node\Stmt[]  $stmts
     */
    private function addUpMethodStatements(array $stmts): void
    {
        $nodeFinder = new NodeFinder();
        $methods = $nodeFinder->findInstanceOf($stmts, PhpParser\Node\Stmt\Expression::class);

        foreach ($methods as $stmt) {
            if ($stmt instanceof PhpParser\Node\Stmt\Expression
                && $stmt->expr instanceof PhpParser\Node\Expr\MethodCall
                && $stmt->expr->var instanceof PhpParser\Node\Expr\StaticCall
                && $stmt->expr->var->class instanceof PhpParser\Node\Name
                && $stmt->expr->var->name instanceof PhpParser\Node\Identifier
                && ($stmt->expr->var->name->toString() === 'connection' || $stmt->expr->var->name->toString() === 'setConnection')
                && ($stmt->expr->var->class->toCodeString() === '\Schema' || (new ObjectType('Illuminate\Support\Facades\Schema'))->isSuperTypeOf(new ObjectType($stmt->expr->var->class->toCodeString()))->yes())
            ) {
                $statement = $stmt->expr;
            } elseif ($stmt instanceof PhpParser\Node\Stmt\Expression
                && $stmt->expr instanceof PhpParser\Node\Expr\StaticCall
                && $stmt->expr->class instanceof PhpParser\Node\Name
                && $stmt->expr->name instanceof PhpParser\Node\Identifier
                && ($stmt->expr->class->toCodeString() === '\Schema' || (new ObjectType('Illuminate\Support\Facades\Schema'))->isSuperTypeOf(new ObjectType($stmt->expr->class->toCodeString()))->yes())
            ) {
                $statement = $stmt->expr;
            } else {
                continue;
            }

            if (! $statement->name instanceof PhpParser\Node\Identifier) {
                continue;
            }

            switch ($statement->name->name) {
                case 'create':
                    $this->alterTable($statement, true);
                    break;

                case 'table':
                    $this->alterTable($statement, false);
                    break;

                case 'drop':
                case 'dropIfExists':
                    $this->dropTable($statement);
                    break;

                case 'rename':
                    $this->renameTableThroughStaticCall($statement);
            }
        }
    }

    private function alterTable(PhpParser\Node\Expr\StaticCall|PhpParser\Node\Expr\MethodCall $call, bool $creating): void
    {
        if (! isset($call->args[0])
            || ! $call->getArgs()[0]->value instanceof PhpParser\Node\Scalar\String_
        ) {
            return;
        }

        $tableName = $call->getArgs()[0]->value->value;

        if ($creating) {
            $this->tables[$tableName] = new SchemaTable($tableName);
        }

        if (! isset($call->args[1])
            || ! $call->getArgs()[1]->value instanceof PhpParser\Node\Expr\Closure
            || count($call->getArgs()[1]->value->params) < 1
            || ($call->getArgs()[1]->value->params[0]->type instanceof PhpParser\Node\Name
                && ! (new ObjectType('Illuminate\Database\Schema\Blueprint'))->isSuperTypeOf(new ObjectType($call->getArgs()[1]->value->params[0]->type->toCodeString()))->yes()
            )
        ) {
            return;
        }

        $updateClosure = $call->getArgs()[1]->value;

        if ($call->getArgs()[1]->value->params[0]->var instanceof PhpParser\Node\Expr\Variable
            && is_string($call->getArgs()[1]->value->params[0]->var->name)
        ) {
            $argName = $call->getArgs()[1]->value->params[0]->var->name;

            $this->processColumnUpdates($tableName, $argName, $updateClosure->stmts);
        }
    }

    /**
     * @param  string  $tableName
     * @param  string  $argName
     * @param  PhpParser\Node\Stmt[]  $stmts
     *
     * @throws \Exception
     */
    private function processColumnUpdates(string $tableName, string $argName, array $stmts): void
    {
        if (! isset($this->tables[$tableName])) {
            return;
        }

        $table = $this->tables[$tableName];

        foreach ($stmts as $stmt) {
            if ($stmt instanceof PhpParser\Node\Stmt\Expression
                && $stmt->expr instanceof PhpParser\Node\Expr\MethodCall
                && $stmt->expr->name instanceof PhpParser\Node\Identifier
            ) {
                $rootVar = $stmt->expr;

                $firstMethodCall = $rootVar;

                $nullable = false;

                while ($rootVar instanceof PhpParser\Node\Expr\MethodCall) {
                    if ($rootVar->name instanceof PhpParser\Node\Identifier
                        && $rootVar->name->name === 'nullable'
                    ) {
                        $nullable = true;
                    }

                    $firstMethodCall = $rootVar;
                    $rootVar = $rootVar->var;
                }

                if ($rootVar instanceof PhpParser\Node\Expr\Variable
                    && $rootVar->name === $argName
                    && $firstMethodCall->name instanceof PhpParser\Node\Identifier
                ) {
                    $firstArg = $firstMethodCall->getArgs()[0]->value ?? null;
                    $secondArg = $firstMethodCall->getArgs()[1]->value ?? null;

                    if ($firstMethodCall->name->name === 'foreignIdFor') {
                        if ($firstArg instanceof PhpParser\Node\Expr\ClassConstFetch
                            && $firstArg->class instanceof PhpParser\Node\Name
                        ) {
                            $modelClass = $firstArg->class->toCodeString();
                        } elseif ($firstArg instanceof PhpParser\Node\Scalar\String_) {
                            $modelClass = $firstArg->value;
                        } else {
                            continue;
                        }

                        $columnName = Str::snake(class_basename($modelClass)).'_id';
                        if ($secondArg instanceof PhpParser\Node\Scalar\String_) {
                            $columnName = $secondArg->value;
                        }

                        $table->setColumn(new SchemaColumn($columnName, 'int', $nullable));

                        continue;
                    }

                    if (! $firstArg instanceof PhpParser\Node\Scalar\String_) {
                        if ($firstMethodCall->name->name === 'timestamps'
                            || $firstMethodCall->name->name === 'timestampsTz'
                            || $firstMethodCall->name->name === 'nullableTimestamps'
                            || $firstMethodCall->name->name === 'nullableTimestampsTz'
                            || $firstMethodCall->name->name === 'rememberToken'
                        ) {
                            switch (strtolower($firstMethodCall->name->name)) {
                                case 'droptimestamps':
                                case 'droptimestampstz':
                                    $table->dropColumn('created_at');
                                    $table->dropColumn('updated_at');
                                    break;

                                case 'remembertoken':
                                    $table->setColumn(new SchemaColumn('remember_token', 'string', $nullable));
                                    break;

                                case 'dropremembertoken':
                                    $table->dropColumn('remember_token');
                                    break;

                                case 'timestamps':
                                case 'timestampstz':
                                case 'nullabletimestamps':
                                    $table->setColumn(new SchemaColumn('created_at', 'string', true));
                                    $table->setColumn(new SchemaColumn('updated_at', 'string', true));
                                    break;
                            }

                            continue;
                        }

                        $defaultsMap = [
                            'softDeletes' => 'deleted_at',
                            'softDeletesTz' => 'deleted_at',
                            'dropSoftDeletes' => 'deleted_at',
                            'dropSoftDeletesTz' => 'deleted_at',
                            'uuid' => 'uuid',
                        ];
                        if (array_key_exists($firstMethodCall->name->name, $defaultsMap)) {
                            $columnName = $defaultsMap[$firstMethodCall->name->name];
                        } else {
                            continue;
                        }
                    } else {
                        $columnName = $firstArg->value;
                    }

                    $secondArgArray = null;

                    if ($secondArg instanceof PhpParser\Node\Expr\Array_) {
                        $secondArgArray = [];

                        foreach ($secondArg->items as $array_item) {
                            if ($array_item !== null && $array_item->value instanceof PhpParser\Node\Scalar\String_) {
                                $secondArgArray[] = $array_item->value->value;
                            }
                        }
                    }

                    switch (strtolower($firstMethodCall->name->name)) {
                        case 'biginteger':
                        case 'increments':
                        case 'integer':
                        case 'integerincrements':
                        case 'mediumincrements':
                        case 'mediuminteger':
                        case 'smallincrements':
                        case 'smallinteger':
                        case 'tinyincrements':
                        case 'tinyinteger':
                        case 'unsignedbiginteger':
                        case 'unsignedinteger':
                        case 'unsignedmediuminteger':
                        case 'unsignedsmallinteger':
                        case 'unsignedtinyinteger':
                        case 'bigincrements':
                        case 'foreignid':
                            $table->setColumn(new SchemaColumn($columnName, 'int', $nullable));
                            break;

                        case 'char':
                        case 'datetimetz':
                        case 'date':
                        case 'datetime':
                        case 'ipaddress':
                        case 'json':
                        case 'jsonb':
                        case 'linestring':
                        case 'longtext':
                        case 'macaddress':
                        case 'mediumtext':
                        case 'multilinestring':
                        case 'string':
                        case 'text':
                        case 'time':
                        case 'timestamp':
                        case 'uuid':
                        case 'binary':
                            $table->setColumn(new SchemaColumn($columnName, 'string', $nullable));
                            break;

                        case 'boolean':
                            $table->setColumn(new SchemaColumn($columnName, 'bool', $nullable));
                            break;

                        case 'geometry':
                        case 'geometrycollection':
                        case 'multipoint':
                        case 'multipolygon':
                        case 'multipolygonz':
                        case 'point':
                        case 'polygon':
                        case 'computed':
                            $table->setColumn(new SchemaColumn($columnName, 'mixed', $nullable));
                            break;

                        case 'double':
                        case 'float':
                        case 'unsigneddecimal':
                        case 'decimal':
                            $table->setColumn(new SchemaColumn($columnName, 'float', $nullable));
                            break;

                        case 'after':
                            if ($secondArg instanceof PhpParser\Node\Expr\Closure
                                && $secondArg->params[0]->var instanceof PhpParser\Node\Expr\Variable
                                && ! ($secondArg->params[0]->var->name instanceof PhpParser\Node\Expr)) {
                                $argName = $secondArg->params[0]->var->name;
                                $this->processColumnUpdates($tableName, $argName, $secondArg->stmts);
                            }
                            break;

                        case 'dropcolumn':
                        case 'dropifexists':
                        case 'dropsoftdeletes':
                        case 'dropsoftdeletestz':
                        case 'removecolumn':
                        case 'drop':
                            $table->dropColumn($columnName);
                            break;

                        case 'dropforeign':
                        case 'dropindex':
                        case 'dropprimary':
                        case 'dropunique':
                        case 'foreign':
                        case 'index':
                        case 'primary':
                        case 'renameindex':
                        case 'spatialIndex':
                        case 'unique':
                        case 'dropspatialindex':
                            break;

                        case 'dropmorphs':
                            $table->dropColumn($columnName.'_type');
                            $table->dropColumn($columnName.'_id');
                            break;

                        case 'enum':
                            $table->setColumn(new SchemaColumn($columnName, 'enum', $nullable, $secondArgArray));
                            break;

                        case 'morphs':
                            $table->setColumn(new SchemaColumn($columnName.'_type', 'string', $nullable));
                            $table->setColumn(new SchemaColumn($columnName.'_id', 'int', $nullable));
                            break;

                        case 'nullablemorphs':
                            $table->setColumn(new SchemaColumn($columnName.'_type', 'string', true));
                            $table->setColumn(new SchemaColumn($columnName.'_id', 'int', true));
                            break;

                        case 'nullableuuidmorphs':
                            $table->setColumn(new SchemaColumn($columnName.'_type', 'string', true));
                            $table->setColumn(new SchemaColumn($columnName.'_id', 'string', true));
                            break;

                        case 'rename':
                            $this->renameTableThroughMethodCall($table, $stmt->expr);
                            break;

                        case 'renamecolumn':
                            if ($secondArg instanceof PhpParser\Node\Scalar\String_) {
                                $table->renameColumn($columnName, $secondArg->value);
                            }
                            break;

                        case 'set':
                            $table->setColumn(new SchemaColumn($columnName, 'set', $nullable, $secondArgArray));
                            break;

                        case 'softdeletestz':
                        case 'timestamptz':
                        case 'timetz':
                        case 'year':
                        case 'softdeletes':
                            $table->setColumn(new SchemaColumn($columnName, 'string', true));
                            break;

                        case 'uuidmorphs':
                            $table->setColumn(new SchemaColumn($columnName.'_type', 'string', $nullable));
                            $table->setColumn(new SchemaColumn($columnName.'_id', 'string', $nullable));
                            break;

                        default:
                            // We know a property exists with a name, we just don't know its type.
                            $table->setColumn(new SchemaColumn($columnName, 'mixed', $nullable));
                            break;
                    }
                }
            }
        }
    }

    private function dropTable(PhpParser\Node\Expr\StaticCall|PhpParser\Node\Expr\MethodCall $call): void
    {
        if (! isset($call->args[0])
            || ! $call->getArgs()[0]->value instanceof PhpParser\Node\Scalar\String_
        ) {
            return;
        }

        $tableName = $call->getArgs()[0]->value->value;

        unset($this->tables[$tableName]);
    }

    private function renameTableThroughStaticCall(PhpParser\Node\Expr\StaticCall|PhpParser\Node\Expr\MethodCall $call): void
    {
        if (! isset($call->args[0], $call->args[1])
            || ! $call->getArgs()[0]->value instanceof PhpParser\Node\Scalar\String_
            || ! $call->getArgs()[1]->value instanceof PhpParser\Node\Scalar\String_
        ) {
            return;
        }

        $oldTableName = $call->getArgs()[0]->value->value;
        $newTableName = $call->getArgs()[1]->value->value;

        $this->renameTable($oldTableName, $newTableName);
    }

    private function renameTableThroughMethodCall(SchemaTable $oldTable, PhpParser\Node\Expr\MethodCall $call): void
    {
        if (! isset($call->args[0])
            || ! $call->getArgs()[0]->value instanceof PhpParser\Node\Scalar\String_
        ) {
            return;
        }

        /** @var PhpParser\Node\Scalar\String_ $methodCallArgument */
        $methodCallArgument = $call->getArgs()[0]->value;

        $oldTableName = $oldTable->name;
        $newTableName = $methodCallArgument->value;

        $this->renameTable($oldTableName, $newTableName);
    }

    private function renameTable(string $oldTableName, string $newTableName): void
    {
        if (! isset($this->tables[$oldTableName])) {
            return;
        }

        $table = $this->tables[$oldTableName];

        unset($this->tables[$oldTableName]);

        $table->name = $newTableName;

        $this->tables[$newTableName] = $table;
    }
}
