<?php

declare(strict_types=1);

namespace HiEvents\Services\Infrastructure\DomainObjectGenerator;

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use HiEvents\DomainObjects\AbstractDomainObject;
use HiEvents\Exceptions\NoDefaultValueAvailableForGeneratedDoProperty;
use Illuminate\Foundation\Application;
use Illuminate\Support\Pluralizer;
use Illuminate\Support\Str;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\PhpNamespace;
use Nette\PhpGenerator\PsrPrinter;

/**
 * @todo - move properties to config
 */
class ClassGenerator
{
    public const NO_TYPE = 'no-type';

    private string $defaultAbstractNamespace = 'HiEvents\DomainObjects\Generated';

    private string $defaultConcreteNamespace = 'HiEvents\DomainObjects';

    private string $defaultAbstractModelPath = "DomainObjects/Generated";

    private string $defaultConcreteModelPath = "DomainObjects";

    private string $defaultExtends = AbstractDomainObject::class;

    private array $defaultIgnoreProperties = [];

    private array $ignoredTables = [
        'migrations'
    ];

    private AbstractSchemaManager $schemaManager;

    private Application $app;

    private ClassType $currentClass;

    private Table $currentTable;

    private array $currentColumns;

    public function __construct(Application $app, AbstractSchemaManager $schemaManager)
    {
        $this->schemaManager = $schemaManager;
        $this->app = $app;
    }

    /**
     * @return void
     * @throws Exception
     */
    public function run(): void
    {
        foreach ($this->schemaManager->listTables() as $table) {
            if (in_array($table->getName(), $this->ignoredTables, true)) {
                continue;
            }

            $namespace = new PhpNamespace($this->defaultAbstractNamespace);
            $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))) . 'DomainObjectAbstract';

            $this->currentClass = $namespace->addClass($className);
            $this->currentTable = $table;
            $this->currentColumns = $table->getColumns();

            $this->currentClass
                ->setAbstract()
                ->setExtends($this->defaultExtends)
                ->addComment("THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY.")
                ->addComment("@package $this->defaultAbstractNamespace");

            $this->buildConstants();
            $this->buildProperties();
            $this->buildPublicMethods();
            $this->buildGettersAndSetters();

            $namespace->add($this->currentClass);

            $file = new PhpFile();
            $file->addNamespace($namespace);
            $this->writeFile($table, (new PsrPrinter)->printFile($file));
        }
    }

    private function buildConstants(): void
    {
        $singularName = Str::singular($this->currentTable->getName());
        $pluralName = Str::plural($singularName);

        $this->currentClass->addConstant('SINGULAR_NAME', $singularName)
            ->setPublic()
            ->setFinal();

        $this->currentClass->addConstant('PLURAL_NAME', $pluralName)
            ->setPublic()
            ->setFinal();

        foreach ($this->currentColumns as $column) {
            if ($this->shouldIgnoreProperty($column->getName())) {
                continue;
            }

            $constName = Str::upper($column->getName());

            $this->currentClass->addConstant($constName, $column->getName())
                ->setPublic()
                ->setFinal();
        }
    }

    private function shouldIgnoreProperty(string $property): bool
    {
        return in_array($property, $this->defaultIgnoreProperties, true);
    }

    private function buildProperties(): void
    {
        foreach ($this->currentColumns as $column) {
            if ($this->shouldIgnoreProperty($column->getName())) {
                continue;
            }

            $prop = $this->currentClass
                ->addProperty($column->getName())
                ->setProtected()
                ->setType($this->getType($column->getType()))
                ->setNullable(!$column->getNotnull());

            $defaultValue = $this->getDefaultValue($column);
            if ($defaultValue !== self::NO_TYPE) {
                $prop->setValue($defaultValue);
            }
        }
    }

    private function getType(Type $type): ?string
    {
        return match (strtolower($type->getName())) {
            Types::INTEGER, Types::BIGINT, Types::SMALLINT => 'int',
            Types::BOOLEAN => 'bool',
            Types::DECIMAL, Types::FLOAT => 'float',
            Types::JSON => 'array|string',
            default => 'string',
        };
    }

    private function getDefaultValue(Column $column): mixed
    {
        if (is_null($column->getDefault())) {
            return $column->getNotnull() ? self::NO_TYPE : null;
        }

        return match ($column->getType()->getName()) {
            Types::STRING, Types::TEXT, Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATETIME_MUTABLE => $column->getDefault(),
            Types::INTEGER, Types::BIGINT, Types::SMALLINT => (int)$column->getDefault(),
            Types::FLOAT, Types::DECIMAL => (float)$column->getDefault(),
            Types::GUID => self::NO_TYPE,
            Types::BOOLEAN => (bool)$column->getDefault(),
            default => throw new NoDefaultValueAvailableForGeneratedDoProperty(
                sprintf('Unable to handle %s', $column->getType()->getName())
            ),
        };
    }

    private function buildPublicMethods(): void
    {
        $response[] = "return [";
        foreach ($this->currentColumns as $column) {
            if ($this->shouldIgnoreProperty($column->getName())) {
                continue;
            }

            $response[] = "
            '{$column->getName()}' => \$this->{$column->getName()} ?? null,";
        }
        $response[] = "
        ];
    ";
        $this->currentClass
            ->addMethod('toArray')
            ->setReturnType('array')
            ->setBody(implode('', $response))
            ->setPublic();
    }

    private function buildGettersAndSetters(): void
    {
        foreach ($this->currentColumns as $column) {
            if ($this->shouldIgnoreProperty($column->getName())) {
                continue;
            }

            $model = ucfirst(Str::camel($column->getName()));
            $setterName = "set" . $model;
            $getterName = "get" . $model;

            $this->currentClass->addMethod($setterName)
                ->setPublic()
                ->addBody("\$this->{$column->getName()} = \${$column->getName()};\nreturn \$this;")
                ->setReturnType('self')
                ->addParameter($column->getName())
                ->setType($this->getType($column->getType()))
                ->setNullable(!$column->getNotnull());

            $this->currentClass->addMethod($getterName)
                ->setPublic()
                ->addBody("return \$this->{$column->getName()};")
                ->setReturnType($this->getType($column->getType()))
                ->setReturnNullable(!$column->getNotnull());
        }
    }

    private function writeFile(Table $table, string $modelContent): void
    {
        $abstractPath = $this->app->path($this->defaultAbstractModelPath);
        $concretePath = $this->app->path($this->defaultConcreteModelPath);

        $model = Pluralizer::singular(ucfirst(Str::camel($table->getName()))) . 'DomainObject';
        $abstractFile = $abstractPath . '/' . $model . 'Abstract' . '.php';
        $concreteFile = $concretePath . '/' . $model . '.php';

        if (file_exists($abstractFile)) {
            unlink($abstractFile);
        }

        if (!file_exists($concreteFile)) {
            $this->writeConcreteFile($table, $concreteFile);
        }

        file_put_contents($abstractFile, $modelContent);
    }

    private function writeConcreteFile(Table $table, string $concreteFile): void
    {
        $namespace = new PhpNamespace($this->defaultConcreteNamespace);
        $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))) . 'DomainObject';

        $class = $namespace->addClass($className);
        $class->setExtends($this->defaultConcreteNamespace. '\Generated' . '\\' . $className . 'Abstract');

        $file = new PhpFile();
        $file->addNamespace($namespace);

        file_put_contents($concreteFile, (new PsrPrinter())->printFile($file));
    }
}
