<?php

declare(strict_types=1);

namespace Apexl\Io;

use function Apexl\Config\config;
use Apexl\Dto\Raw;
use Apexl\Io\Cache\Interfaces\CacheStreamInterface;
use Apexl\Io\Collections\EntityCollection;
use Apexl\Io\Collections\LoaderConditionCollection;
use Apexl\Io\Collections\LoaderSortCollection;
use Apexl\Io\Collections\RelationCache;
use Apexl\Io\Collections\RelationCollection;
use Apexl\Io\Collections\VirtualFieldDefinitionCollection;
use Apexl\Io\Dtos\EntitySchema;
use Apexl\Io\Dtos\LoaderCondition;
use Apexl\Io\Dtos\LoaderSort;
use Apexl\Io\Dtos\Relationship\BelongsTo;
use Apexl\Io\Dtos\Relationship\BelongsToMany;
use Apexl\Io\Dtos\Relationship\HasMany;
use Apexl\Io\Dtos\Relationship\HasOne;
use Apexl\Io\Dtos\Relationship\HasOneViaPivot;
use Apexl\Io\Dtos\Relationship\OneToMany;
use Apexl\Io\Dtos\VirtualFieldDefinition;
use Apexl\Io\Enums\EntityFieldKind;
use Apexl\Io\Enums\EntityFinderJsonType;
use Apexl\Io\Exceptions\LazyLoadException;
use Apexl\Io\Exceptions\RecordNotFoundException;
use Apexl\Io\Factories\EntityFactory;
use Apexl\Io\Factories\EntityFactoryFactory;
use Apexl\Io\Interfaces\EntityInterface;
use Apexl\Io\Interfaces\EntityOperatorInterface;
use Apexl\Io\Interfaces\HasCreatorInterface;
use Apexl\Io\Interfaces\HasEntityFactory;
use Apexl\Io\Interfaces\HasModifierInterface;
use Apexl\Io\Interfaces\HasParentInterface;
use Apexl\Io\Interfaces\LazyLoadAllowerInterface;
use Apexl\Io\Interfaces\PivotSqlFragment;
use Apexl\Io\Interfaces\QueryDecorator;
use Apexl\Io\Interfaces\RelationOrderBy;
use Apexl\Io\Interfaces\RelationshipInterface;
use Apexl\Io\Interfaces\RelationSqlFragment;
use Apexl\Io\Operators\EntityDatabaseOperator;
use Apexl\Io\Services\EntityFinderService;
use Apexl\Io\Services\EntitySchemaRegistry;
use Apexl\Io\Services\RelationResolver;
use Apexl\Io\Traits\HasCasts;
use Apexl\Vault\Enums\SortDirection;
use Apexl\Vault\Exceptions\ExecutionException;
use Apexl\Vault\Exceptions\VaultException;
use Apexl\Vault\Interfaces\QueryBuilderInterface;
use Apexl\Vault\Vault;
use Arrayy\Arrayy;
use Arrayy\Type\StringCollection;
use BackedEnum;
use BadMethodCallException;
use DateTimeInterface;
use DI\DependencyException;
use DI\NotFoundException;
use Exception;
use ForceUTF8\Encoding;
use JsonSerializable;
use ReflectionClass;
use stdClass;
use Stringable;
use Stringy\Stringy;
use UnitEnum;

class Entity implements EntityInterface, HasEntityFactory, JsonSerializable, Stringable
{
    use HasCasts;

    // Array of fields that will not be passed to the frontend when `jsonSerialized`
    protected const array PROTECTED_FIELDS = [];

    /** The operator class this entity uses. Override per entity; instantiated lazily. */
    protected const string OPERATOR = EntityDatabaseOperator::class;

    /**
     * The table this entity lives in, and the column that identifies a row. Declare both
     * on every concrete entity. Read them through getTableName()/getPrimaryKey() — an
     * entity whose table is only known at runtime (the test fixtures) overrides those.
     */
    public const string TABLE = '';
    public const string PRIMARY_KEY = 'id';

    /**
     * Fields a caller cannot change once the row exists. Extend with
     * `[...parent::READ_ONLY_FIELDS, 'foo']`. Enforced by store() on update only — see
     * readOnlyWrites() for what is exempt and why an insert is different.
     */
    protected const array READ_ONLY_FIELDS = ['id', 'created', 'created_by', 'modified', 'modified_by'];

    /**
     * Read-only fields the framework writes on the entity's behalf, so they survive the
     * strip: the stamps that are supposed to move on every update.
     */
    protected const array FRAMEWORK_WRITTEN_FIELDS = ['modified', 'modified_by'];

    public final const string HOOK__BEFORE_STORE = 'entity.store.before';
    public final const string HOOK__AFTER_STORE = 'entity.store.after';
    public final const string HOOK__BEFORE_FIRST_STORE = 'entity.store.before.first';
    public final const string HOOK__AFTER_FIRST_STORE = 'entity.store.after.first';


    /** @var array<string, EntityFieldKind|null> */
    private static array $fieldKinds = [];
    protected array $fieldConfig = [];
    protected System $system;
    protected HookManager $hookManager;
    protected RelationCache $relationCache;
    protected Arrayy $virtualFieldsCache;
    protected Vault $db;
    /** @var bool  Flag the entity as changed. Allows us to only update
     *             when something has been changed - should reduce overhead
     */
    protected bool $entityChanged = false;
    protected array $data = [];
    /** @var bool[] */
    protected array $dirty = [];
    protected string $type = '';
    protected EntityOperatorInterface $operator;
    protected array $requiredData = [];
    protected array $missingData = [];
    protected array $sensitiveDataFields = [];
    private LazyLoadAllowerInterface $lazyLoadAllower;
    private CacheStreamInterface $cache;
    private RelationResolver $relationResolver;
    private ?EntitySchemaRegistry $schemaRegistry = null;

    public function __construct()
    {
        $this->relationResolver = \Apexl\Io\container()->get(RelationResolver::class);
        $this->lazyLoadAllower = \Apexl\Io\container()->get(LazyLoadAllowerInterface::class);
        $this->hookManager = \Apexl\Io\container()->get(HookManager::class);
        $this->relationCache = RelationCache::create();
        $this->virtualFieldsCache = Arrayy::create();
        $this->cache = \Apexl\Io\container()->get('cache.default');
    }

    /**
     * @throws RecordNotFoundException
     */
    public static function fromPrimaryKey(mixed $primaryKey): static
    {
        return static::find($primaryKey) ?? throw new RecordNotFoundException();
    }

    public static function find(
        int|string $id,
        array $fields = ['*'],
    ): ?static {
        return new static()->_find($id, $fields);
    }

    protected function _find(
        int|string $id,
        array $fields = ['*'],
    ): ?static {
        return $this->_findWhere($this->getPrimaryKey(), $id, $fields);
    }

    protected function _findWhere(
        string $field,
        int|string|float|bool $value,
        array $fields = ['*'],
    ): ?static {
        $result = $this->_findMultiple(
            conditions: [
                LoaderCondition::equals(sprintf('%s.%s', $this->getTableName(), $field), $value),
            ],
            limit: 1,
            fields: $fields,
        );

        return $result->pop();
    }

    protected function _findMultiple(
        array|LoaderConditionCollection $conditions = [],
        ?int $limit = 20,
        int $offset = 0,
        string|array|LoaderSort|LoaderSortCollection $sorts = [],
        array|StringCollection $fields = ['*'],
        false|null|array &$paging = false,
    ): EntityCollection {
        $query = $this->findMultipleQuery(
            conditions: $conditions,
            limit: $limit,
            offset: $offset,
            sorts: $sorts,
            fields: $fields,
        );

        $items = $query->execute()->fetchAllAssoc();

        if ($paging !== false) {
            $query = $this->findMultipleQuery(
                conditions: $conditions,
                fields: [Raw::create('count(*) as count')],
                autoIncludePrimaryKey: false,
            );

            $result = $query->execute()->fetch();

            $paging = [
                $result->count,
                $limit ? (int) ceil($result->count / $limit) : 1,
            ];
        }

        $entities = array_map(fn(array $item): static => new static()->hydrate($item), $items);

        return EntityCollection::createFromArray($entities);
    }

    /**
     * @throws DependencyException
     * @throws NotFoundException
     */
    public function findMultipleQuery(
        array|LoaderConditionCollection $conditions = [],
        ?int $limit = 20,
        int $offset = 0,
        string|array|LoaderSort|LoaderSortCollection $sorts = [],
        array|StringCollection $fields = ['*'],
        EntityFinderJsonType $json = EntityFinderJsonType::NONE,
        bool $group = false,
        bool $autoIncludePrimaryKey = true,
        ?string $tableAlias = null,
    ): QueryBuilderInterface {
        $entityFinderService = \Apexl\Io\container()->make(EntityFinderService::class, [
            'entity' => $this,
            'json' => $json,
        ]);

        return $entityFinderService->query(
            conditions: $conditions,
            limit: $limit,
            offset: $offset,
            sorts: $sorts,
            fields: $fields,
            group: $group,
            autoIncludePrimaryKey: $autoIncludePrimaryKey,
            tableAlias: $tableAlias,
        );
    }

    public function hydrate(array $data): static
    {
        foreach ($data as $key => $value) {
            $kind = $this->fieldKind($key);

            if ($kind !== EntityFieldKind::RELATION) {
                match ($kind) {
                    EntityFieldKind::DATA => $this->data[$key] = $value,
                    EntityFieldKind::VIRTUAL => $this->virtualFieldsCache->set($key, $value),
                    default => null,
                };
                continue;
            }

            $relation = $this->relation($key);

            if (is_null($value)) {
                $this->relationCache->set($relation->name, $relation->valueWhenEmpty());
                continue;
            }

            if (is_string($value) && json_validate($value)) {
                $value = json_decode($value, true);
            }

            $this->hydrateRelation($relation, $value);
        }

        $this->resetDirty();

        return $this;
    }

    private function fieldKind(string $field): ?EntityFieldKind
    {
        // Keyed by class *and* table: the answer depends on both, and a fixture that
        // rebuilds its table between runs must not inherit the previous answer. This used
        // to live in the cache stream, which meant a per-class key over per-instance state
        // — one entity with an unbuilt field list poisoned the answer for every other.
        $key = sprintf('%s@%s:%s', static::class, $this->getTableName(), $field);

        return self::$fieldKinds[$key] ??= match (true) {
            $this->isDataField($field) => EntityFieldKind::DATA,
            $this->isVirtualField($field) => EntityFieldKind::VIRTUAL,
            $this->isRelationField($field) => EntityFieldKind::RELATION,
            default => null,
        };
    }

    /**
     * Allow entities to override this and specify a 'nice name'
     * @return string
     */
    public function getTableName(): string
    {
        return static::TABLE;
    }

    public function isDataField(string $field): bool
    {
        return $this->schema()->has($field);
    }

    /**
     * This entity's table shape — columns, names and typed fields — described once per
     * table per request.
     */
    protected function schema(): EntitySchema
    {
        return ($this->schemaRegistry ??= \Apexl\Io\container()->get(EntitySchemaRegistry::class))
            ->for($this->getTableName(), fn(): array => $this->operator()->getTableColumns());
    }

    public function getTableColumns(): array
    {
        return $this->schema()->columns;
    }

    /**
     * The operator this entity talks to. Built on first use, not in the constructor —
     * constructing one costs three container lookups, and an entity that is only ever a
     * data bag never needs it at all.
     */
    protected function operator(): EntityOperatorInterface
    {
        if (!isset($this->operator)) {
            $this->setOperator($this->makeOperator());
        }

        return $this->operator;
    }

    /**
     * Override to build an operator that needs more than a table and a key.
     */
    protected function makeOperator(): EntityOperatorInterface
    {
        // Legacy: an app may name one operator for every entity in config rather than
        // per entity. Nothing in Newton sets this.
        if ($baseOperator = \Apexl\Config\config('entity.operator')) {
            return new $baseOperator(\Apexl\Config\config('entity'));
        }

        return new (static::OPERATOR)($this->getTableName(), $this->getPrimaryKey());
    }

    public function getPrimaryKey(): string
    {
        return static::PRIMARY_KEY;
    }

    public function isVirtualField(string $field): bool
    {
        return $this->virtualFields()->has($field);
    }

    public function virtualFields(): VirtualFieldDefinitionCollection
    {
        $key = sprintf('%s:virtual_fields', static::class);

        return $this->cache->remember(
            $key,
            function (): VirtualFieldDefinitionCollection {

                $fields = VirtualFieldDefinitionCollection::createFromArray(
                    array_map(
                        fn($sql, $field): VirtualFieldDefinition => VirtualFieldDefinition::create(
                            field: $field,
                            table: $this->getTableName(),
                            definition: $sql,
                        ),
                        $this->definedVirtualFields(),
                        array_keys($this->definedVirtualFields())
                    )
                );

                $this->relations()
                    ->filter(fn(RelationshipInterface $relationship): bool => $relationship instanceof OneToMany)
                    ->each(function (RelationshipInterface $relationship) use ($fields): void {
                        if ($relationship instanceof OneToMany) {
                            $fields->push(
                                VirtualFieldDefinition::create(
                                    field: sprintf('%s_count', $relationship->getName()),
                                    table: $this->getTableName(),
                                    definition: [
                                        'sql' => fn(): string => $relationship->countSql(),
                                    ],
                                )
                            );
                        }
                    });

                $keys = $fields->map(fn(VirtualFieldDefinition $field): string => $field->field)->toArray();

                return $fields->replaceAllKeys($keys);
            }
        );
    }

    /**
     * Resolve a field name to its kind once per (class, field). Subsequent lookups
     * across the 1500-row hot path skip three sequential isX checks (each of which
     * walks relations / triggers DI) in favour of a single hash lookup.
     */

    protected function definedVirtualFields(): array
    {
        return [];
    }

    public function relations(): RelationCollection
    {
        $key = sprintf('%s:relations', static::class);

        return $this->cache->remember($key, function (): RelationCollection {
            $definedRelations = $this->addAutoRelations($this->definedRelations());

            $relations = RelationCollection::createFromArray($definedRelations);

            $keys = $relations->map(fn(RelationshipInterface $relation): string => $relation->getName())->toArray();

            return $relations->replaceAllKeys($keys);
        });
    }

    protected function addAutoRelations(array $relations): array
    {
        if ($this instanceof HasCreatorInterface) {
            $relations[] = $this->creatorRelation();
        }

        if ($this instanceof HasModifierInterface) {
            $relations[] = $this->modifierRelation();
        }

        if ($this instanceof HasParentInterface) {
            $relations[] = $this->parentRelation();
        }

        return $relations;
    }

    protected function definedRelations(): array
    {
        return [];
    }

    public function isRelationField(string $field): bool
    {
        return !!$this->relation($field);
    }

    public function relation(string $relation): ?RelationshipInterface
    {
        return $this->relationResolver
            ->resolve($relation, $this);
    }

    private function hydrateRelation(RelationshipInterface|string $relation, array $data): void
    {
        $_relation = $relation instanceof RelationshipInterface ? $relation : $this->relation($relation);

        if (!$_relation) {
            return;
        }

        $this->relationCache->set($_relation->name, $_relation->hydrate($data));
    }

    protected function resetDirty(): void
    {
        $this->dirty = [];
    }

    /**
     * @throws RecordNotFoundException
     */
    public static function from(string $key, mixed $value): static
    {
        // findWhere() types its value; a null one used to mean `WHERE key = NULL`, which
        // matched nothing and landed here anyway.
        if ($value === null) {
            throw new RecordNotFoundException();
        }

        return static::findWhere($key, $value) ?? throw new RecordNotFoundException();
    }

    public static function findWhere(
        string $field,
        mixed $value,
        array $fields = ['*'],
    ): ?static {
        return new static()->_findWhere($field, $value, $fields);
    }

    public static function factory(): EntityFactory
    {
        return \Apexl\Io\container()->make(EntityFactoryFactory::class)->make(static::class);
    }

    public static function entityFactoryBaseClass(): string
    {
        return EntityFactory::class;
    }

    public static function findMultiple(
        array|LoaderConditionCollection $conditions = [],
        ?int $limit = 20,
        int $offset = 0,
        string|array|LoaderSort|LoaderSortCollection $sorts = [],
        array|StringCollection $fields = ['*'],
        false|null|array &$paging = false,
    ): EntityCollection {
        return new static()->_findMultiple(
            $conditions,
            $limit,
            $offset,
            $sorts,
            $fields,
            $paging,
        );
    }

    /**
     * Construction-free primary key name. Override per entity when the primary
     * key is not the default 'id'. Lets the build path read the key without
     * constructing the entity (counterpart to the instance {@see getPrimaryKey()}).
     */
    public static function primaryKey(): string
    {
        return static::PRIMARY_KEY;
    }

    /**
     * Function to get the fields for the entity (from the primary table).
     * Allows us to check what data is storable and strip data that isn't.
     * Will only return fields not specified as 'read only' in the entity settings.
     * @see the READ_ONLY_FIELDS constant.
     */
    public function getEntityDataFields(): array
    {
        return array_values($this->schema()->writableColumns(static::READ_ONLY_FIELDS));
    }

    /**
     * @throws VaultException
     * @throws ExecutionException
     * @see static::fromPrimaryKey() if you want the throwing form.
     *
     * @deprecated Use {@see static::find()}. This reads through the operator, hydrates
     *             `$this` in place and hands back the raw row; `find()` returns a fresh
     *             entity (or null) through the finder, so relations and computed fields
     *             come back with it. The two are not drop-in: `find()` does not mutate the
     *             receiver, and a miss is `null` rather than an entity with empty data.
     * @see static::find()
     */
    public function load(int|string $id, bool $skipAccessCheck = false): array
    {
        return $this->applyOperatorResponse($this->operator()->load($id, $skipAccessCheck));
    }

    /**
     * Operator methods that (re)read the record hand back
     * `['updateEntityData' => true, 'data' => [...]]`. Apply that to the entity and give
     * the response back untouched.
     *
     * Entities call their operator explicitly; this is the one piece of the old
     * `__call()` pass-through that was doing real work rather than routing.
     */
    protected function applyOperatorResponse(mixed $response): mixed
    {
        if (is_array($response) && ($response['updateEntityData'] ?? null) === true) {
            $this->setData((array) $response['data'] ?: []);
        }

        return $response;
    }

    /**
     * @throws VaultException
     * @throws ExecutionException
     * @deprecated Use {@see static::findWhere()}. Same difference as {@see static::load()}:
     *             this hydrates `$this` in place and returns the raw row, `findWhere()`
     *             returns a fresh entity or null through the finder.
     * @see static::findWhere()
     */
    public function loadBy(string $field, mixed $value, bool $skipAccessCheck = false): array
    {
        return $this->applyOperatorResponse($this->operator()->loadBy($field, $value, $skipAccessCheck));
    }

    public function entityFields(): array
    {
        return $this->schema()->fields;
    }

    public function getEntityDataFieldNames(): array
    {
        return $this->schema()->names();
    }

    public function setField($field, $value): static
    {
        if (($this->data[$field] ?? null) !== $value) {
            $this->markDirty($field);
        }

        match ($this->fieldKind($field)) {
            EntityFieldKind::DATA => $this->data[$field] = $value,
            EntityFieldKind::VIRTUAL => $this->virtualFieldsCache->set($field, $value),
            EntityFieldKind::RELATION => $this->setRelationField($field, $value),
            default => null,
        };

        return $this;
    }

    /**
     * Setter for individual entity field
     * @param string $field
     * @param mixed $value
     * @return $this
     */
    protected function markDirty(string $field): void
    {
        $this->entityChanged = true;
        $this->dirty[$field] = true;
    }

    private function setRelationField(string $field, Entity|EntityCollection|null $value): void
    {
        if ($value === null) {
            $value = $this->relation($field)->valueWhenEmpty();
        }

        $this->relationCache->set($field, $value);
    }

    /**
     * Allow entities to override this and specify a 'nice name'.
     *
     * @deprecated Use the static {@see Entity::niceName()} — it derives the name from
     *             the class with no instance/DB hit. When a custom label is needed,
     *             override the static niceName() rather than this method.
     */
    public function getNiceName(): string
    {
        return static::niceName();
    }

    /**
     * Construction-free 'nice name'. Override per entity for a custom label.
     */
    public static function niceName(): string
    {
        return Stringy::create(static::entityName())->snakeize()->humanize()->toString();
    }

    /**
     * Construction-free entity name (the class short name).
     */
    public static function entityName(): string
    {
        return new ReflectionClass(static::class)->getShortName();
    }

    public function titleField(): string
    {
        return (string) ($this->name ?? $this->{$this->getPrimaryKey()});
    }

    public function getEntityType(): string
    {
        return __CLASS__;
    }

    public function getEntityTrueName(): string
    {
        return str_replace('Entity', '', $this->getEntityName());
    }

    /**
     * @deprecated Use the static {@see Entity::entityName()} — it needs no instance.
     */
    public function getEntityName(): string
    {
        return static::entityName();
    }

    /**
     * By default, we want to store the current data object
     * @return $this
     * @throws Exception
     */
    public function store(bool $timestamps = true): static
    {
        $inserting = $this->getPrimaryKeyValue() === null;

        $this->setStandardFields($timestamps);

        $data = $this->callHooks([
            $inserting ? Entity::HOOK__BEFORE_FIRST_STORE : null,
            Entity::HOOK__BEFORE_STORE
        ], [...$this->data], $this);

        $this->applyOperatorResponse($this->operator()->store($data));
        $this->virtualFieldsCache->clear();

        $this->callHooks([
            $inserting ? Entity::HOOK__AFTER_FIRST_STORE : null,
            Entity::HOOK__AFTER_STORE
        ], [...$this->data], $this);

        $this->resetDirty();

        return $this;
    }

    public function getPrimaryKeyValue(): string|int|null
    {
        return $this->{$this->getPrimaryKey()};
    }

    public function setStandardFields(bool $timestamps = true): void
    {
        //set created / modified stamps if the fields exist
        if ($timestamps && $this->isDataField('created') && !isset($this->data['created'])) {
            $this->data['created'] = time();
        }

        if ($timestamps && $this->isDataField('modified')) {
            $this->data['modified'] = time();
        }
    }

    /**
     * Dispatches entity-specific then generic for each hook, threading the
     * accumulated result through every one via {@see HookManager::processMany()}.
     */
    protected function callHooks(array $hooks, mixed $input, mixed ...$args): mixed
    {
        $names = [];
        foreach (array_filter($hooks) as $hook) {
            $names[] = self::entityHook($hook, $this::class);
            $names[] = $hook;
        }

        return $this->hookManager->processMany($names, $input, ...$args);
    }

    /** @param class-string<Entity> $entityClass */
    public static function entityHook(string $hook, string $entityClass): string
    {
        return sprintf('%s.%s', $hook, $entityClass);
    }

    /**
     * The fields the operator must drop from an UPDATE payload.
     *
     * The entity owns the policy; the operator owns the moment, because it is the only
     * thing that knows whether a store is an insert or an update — "the primary key is
     * set" does not answer that for an entity with a natural key.
     *
     * An insert strips nothing: `created`/`created_by` are provenance written exactly
     * once, and io-user's AddCreatedByToEntitiesOnSave documents an explicit `created_by`
     * as first-write-wins, with a regression test behind it.
     *
     * Two exemptions: the primary key, which targets the row rather than being a write to
     * it and which the operator needs for its WHERE clause, and the stamps the framework
     * maintains itself.
     */
    public function readOnlyWrites(): array
    {
        return array_values(
            array_diff(
                static::READ_ONLY_FIELDS,
                [$this->getPrimaryKey(), ...static::FRAMEWORK_WRITTEN_FIELDS],
            )
        );
    }

    public function __call($name, $arguments)
    {
        if ($this->isRelationField($name)) {
            return $this->relation($name, ...$arguments);
        }

        if ($this->isVirtualField($name)) {
            return $this->getVirtualField($name, ...$arguments);
        }

        throw new BadMethodCallException(
            sprintf(
                '%s::%s() does not exist. Operator methods are no longer reachable through the entity '
                . 'by magic — call the operator method you want, or add an explicit wrapper.',
                static::class,
                $name,
            )
        );
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    protected function getVirtualField($field)
    {
        if (!$this->virtualFieldsCache->has($field)) {
            $this->virtualFieldsCache->set($field, $this->lazyLoadVirtualField($field));
        }

        return $this->virtualFieldsCache->get($field);
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    private function lazyLoadVirtualField(string $field): mixed
    {
        $this->assertLazyLoadAllowed($field);

        $sql = $this->virtualFields()->get($field)->getSql();

        $row = $this->db()
            ->select($this->getTableName())
            ->fields(sprintf('(%s) AS result', $sql))
            ->where($this->getPrimaryKey(), $this->getField($this->getPrimaryKey()))
            ->limit(1)
            ->execute()
            ->fetch();

        $this->virtualFieldsCache->set($field, $row?->result);

        return $this->virtualFieldsCache->get($field);
    }

    /**
     * @throws LazyLoadException
     */
    private function assertLazyLoadAllowed(string $field): void
    {
        if (!$this->lazyLoadAllower->isAllowed()) {
            throw new LazyLoadException(
                sprintf('Tried to load `%s` on instance of `%s`, but lazy load is not allowed; ', $field, $this::class)
            );
        }
    }

    /**
     * The Vault handle. Lazy for the same reason as the operator — and it is the last
     * direct database dependency on the entity; it belongs on a repository.
     */
    protected function db(): Vault
    {
        return $this->db ??= \Apexl\Io\container()->get(Vault::class);
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    public function getField($field): mixed
    {
        if ($this->isDataField($field)) {
            return $this->getDataField($field);
        }

        if ($this->isVirtualField($field)) {
            return $this->getVirtualField($field);
        }

        if ($this->isRelationField($field)) {
            $bits = explode('__', $field);
            if (count($bits) > 1) {

                $relationField = array_shift($bits);
                $field = implode('__', $bits);

                $relation = $this->getRelationField($relationField);

                if ($relation === null) {
                    return null;
                }

                if ($relation instanceof Entity) {
                    return $relation->getField($field);
                }

                return $relation->map(fn(Entity $entity): mixed => $entity->getField($field));
            }

            return $this->getRelationField($field);
        }

        return null;
    }

    /**
     * @throws ExecutionException
     * @throws LazyLoadException
     */
    private function getDataField($field): mixed
    {
        if (array_key_exists($field, $this->data)) {
            return $this->data[$field];
        } elseif ($field !== $this->getPrimaryKey() && $this->isDataField($field)) {
            return $this->lazyLoadDataField($field);
        }

        return null;
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    private function lazyLoadDataField(string $field): ?string
    {
        $this->assertLazyLoadAllowed($field);

        return $this->db()->select($this->getTableName())
            ->fields($field)
            ->where($this->getPrimaryKey(), $this->getPrimaryKeyValue())
            ->execute()
            ->fetchColumn();
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    private function getRelationField($field): null|Entity|EntityCollection
    {
        if (!$this->relationIsLoaded($field)) {
            $this->relationCache->set($field, $this->lazyLoadRelation($field));
        }

        return $this->relationCache->get($field) ?: $this->relation(
            $field
        )->valueWhenEmpty();
    }

    public function relationIsLoaded(string $relation): bool
    {
        return $this->relationCache->has($relation);
    }

    /**
     * @throws LazyLoadException
     * @throws ExecutionException
     */
    private function lazyLoadRelation($field): EntityCollection|Entity|null
    {
        $this->assertLazyLoadAllowed($field);
        $relation = $this->relation($field);

        // Key the lazy load off the relation's *local* key, not the entity's
        // primary key. They coincide for BelongsTo / HasMany (localKey defaults
        // to the PK), but HasOne holds the foreign key locally (e.g. session_id),
        // so passing the PK would query the wrong value.
        return $relation->lazyLoad($this->getField($relation->getLocalKey()));
    }

    /**
     * Method to delete attached entity (or provided id)
     * @param null $id
     * @return $this
     * @throws Exception
     * @noinspection PhpHierarchyChecksInspection
     */
    public function delete($id = null): Entity
    {
        if ($id) {
            $this->{$this->getPrimaryKey()} = $id;
        }
        $delete = $this->hookManager->processHook('entityPreDelete', $this, $this->getEntityName());
        if ($delete) {
            $this->operator()->delete($this->{$this->getPrimaryKey()});
        }
        $this->hookManager->processHook('entityPostDelete', $this, $this->getEntityName());
        //deleting? clear the data
        $this->data = [];

        return $this;
    }

    /**
     * @throws VaultException
     */
    public function totalEntities(array $filters = []): int
    {
        return $this->operator()->totalEntities($filters);
    }

    /**
     * Entity load multiple
     *
     * @param array $conditions
     * @param array $orderBy
     * @param int|bool $limit
     * @param int|bool $offset
     * @param bool $asEntities
     * @return array
     * @throws Exception
     * @see static::findMultiple()
     *
     * @deprecated Use {@see static::findMultiple()}. This takes positional condition and
     *             orderBy arrays and returns a plain array; `findMultiple()` takes
     *             LoaderConditions and LoaderSorts, returns an EntityCollection, and can
     *             fill a `$paging` reference in the same round trip. The two are not
     *             drop-in: condition shape, sort shape and return type all differ, and
     *             `findMultiple()` has no `$asEntities` — call `->getData()` on what you
     *             get back if you wanted rows.
     */
    public function loadMultiple(
        array $conditions = [],
        array $orderBy = [],
        $limit = null,
        $offset = null,
        bool $asEntities = true
    ): array {
        $loaded = [];
        if (isset($orderBy[1]) && is_string($orderBy[1])) {
            $orderBy[1] = strtolower($orderBy[1]) == 'desc' ? SortDirection::DESC : SortDirection::ASC;
        }
        // The operator wants ?int. Callers still pass `false` for "unset" (userEntity's
        // defaults, among others) and this file is strict_types, so coerce here — which is
        // what the non-strict __call() boundary used to do silently.
        $results = $this->operator()->loadMultiple(
            $conditions,
            $orderBy,
            $limit === null ? null : (int) $limit,
            $offset === null ? null : (int) $offset,
        );
        //make sure data is UTF8 Safe, construct entities if required (default)
        if (!empty($results)) {
            foreach ($results as $result) {
                $data = $this->makeUTF8Safe($result);
                $entityType = get_called_class();
                //set entity data (prevents us from having to reload entities)
                $entity = new $entityType();
                //we setData here even if we don't ultimately want the entities,
                //so we can take advantage of the formatting fixes and allow for any custom setData functionality
                $entity->setData((array) $data);
                //build an array of loaded entities, or just get the data if that's what we want.
                $entity = $this->hookManager->processHook('entityPostLoadMultiple', $entity, $entity->getEntityName());
                //TN do not key by ID as we need to preserve order with a 0 keyed array. This is to allow JS consumers to maintain the order.
                if ($asEntities) {
                    $loaded[] = $entity;
                } else {
                    $loaded[] = $entity->getData();
                }
            }
        }

        return $loaded;
    }

    private function makeUTF8Safe(array|stdClass $data): object
    {
        $object = (object) [];
        foreach ($data as $key => $value) {
            $object->$key = Encoding::fixUTF8($value);
        }

        return $object;
    }

    /**
     * Override of the raw data accessor
     * Force loaded values through __Get for processing.
     * @return array
     * @todo Caching!
     */
    public function getData($forDisplay = true): array|bool
    {
        $data = $this->dataWithoutSensitiveFields();
        if ($forDisplay) {
            $usableData = [];
            //force the loaded data to be passed through __get
            foreach ($data as $field => $value) {
                $value = $this->hrData($field, $value);
                if ($value !== Utils::HIDE_FROM_DISPLAY) {
                    $usableData[$field] = $value;
                }
            }

            return $usableData;
        }

        return $data;
    }

    /**
     * Function to set the entity data array. Useful when checking against loaded entities or for a direct overwrite.
     * @TODO - Why are we converting the encoding?
     * @return $this
     */
    public function setData(array $entity): static
    {
        //loop over and clean utf8 data from the entity. We use this not array_map because utf8_encode converts NULL to blank string.
        $utf8Safe = [];
        if (!empty($entity)) {
            foreach ($entity as $key => $value) {
                $utf8Safe[$key] = is_string($value) ? mb_convert_encoding(
                    $value,
                    'UTF-8',
                    mb_detect_encoding($value)
                ) : $value;
            }
        }
        $this->data = empty($entity) ? [] : json_decode(json_encode($utf8Safe), true);

        return $this;
    }

    /**
     * The entity's data with any field marked sensitive removed. getData() is the public
     * way in; this is what it reads.
     */
    protected function dataWithoutSensitiveFields(): array
    {
        $data = $this->data;
        if (!empty($this->sensitiveDataFields)) {
            foreach ($this->sensitiveDataFields as $field) {
                unset($data[$field]);
            }
        }

        return $data;
    }

    public function hrData($fieldName, $data)
    {
        if (isset($this->fieldConfig[$fieldName]) && isset($this->fieldConfig[$fieldName]['display'])) {
            return $this->fieldConfig[$fieldName]['display']($data, $this);
        }

        return $data;
    }

    /**
     * Function to flag certain data fields as read only (data changed in these fields will not be stored against the original loaded entity)
     * @param string $field
     * @return Entity
     */
    public function getReadOnlyFields(): array
    {
        return static::READ_ONLY_FIELDS;
    }

    /**
     * Function to flag certain data fields as read only (data changed in these fields will not be stored against the original loaded entity)
     * @param array $fields
     */
    public function hrName($fieldName)
    {
        if (isset($this->fieldConfig[$fieldName]) && isset($this->fieldConfig[$fieldName]['name'])) {
            return $this->fieldConfig[$fieldName]['name'];
        }

        return $fieldName;
    }

    public function virtualField(string $field): ?VirtualFieldDefinition
    {
        return $this->virtualFields()->get($field);
    }

    public function jsonSerialize(): mixed
    {
        $data = [];
        foreach (array_keys($this->data) as $key) {
            $value = $this->valueForSerialization($this->__get($key));
            $data[$key] = $value;
        }


        $data = [
            ...$data,
            ...$this->virtualFieldsCache->toArray(),
            'relations' => $this->relationCache->toArray(),
        ];

        return array_filter(
            $data,
            fn($key) => !in_array($key, static::PROTECTED_FIELDS, true),
            ARRAY_FILTER_USE_KEY
        );
    }

    protected function valueForSerialization(mixed $value): mixed
    {
        if ($value instanceof DateTimeInterface) {
            return $value->format('c');
        }

        if ($value instanceof BackedEnum) {
            return $value->value;
        }

        if ($value instanceof UnitEnum) {
            return $value->name;
        }

        if ($value instanceof Entity) {
            return $value->{$value->getPrimaryKey()};
        }

        return $value;
    }

    public function __get($name)
    {
        return $this->getCasted($name);
    }

    public function __set($name, $value): void
    {
        $this->setCasted($name, $value);
    }

    public function withLazyLoadAllowed(callable $callable): mixed
    {
        $overridden = $this->lazyLoadAllower->override(true);
        $result = $callable($this);
        $this->lazyLoadAllower->override($overridden);

        return $result;
    }

    /**
     * Method to allow us to set certain data as 'sensitive'. This prevents the data being returned in the getData() method, but allows it to still be accessible directly.
     */
    public function setSensitiveData($field, $replace = false): static
    {
        if (is_array($field)) {
            $this->sensitiveDataFields = $replace ? $field : array_merge($this->sensitiveDataFields, $field);
        } else {
            $this->sensitiveDataFields[] = $field;
        }

        return $this;
    }

    /**
     * Method to get the names of the fields marked sensitive.
     * @return array
     */
    public function getSensitiveDataNames(): array
    {
        return $this->sensitiveDataFields;
    }

    /**
     * Getter for entity changed state.
     */
    public function isChanged(): bool
    {
        return $this->entityChanged;
    }

    /**
     * Allow us toi reset the change state of an entity (on load / save for example)
     */
    public function resetChanged()
    {
        $this->entityChanged = false;
    }

    /**
     * Allow entities to have defined required fields.
     * @param $fields
     */
    public function setRequiredData($fields)
    {
        $this->requiredData = $fields;
    }

    /**
     * Allow the addition of single fields to the required array
     * @param $field
     */
    public function addRequiredData($field)
    {
        $this->requiredData[] = $field;
    }

    /**
     * Magic PHP __get function to get the required field from the data object, without having to specify it to the entity.
     * @param $name
     * @return bool|mixed
     */
    /**
     * Magic PHP __set function to set provided field value
     * @param $name
     * @param $value
     * @return entity
     */
    /**
     * Getter for individual entity field
     * @param $field
     * @return bool|mixed
     */

    /**
     * If validation fails, allow us to get the data that was missing.
     * @return array
     */
    public function getMissingData(): array
    {
        return $this->missingData;
    }

    /**
     * Function to check if the entity is valid. I.E. it has all of its required fields.
     * @return bool
     */
    public function isValid(): bool
    {
        $valid = true;
        $this->missingData = []; //clear this data at the start of a validation check
        if (!empty($this->requiredData)) {
            foreach ($this->requiredData as $field) {
                if (isset($this->data[$field]) && !empty($this->data[$field])) {
                    $valid = true;
                } else {
                    $valid = false;
                    $this->missingData[] = $field;
                }
            }
        }

        return $valid;
    }

    /**
     * function to determine the entity Type used in request URL generation based in the current class type.
     */
    public function getType(): string
    {
        return $this->type;
    }

    /**
     * Function to convert the entity to a jsonString
     */
    public function __toString(): string
    {
        return (string) json_encode($this->data);
    }

    /**
     * Magic __isset function to allow us to use isset on $data values retrieved by __get
     * @param $name
     * @return bool
     */
    public function __isset($name)
    {
        return isset($this->data[$name]);
    }

    /**
     * Magic __unset function to allow us to unset $data values.
     * @param $name
     */
    public function __unset($name)
    {
        if (isset($this->data[$name])) {
            unset($this->data[$name]);
            $this->entityChanged = true;
        }
    }

    public function isDirty(string $field): bool
    {
        return $this->dirty[$field] ?? false;
    }

    /** @param class-string<Entity> $remoteEntity */
    protected function hasOne(
        string $name,
        string $remoteEntity,
        null|array|string $localKey = null,
        null|array|string $remoteKey = null,
        ?string $aliasedRemoteTable = null,
        ?RelationshipInterface $parent = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?QueryDecorator $rawQuery = null,
        ?RelationOrderBy $orderByCallback = null,
    ): HasOne {
        return HasOne::create(
            name: $name,
            localEntity: $this,
            remoteEntity: $remoteEntity,
            localKey: $localKey,
            remoteKey: $remoteKey,
            aliasedRemoteTable: $aliasedRemoteTable,
            parent: $parent,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawQuery: $rawQuery,
            orderByCallback: $orderByCallback
        );
    }

    /**
     * @param class-string<Entity> $remoteEntity
     */
    protected function belongsTo(
        string $name,
        string $remoteEntity,
        null|array|string $localKey = null,
        null|array|string $remoteKey = null,
        ?RelationshipInterface $parent = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?QueryDecorator $rawQuery = null,
        ?string $aliasedRemoteTable = null,
        ?RelationOrderBy $orderByCallback = null,
    ): BelongsTo {
        return BelongsTo::create(
            name: $name,
            localEntity: $this,
            remoteEntity: $remoteEntity,
            localKey: $localKey,
            remoteKey: $remoteKey,
            parent: $parent,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawQuery: $rawQuery,
            aliasedRemoteTable: $aliasedRemoteTable,
            orderByCallback: $orderByCallback,
        );
    }

    /**
     * @param class-string<Entity> $remoteEntity
     */
    protected function hasOneViaPivot(
        string $name,
        string $remoteEntity,
        null|string|array $localKey = null,
        null|string|array $remoteKey = null,
        ?string $aliasedRemoteTable = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?PivotSqlFragment $rawPivotJoin = null,
        ?QueryDecorator $rawQuery = null,
        null|array|RelationCollection|RelationshipInterface $parent = null,
        bool $noFields = false,
        ?string $pivotTable = null,
        ?string $pivotLocal = null,
        ?string $pivotRemote = null,
        array $pivotFields = [],
        bool $hasWeight = true,
        ?PivotSqlFragment $weightCallback = null,
        ?RelationOrderBy $orderByCallback = null,
    ): HasOneViaPivot {
        return HasOneViaPivot::create(
            name: $name,
            localEntity: $this,
            remoteEntity: $remoteEntity,
            localKey: $localKey,
            remoteKey: $remoteKey,
            aliasedRemoteTable: $aliasedRemoteTable,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawPivotJoin: $rawPivotJoin,
            rawQuery: $rawQuery,
            parent: $parent,
            noFields: $noFields,
            pivotTable: $pivotTable,
            pivotLocal: $pivotLocal,
            pivotRemote: $pivotRemote,
            pivotFields: $pivotFields,
            hasWeight: $hasWeight,
            weightCallback: $weightCallback,
            orderByCallback: $orderByCallback,
        );
    }

    /**
     * @param class-string<Entity> $remoteEntity
     */
    protected function hasMany(
        string $name,
        string $remoteEntity,
        null|string|array $localKey = null,
        null|string|array $remoteKey = null,
        ?string $aliasedRemoteTable = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?PivotSqlFragment $rawPivotJoin = null,
        ?QueryDecorator $rawQuery = null,
        null|array|RelationCollection|RelationshipInterface $parent = null,
        bool $noFields = false,
        ?string $pivotTable = null,
        ?string $pivotLocal = null,
        ?string $pivotRemote = null,
        array $pivotFields = [],
        bool $hasWeight = true,
        ?PivotSqlFragment $weightCallback = null,
        ?RelationOrderBy $orderByCallback = null,
    ): HasMany {
        return HasMany::create(
            name: $name,
            localEntity: $this,
            remoteEntity: $remoteEntity,
            localKey: $localKey,
            remoteKey: $remoteKey,
            aliasedRemoteTable: $aliasedRemoteTable,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawPivotJoin: $rawPivotJoin,
            rawQuery: $rawQuery,
            parent: $parent,
            noFields: $noFields,
            pivotTable: $pivotTable,
            pivotLocal: $pivotLocal,
            pivotRemote: $pivotRemote,
            pivotFields: $pivotFields,
            hasWeight: $hasWeight,
            weightCallback: $weightCallback,
            orderByCallback: $orderByCallback,
        );
    }

    /**
     * @param class-string<Entity> $remoteEntity
     */
    protected function belongsToMany(
        string $name,
        string $remoteEntity,
        null|string|array $localKey = null,
        null|string|array $remoteKey = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?QueryDecorator $rawQuery = null,
        ?RelationshipInterface $parent = null,
        ?string $aliasedRemoteTable = null,
        ?RelationOrderBy $orderByCallback = null,
    ): BelongsToMany {
        return BelongsToMany::create(
            name: $name,
            localEntity: $this,
            remoteEntity: $remoteEntity,
            localKey: $localKey,
            remoteKey: $remoteKey,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawQuery: $rawQuery,
            parent: $parent,
            aliasedRemoteTable: $aliasedRemoteTable,
            orderByCallback: $orderByCallback,
        );
    }

    protected function getOperator(): EntityOperatorInterface
    {
        return $this->operator();
    }

    /**
     * Method to force any provided operators to implement the EntityOperatorInterface interface.
     * @return $this
     */
    public function setOperator(EntityOperatorInterface $operator): static
    {
        $entityClass = get_called_class();
        $entityName = substr($entityClass, strrpos($entityClass, '\\') + 1);
        $entityConfigProperty = str_ireplace("entity", "", $entityName);
        if ($accessControl = \Apexl\Config\config(sprintf('app.accessControl.%s', $entityConfigProperty))) {
            $operator->setAccessControlType($accessControl);
        }

        $operator->setReadOnlyFields($this->readOnlyWrites());

        $this->operator = $operator;

        return $this;
    }
}
