<?php

declare(strict_types=1);

namespace Atk4\Ui\Demos;

use Atk4\Core\Factory;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Ui\Exception;
use Atk4\Ui\Form;
use Atk4\Ui\Table;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Types\Type as DbalType;
use Mvorisek\Atk4\Hintable\Data\HintablePropertyDef;
use SebastianBergmann\CodeCoverage\CodeCoverage;

try {
    require_once file_exists(__DIR__ . '/db.php')
        ? __DIR__ . '/db.php'
        : __DIR__ . '/db.default.php';
} catch (\PDOException $e) {
    // do not show $e unless you can secure DSN!
    throw (new Exception('This demo requires access to the database. See "demos/init-db.php"'))
        ->addMoreInfo('PDO error', $e->getMessage());
}

/**
 * Improve testing by using non-scalar ID with custom DBAL type.
 */
class WrappedId
{
    private const MIN_VALUE = 1;
    private const MAX_VALUE = 100_000;

    private int $id;

    public function __construct(int $id)
    {
        if ($id < self::MIN_VALUE || $id > self::MAX_VALUE) {
            throw (new Exception('ID value is outside supported range'))
                ->addMoreInfo('value', $id);
        }

        $this->id = $id;
    }

    public function getId(): int
    {
        return $this->id;
    }
}

class WrappedIdType extends DbalType
{
    public const NAME = 'atk4_ui_demos_id';

    #[\Override]
    public function getName(): string
    {
        return self::NAME;
    }

    #[\Override]
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
    {
        return DbalType::getType('bigint')->getSQLDeclaration($fieldDeclaration, $platform);
    }

    #[\Override]
    public function convertToDatabaseValue($value, AbstractPlatform $platform): ?int
    {
        if ($value === null) {
            return null;
        }

        return DbalType::getType('bigint')->convertToDatabaseValue($value->getId(), $platform);
    }

    #[\Override]
    public function convertToPHPValue($value, AbstractPlatform $platform): ?object
    {
        if ($value === null) {
            return null;
        }

        return new WrappedId((int) DbalType::getType('bigint')->convertToPHPValue($value, $platform)); // once DBAL 3.x support is dropped, the explicit cast should no longer be needed
    }

    #[\Override]
    public function requiresSQLCommentHint(AbstractPlatform $platform): bool
    {
        return true;
    }
}

DbalType::addType(WrappedIdType::NAME, WrappedIdType::class);

trait ModelPreventModificationTrait
{
    protected function isAllowDbModifications(): bool
    {
        static $rw = null;
        if ($rw === null) {
            $rw = file_exists(__DIR__ . '/db-behat-rw.txt');
        }

        return $rw;
    }

    #[\Override]
    public function atomic(\Closure $fx)
    {
        $eRollback = true;
        foreach (array_slice(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT), 1) as $frame) {
            if ($frame['function'] === 'atomic'
                && ($frame['class'] ?? null) === self::class
                && $frame['object']->getModel(true)->getPersistence() === $this->getModel(true)->getPersistence()
            ) {
                $eRollback = null;

                break;
            }
        }
        if ($eRollback === true) {
            $eRollback = new \Exception('Prevent modification');
        }

        $res = null;
        try {
            parent::atomic(function () use ($fx, $eRollback, &$res) {
                $res = $fx();

                if ($eRollback !== null && !$this->isAllowDbModifications()) {
                    throw $eRollback;
                }
            });
        } catch (\Exception $e) {
            if ($e !== $eRollback) {
                throw $e;
            }
        }

        return $res;
    }

    /**
     * @param \Closure(static): string $outputCallback
     */
    protected function wrapUserActionCallbackPreventModification(Model\UserAction $action, \Closure $outputCallback): void
    {
        $originalCallback = $action->callback;
        $action->callback = function (Model $model, ...$args) use ($action, $originalCallback, $outputCallback) {
            if ($model->isEntity()) {
                $action = $action->getActionForEntity($model);
                $loadedEntity = clone $model;
            }

            $callbackBackup = $action->callback;
            try {
                $action->callback = $originalCallback;
                $res = $action->execute(...$args);

                if ($this->isAllowDbModifications()) {
                    return $res;
                }
            } finally {
                $action->callback = $callbackBackup;
            }

            return $outputCallback($model->isEntity() && !$model->isLoaded() ? $loadedEntity : $model, ...$args); // @phpstan-ignore argument.type
        };
    }

    protected function initPreventModification(): void
    {
        $makeMessageFx = static function (string $actionName, Model $entity) {
            return $entity->getModel()->getModelCaption() . ' action "' . $actionName . '" with "' . $entity->getTitle() . '" entity '
                . ' was executed. In demo mode all changes are reversed.';
        };

        $this->wrapUserActionCallbackPreventModification($this->getUserAction('add'), static function (Model $entity) use ($makeMessageFx) {
            return $makeMessageFx('add', $entity);
        });

        $this->wrapUserActionCallbackPreventModification($this->getUserAction('edit'), static function (Model $entity) use ($makeMessageFx) {
            return $makeMessageFx('edit', $entity);
        });

        $this->getUserAction('delete')->confirmation = 'Please go ahead. Demo mode does not really delete data.';
        $this->wrapUserActionCallbackPreventModification($this->getUserAction('delete'), static function (Model $entity) use ($makeMessageFx) {
            return $makeMessageFx('delete', $entity);
        });
    }
}

/**
 * Improve testing by using non-scalar ID and prefixed field/SQL names.
 *
 * @property WrappedId $id @Atk4\Field()
 *
 * @method WrappedId|null                  getId()
 * @method static|null                     tryLoad(WrappedId $id = null)
 * @method static                          load(WrappedId $id)
 * @method \Traversable<WrappedId, static> getIterator()
 * @method WrappedId                       insert(array<string, mixed> $row)
 * @method static                          delete(WrappedId $id = null)
 */
class ModelWithPrefixedFields extends Model
{
    use ModelPreventModificationTrait;

    /** @var array<string, string> */
    private static $prefixedFieldNames = [];

    private function prefixFieldName(string $fieldName, bool $forActualName = false): string
    {
        $tableShort = $this->table;
        if (strlen($tableShort) > 16) {
            $tableShort = substr(md5($tableShort), 0, 16);
        }

        if ($forActualName) {
            $fieldName = $this->unprefixFieldName($fieldName);
        }

        $fieldShort = $fieldName;
        $fieldWithIdSuffix = false;
        if (str_ends_with($fieldShort, '_id')) {
            $fieldShort = substr($fieldShort, 0, -strlen('_id'));
            $fieldWithIdSuffix = true;
        }
        if (strlen($fieldShort) > 16) {
            $fieldShort = substr(md5($fieldShort), 0, 16);
        }
        if ($fieldWithIdSuffix) {
            $fieldShort .= '_id';
        }

        $res = 'atk_' . ($forActualName ? 'a' : '') . 'fp_' . $tableShort . '__' . $fieldShort;

        self::$prefixedFieldNames[$res] = $fieldName;

        return $res;
    }

    private function unprefixFieldName(string $name): string
    {
        if (!str_starts_with($name, 'atk_fp_')) {
            return $name;
        }

        if (str_ends_with(self::$prefixedFieldNames[$name . '_id'] ?? '', '_id')) {
            return substr(self::$prefixedFieldNames[$name . '_id'], 0, -3);
        }

        return self::$prefixedFieldNames[$name];
    }

    #[\Override]
    protected function createHintablePropsFromClassDoc(string $className): array
    {
        return array_map(function (HintablePropertyDef $hintableProp) {
            $hintableProp->fieldName = $this->prefixFieldName($hintableProp->name);

            return $hintableProp;
        }, parent::createHintablePropsFromClassDoc($className));
    }

    #[\Override]
    protected function init(): void
    {
        if ($this->idField === 'id') {
            $this->idField = $this->prefixFieldName($this->idField);
        }

        if ($this->titleField === 'name') {
            $this->titleField = $this->prefixFieldName($this->titleField);
        }

        parent::init();

        $this->getIdField()->type = WrappedIdType::NAME;

        $this->initPreventModification();

        if ($this->getPersistence()->getDatabasePlatform() instanceof PostgreSQLPlatform || class_exists(CodeCoverage::class, false)) {
            $this->setOrder($this->idField);
        }
    }

    #[\Override]
    public function addField(string $name, $seed = []): Field
    {
        $seed = Factory::mergeSeeds($seed, [
            'actual' => $this->prefixFieldName($name, true),
            'caption' => $this->readableCaption($this->unprefixFieldName($name)),
        ]);

        return parent::addField($name, $seed);
    }

    #[\Override]
    public function getId(): ?WrappedId
    {
        return parent::getId();
    }

    /**
     * @param WrappedId|($allowNull is true ? null : never) $value
     */
    #[\Override] // @phpstan-ignore method.childParameterType
    public function setId($value, bool $allowNull = true)
    {
        return parent::setId($value, $allowNull);
    }

    /**
     * @return \Traversable<WrappedId, static>
     */
    #[\Override]
    public function createIteratorBy($field, $operator = null, $value = null): \Traversable
    {
        return parent::createIteratorBy(...'func_get_args'());
    }
}

/**
 * @property string $name      @Atk4\Field()
 * @property string $sys_name  @Atk4\Field()
 * @property string $iso       @Atk4\Field()
 * @property string $iso3      @Atk4\Field()
 * @property int    $numcode   @Atk4\Field()
 * @property int    $phonecode @Atk4\Field()
 */
class Country extends ModelWithPrefixedFields
{
    public $table = 'country';
    public $caption = 'Country';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name, ['actual' => 'atk_afp_country__nicename', 'required' => true, 'type' => 'string']);
        $this->addField($this->fieldName()->sys_name, ['actual' => 'atk_afp_country__name', 'system' => true]);

        $this->addField($this->fieldName()->iso, ['caption' => 'ISO', 'required' => true, 'type' => 'string', 'ui' => ['table' => ['sortable' => false]]]);
        $this->addField($this->fieldName()->iso3, ['caption' => 'ISO3', 'required' => true, 'type' => 'string']);
        $this->addField($this->fieldName()->numcode, ['caption' => 'ISO Numeric Code', 'type' => 'integer', 'required' => true]);
        $this->addField($this->fieldName()->phonecode, ['caption' => 'Phone Prefix', 'type' => 'integer', 'required' => true]);

        $this->onHook(Model::HOOK_BEFORE_SAVE, static function (self $entity) {
            if (!$entity->sys_name) {
                $entity->sys_name = mb_strtoupper($entity->name);
            }
        });
    }

    #[\Override]
    public function validate(?string $intent = null): array
    {
        $errors = parent::validate($intent);

        if (mb_strlen($this->iso ?? '') !== 2) {
            $errors[$this->fieldName()->iso] = 'Must be exactly 2 characters';
        }

        if (mb_strlen($this->iso3 ?? '') !== 3) {
            $errors[$this->fieldName()->iso3] = 'Must be exactly 3 characters';
        }

        // look if name is unique
        $c = $this->getModel()->tryLoadBy($this->fieldName()->name, $this->name);
        if ($c !== null && !$this->compare($this->idField, $c->getId())) {
            $errors[$this->fieldName()->name] = 'Country name must be unique';
        }

        return $errors;
    }
}

/**
 * @property string    $project_name           @Atk4\Field()
 * @property string    $project_code           @Atk4\Field()
 * @property string    $description            @Atk4\Field()
 * @property string    $client_name            @Atk4\Field()
 * @property string    $client_address         @Atk4\Field()
 * @property Country   $client_country_iso     @Atk4\RefOne()
 * @property string    $client_country         @Atk4\Field()
 * @property bool      $is_commercial          @Atk4\Field()
 * @property string    $currency               @Atk4\Field()
 * @property string    $currency_symbol        @Atk4\Field()
 * @property float     $project_budget         @Atk4\Field()
 * @property float     $project_invoiced       @Atk4\Field()
 * @property float     $project_paid           @Atk4\Field()
 * @property float     $project_hour_cost      @Atk4\Field()
 * @property int       $project_hours_est      @Atk4\Field()
 * @property int       $project_hours_reported @Atk4\Field()
 * @property float     $project_expenses_est   @Atk4\Field()
 * @property float     $project_expenses       @Atk4\Field()
 * @property float     $project_mgmt_cost_pct  @Atk4\Field()
 * @property float     $project_qa_cost_pct    @Atk4\Field()
 * @property \DateTime $start_date             @Atk4\Field()
 * @property \DateTime $finish_date            @Atk4\Field()
 * @property \DateTime $finish_time            @Atk4\Field()
 * @property \DateTime $created                @Atk4\Field()
 * @property \DateTime $updated                @Atk4\Field()
 */
class Stat extends ModelWithPrefixedFields
{
    public $table = 'stat';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->project_name, ['type' => 'string']);
        $this->addField($this->fieldName()->project_code, ['type' => 'string']);
        $this->titleField = $this->fieldName()->project_name;
        $this->addField($this->fieldName()->description, ['type' => 'text']);
        $this->addField($this->fieldName()->client_name, ['type' => 'string']);
        $this->addField($this->fieldName()->client_address, ['type' => 'text', 'ui' => ['form' => [Form\Control\Textarea::class, 'rows' => 4]]]);

        $this->hasOne($this->fieldName()->client_country_iso, [
            'model' => [Country::class],
            'theirField' => Country::hinting()->fieldName()->iso,
            'ui' => [
                'form' => [Form\Control\Line::class],
                'table' => [Table\Column\CountryFlag::class],
            ],
        ])
            ->addField($this->fieldName()->client_country, Country::hinting()->fieldName()->name);

        $this->addField($this->fieldName()->is_commercial, ['type' => 'boolean']);
        $this->addField($this->fieldName()->currency, ['values' => ['EUR' => 'Euro', 'USD' => 'US Dollar', 'GBP' => 'Pound Sterling']]);
        $this->addField($this->fieldName()->currency_symbol, ['neverPersist' => true]);
        $this->onHook(Model::HOOK_AFTER_LOAD, static function (self $entity) {
            $map = ['EUR' => '€', 'USD' => '$', 'GBP' => '£'];
            $entity->currency_symbol = $map[$entity->currency] ?? '?';

            // fix too dirty field for user action
            $dirtyRef = &$entity->getDirtyRef();
            unset($dirtyRef[$entity->fieldName()->currency_symbol]);
        });

        $this->addField($this->fieldName()->project_budget, ['type' => 'atk4_money']);
        $this->addField($this->fieldName()->project_invoiced, ['type' => 'atk4_money']);
        $this->addField($this->fieldName()->project_paid, ['type' => 'atk4_money']);
        $this->addField($this->fieldName()->project_hour_cost, ['type' => 'atk4_money']);

        $this->addField($this->fieldName()->project_hours_est, ['type' => 'integer']);
        $this->addField($this->fieldName()->project_hours_reported, ['type' => 'integer']);

        $this->addField($this->fieldName()->project_expenses_est, ['type' => 'atk4_money']);
        $this->addField($this->fieldName()->project_expenses, ['type' => 'atk4_money']);
        $this->addField($this->fieldName()->project_mgmt_cost_pct, new Percent());
        $this->addField($this->fieldName()->project_qa_cost_pct, new Percent());

        $this->addField($this->fieldName()->start_date, ['type' => 'date']);
        $this->addField($this->fieldName()->finish_date, ['type' => 'date']);
        $this->addField($this->fieldName()->finish_time, ['type' => 'time']);

        $this->addField($this->fieldName()->created, ['type' => 'datetime', 'ui' => ['form' => ['disabled' => true]]]);
        $this->addField($this->fieldName()->updated, ['type' => 'datetime', 'ui' => ['form' => ['disabled' => true]]]);
    }
}

class Percent extends Field
{
    public string $type = 'float';
}

/**
 * @property string $name             @Atk4\Field()
 * @property string $type             @Atk4\Field()
 * @property bool   $is_folder        @Atk4\Field()
 * @property Folder $parent_folder_id @Atk4\RefOne()
 * @property File   $subFolder        @Atk4\RefMany()
 * @property int    $subCount         @Atk4\Field()
 */
class File extends ModelWithPrefixedFields
{
    public $table = 'file';
    public $caption = 'File';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name);

        $this->addField($this->fieldName()->type, ['caption' => 'MIME Type']);
        $this->addField($this->fieldName()->is_folder, ['type' => 'boolean']);

        $this->hasOne($this->fieldName()->parent_folder_id, [
            'model' => [Folder::class],
        ])
            ->addTitle();

        $this->hasMany($this->fieldName()->subFolder, [
            'model' => [self::class],
            'theirField' => self::hinting()->fieldName()->parent_folder_id,
        ])
            ->addField($this->fieldName()->subCount, ['aggregate' => 'count', 'field' => $this->getPersistence()->expr($this, '*')]);
    }

    public function importFromFilesystem(string $path, ?bool $isSub = null): void
    {
        if ($isSub === null) {
            if ($this->isEntity()) { // TODO should be not needed once UserAction is for non-entity only
                $this->getModel()->importFromFilesystem($path);

                return;
            }

            $this->atomic(function () use ($path) {
                do {
                    $empty = true;
                    foreach ($this->createIteratorBy($this->fieldName()->subFolder . '/#', 0) as $entity) {
                        $entity->delete();
                        $empty = false;
                    }
                } while (!$empty);

                $path = __DIR__ . '/../' . $path;

                $this->importFromFilesystem($path, false);
            });

            return;
        }

        foreach (new \DirectoryIterator($path) as $fileinfo) {
            if ($fileinfo->isDot() || in_array($fileinfo->getFilename(), ['.git', 'vendor', 'node_modules', 'external'], true)) {
                continue;
            }

            $entity = $this->createEntity();

            $entity->save([
                $this->fieldName()->name => $fileinfo->getFilename(),
                $this->fieldName()->is_folder => $fileinfo->isDir(),
                $this->fieldName()->type => pathinfo($fileinfo->getFilename(), \PATHINFO_EXTENSION),
            ]);

            if ($fileinfo->isDir()) {
                $entity->subFolder->importFromFilesystem($fileinfo->getPath() . '/' . $fileinfo->getFilename(), true);
            }

            // skip full/slow import for Behat CI testing
            if ($_ENV['CI'] ?? null) {
                break;
            }
        }
    }
}

class Folder extends File
{
    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addCondition($this->fieldName()->is_folder, true);
    }
}

/**
 * @property string      $name          @Atk4\Field()
 * @property SubCategory $subCategories @Atk4\RefMany()
 * @property Product     $products      @Atk4\RefMany()
 */
class Category extends ModelWithPrefixedFields
{
    public $table = 'product_category';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name);

        $this->hasMany($this->fieldName()->subCategories, [
            'model' => [SubCategory::class],
            'theirField' => SubCategory::hinting()->fieldName()->product_category_id,
        ]);
        $this->hasMany($this->fieldName()->products, [
            'model' => [Product::class],
            'theirField' => Product::hinting()->fieldName()->product_category_id,
        ]);
    }
}

/**
 * @property string   $name                @Atk4\Field()
 * @property Category $product_category_id @Atk4\RefOne()
 * @property Product  $products            @Atk4\RefMany()
 */
class SubCategory extends ModelWithPrefixedFields
{
    public $table = 'product_sub_category';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name);

        $this->hasOne($this->fieldName()->product_category_id, [
            'model' => [Category::class],
        ]);
        $this->hasMany($this->fieldName()->products, [
            'model' => [Product::class],
            'theirField' => Product::hinting()->fieldName()->product_sub_category_id,
        ]);
    }
}

/**
 * @property string      $name                    @Atk4\Field()
 * @property string      $brand                   @Atk4\Field()
 * @property Category    $product_category_id     @Atk4\RefOne()
 * @property SubCategory $product_sub_category_id @Atk4\RefOne()
 */
class Product extends ModelWithPrefixedFields
{
    public $table = 'product';
    public $caption = 'Product';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name);
        $this->addField($this->fieldName()->brand);
        $this->hasOne($this->fieldName()->product_category_id, [
            'model' => [Category::class],
        ])->addTitle();
        $this->hasOne($this->fieldName()->product_sub_category_id, [
            'model' => [SubCategory::class],
        ])->addTitle();
    }
}

/**
 * @property string    $item       @Atk4\Field()
 * @property \DateTime $inv_date   @Atk4\Field()
 * @property \DateTime $inv_time   @Atk4\Field()
 * @property Country   $country_id @Atk4\RefOne()
 * @property int       $qty        @Atk4\Field()
 * @property int       $box        @Atk4\Field()
 * @property int       $total_sql  @Atk4\Field()
 * @property int       $total_php  @Atk4\Field()
 */
class MultilineItem extends ModelWithPrefixedFields
{
    public $table = 'multiline_item';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->item, ['required' => true]);
        $this->addField($this->fieldName()->inv_date, ['type' => 'date']);
        $this->addField($this->fieldName()->inv_time, ['type' => 'time']);
        $this->hasOne($this->fieldName()->country_id, [
            'model' => [Country::class],
        ]);
        $this->addField($this->fieldName()->qty, ['type' => 'integer', 'required' => true]);
        $this->addField($this->fieldName()->box, ['type' => 'integer', 'required' => true]);
        if ($this->getPersistence() instanceof Persistence\Sql) { // for MultilineDelivery contained models
            $this->addExpression($this->fieldName()->total_sql, [
                'expr' => function (Model /* TODO self is not working because of clone in Multiline */ $row) {
                    return $row->expr('{' . $this->fieldName()->qty . '} * {' . $this->fieldName()->box . '}'); // @phpstan-ignore method.notFound
                },
                'type' => 'bigint',
            ]);
            $this->addCalculatedField($this->fieldName()->total_php, [
                'expr' => static function (self $row) {
                    return $row->qty * $row->box;
                },
                'type' => 'bigint',
            ]);
        }
    }
}

/**
 * @property string        $name  @Atk4\Field()
 * @property MultilineItem $item  @Atk4\RefOne()
 * @property MultilineItem $items @Atk4\RefMany()
 */
class MultilineDelivery extends ModelWithPrefixedFields
{
    public $table = 'multiline_delivery';

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->addField($this->fieldName()->name, ['required' => true]);
        $this->containsOne($this->fieldName()->item, ['model' => [MultilineItem::class]]);
        $this->containsMany($this->fieldName()->items, ['model' => [MultilineItem::class]]);

        // hack to supress "ContainsXxx does not support unmanaged data modification" exception
        // TODO remove once https://github.com/atk4/ui/issues/1860 is fixed
        // https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsBase.php#L69
        $this->removeHook(Model::HOOK_NORMALIZE);
    }
}
