<?php
/**
 * Jingga
 *
 * PHP Version 8.2
 *
 * @package   phpOMS\DataStorage\Database\Mapper
 * @copyright Dennis Eichhorn
 * @license   OMS License 2.2
 * @version   1.0.0
 * @link      https://jingga.app
 */
declare(strict_types=1);

namespace phpOMS\DataStorage\Database\Mapper;

use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\DataStorage\Database\Query\Where;
use phpOMS\Utils\ArrayUtils;

/**
 * Read mapper (SELECTS).
 *
 * @package phpOMS\DataStorage\Database\Mapper
 * @license OMS License 2.2
 * @link    https://jingga.app
 * @since   1.0.0
 *
 * @todo Add getArray functions to get array instead of object
 *      https://github.com/Karaka-Management/phpOMS/issues/350
 *
 * @todo Allow to define columns in all functions instead of members?
 *
 * @template R
 */
final class ReadMapper extends DataMapperAbstract
{
    /**
     * Columns to load
     *
     * @var array
     * @since 1.0.0
     */
    private array $columns = [];

    /**
     * Create get mapper
     *
     * This makes execute() return a single object or an array of object depending the result size
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function get() : self
    {
        $this->type = MapperType::GET;

        return $this;
    }

    /**
     * Create yield mapper
     *
     * This makes execute() return a single object or an array of object depending the result size
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function yield() : self
    {
        $this->type = MapperType::GET_YIELD;

        return $this;
    }

    /**
     * Get raw result set
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function getRaw() : self
    {
        $this->type = MapperType::GET_RAW;

        return $this;
    }

    /**
     * Create get mapper
     *
     * This makes execute() always return an array of objects (or an empty array)
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function getAll() : self
    {
        $this->type = MapperType::GET_ALL;

        return $this;
    }

    /**
     * Create count mapper
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function count() : self
    {
        $this->type = MapperType::COUNT_MODELS;

        return $this;
    }

    /**
     * Create sum mapper
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function sum() : self
    {
        $this->type = MapperType::SUM_MODELS;

        return $this;
    }

    /**
     * Create exists mapper
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function exists() : self
    {
        $this->type = MapperType::MODEL_EXISTS;

        return $this;
    }

    /**
     * Create has mapper
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function has() : self
    {
        $this->type = MapperType::MODEL_HAS_RELATION;

        return $this;
    }

    /**
     * Create random mapper
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function getRandom() : self
    {
        $this->type = MapperType::GET_RANDOM;

        return $this;
    }

    /**
     * Define the columns to load
     *
     * @param array $columns Columns to load
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function columns(array $columns) : self
    {
        $this->columns = $columns;

        return $this;
    }

    /**
     * Define the properties to load
     *
     * @param array $properties Properties to load
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function properties(array $properties) : self
    {
        foreach ($properties as $property) {
            $this->columns[] = $this->mapper::getColumnByMember($property);
        }

        return $this;
    }

    /**
     * Execute mapper
     *
     * @param mixed ...$options Options to pass to read mapper
     *
     * @return R
     *
     * @since 1.0.0
     */
    public function execute(mixed ...$options) : mixed
    {
        switch($this->type) {
            case MapperType::GET:
                /** @var null|Builder ...$options */
                return $this->executeGet(...$options);
            case MapperType::GET_YIELD:
                /** @var null|Builder ...$options */
                return $this->executeYield(...$options);
            case MapperType::GET_RAW:
                /** @var null|Builder ...$options */
                return $this->executeGetRaw(...$options);
            case MapperType::GET_ALL:
                /** @var null|Builder ...$options */
                return $this->executeGetArray(...$options);
            case MapperType::GET_RANDOM:
                return $this->executeRandom();
            case MapperType::COUNT_MODELS:
                return $this->executeCount();
            case MapperType::SUM_MODELS:
                return $this->executeSum();
            case MapperType::MODEL_EXISTS:
                return $this->executeExists();
            case MapperType::MODEL_HAS_RELATION:
                return $this->executeHas();
            default:
                return null;
        }
    }

    /**
     * Execute mapper
     *
     * @param null|Builder $query Query to use instead of the internally generated query
     *                            Careful, this doesn't merge with the internal query.
     *                            If you want to merge it use ->query() instead
     *
     * @return R
     *
     * @since 1.0.0
     */
    public function executeGet(?Builder $query = null) : mixed
    {
        $objs    = [];
        $indexed = [];

        $hasFactory = $this->mapper::hasFactory();
        $baseClass  = $hasFactory ? null : $this->mapper::getBaseModelClass();

        foreach ($this->executeGetRawYield($query) as $row) {
            if ($row === []) {
                continue;
            }

            $value        = $row[$this->mapper::PRIMARYFIELD . '_d' . $this->depth . $this->joinAlias];
            $objs[$value] = $hasFactory ? $this->mapper::createBaseModel($row) : new $baseClass();
            $objs[$value] = $this->populateAbstract($row, $objs[$value]);

            if (!empty($this->indexedBy) && isset($row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias])) {
                if (!isset($indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]])) {
                    $indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]] = [];
                }

                $indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]][] = $objs[$value];
            }
        }

        if (!empty($this->with) && !empty($objs)) {
            $this->loadHasManyRelations($objs);
        }

        if (!empty($this->indexedBy)) {
            return $indexed;
        } elseif ($this->type === MapperType::GET_ALL) {
            return $objs;
        }

        $countResults = \count($objs);
        if ($countResults === 0) {
            return $this->mapper::createNullModel();
        } elseif ($countResults === 1) {
            return \reset($objs);
        }

        return $objs;
    }

    /**
     * Execute mapper
     *
     * @param null|Builder $query Query to use instead of the internally generated query
     *                            Careful, this doesn't merge with the internal query.
     *                            If you want to merge it use ->query() instead
     *
     * @return \Generator<R>
     *
     * @since 1.0.0
     */
    public function executeYield(?Builder $query = null) : \Generator
    {
        foreach ($this->executeGetRawYield($query) as $row) {
            $obj = $this->mapper::createBaseModel($row);
            $obj = $this->populateAbstract($row, $obj);

            if (!empty($this->with)) {
                $this->loadHasManyRelations([$obj]);
            }

            yield $obj;
        }
    }

    /**
     * Execute mapper
     *
     * @param null|Builder $query Query to use instead of the internally generated query
     *                            Careful, this doesn't merge with the internal query.
     *                            If you want to merge it use ->query() instead
     *
     * @return array
     *
     * @since 1.0.0
     */
    public function executeGetRaw(?Builder $query = null) : array
    {
        $query ??= $this->getQuery();
        $results = false;

        try {
            $sth = $query->execute();
            if ($sth !== null) {
                $results = $sth->fetchAll(\PDO::FETCH_ASSOC);
            }
        } catch (\Throwable $t) {
            \phpOMS\Log\FileLogger::getInstance()->error(
                \phpOMS\Log\FileLogger::MSG_FULL, [
                    'message' => $t->getMessage() . ':' . $query->toSql(),
                    'line'    => __LINE__,
                    'file'    => self::class,
                ]
            );
        }

        return $results === false ? [] : $results;
    }

    /**
     * Execute mapper
     *
     * @param null|Builder $query Query to use instead of the internally generated query
     *                            Careful, this doesn't merge with the internal query.
     *                            If you want to merge it use ->query() instead
     *
     * @return array
     *
     * @since 1.0.0
     */
    public function executeGetRawYield(?Builder $query = null)
    {
        $query ??= $this->getQuery();

        try {
            $sth = $query->execute();
            if ($sth === null) {
                yield [];

                return;
            }

            while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) {
                yield $row;
            }
        } catch (\Throwable $t) {
            \phpOMS\Log\FileLogger::getInstance()->error(
                \phpOMS\Log\FileLogger::MSG_FULL, [
                    'message' => $t->getMessage() . ':' . $query->toSql(),
                    'line'    => __LINE__,
                    'file'    => self::class,
                ]
            );

            yield [];
        }
    }

    /**
     * Execute mapper
     *
     * @param null|Builder $query Query to use instead of the internally generated query
     *                            Careful, this doesn't merge with the internal query.
     *                            If you want to merge it use ->query() instead
     *
     * @return R[]
     *
     * @since 1.0.0
     */
    public function executeGetArray(?Builder $query = null) : array
    {
        $this->getAll();

        $result = $this->executeGet($query);

        if (\is_object($result)
            && (\str_starts_with($class = \get_class($result), 'Null') || \stripos($class, '\Null') !== false)
        ) {
            return [];
        }

        return \is_array($result) ? $result : [$result];
    }

    /**
     * Count the number of elements
     *
     * @return int
     *
     * @since 1.0.0
     */
    public function executeCount() : int
    {
        $this->count();

        $query = $this->getQuery(
            null,
            [
                'COUNT(' . (empty($this->columns) ? '*' : \implode(',', $this->columns)) . ')' => 'count',
            ]
        );

        return (int) $query->execute()?->fetchColumn();
    }

    /**
     * Sum the number of elements
     *
     * @return int|float
     *
     * @since 1.0.0
     */
    public function executeSum() : int|float
    {
        $this->sum();

        $query = $this->getQuery(
            null,
            [
                'SUM(' . (empty($this->columns) ? '*' : \implode(',', $this->columns)) . ')' => 'sum',
            ]
        );

        $result = $query->execute()?->fetchColumn();
        if (empty($result)) {
            return 0;
        }

        return \stripos($result, '.') === false ? (int) $result : (float) $result;
    }

    /**
     * Check if any element exists
     *
     * @return bool
     *
     * @since 1.0.0
     */
    public function executeExists() : bool
    {
        $this->exists();

        $query = $this->getQuery(null, [1]);

        return ($query->execute()?->fetchColumn() ?? 0) > 0;
    }

    /**
     * Check if any element exists
     *
     * @return bool
     *
     * @since 1.0.0
     */
    public function executeHas() : bool
    {
        $obj = isset($this->where[$this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal']])
            ? $this->mapper::createNullModel($this->where[$this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal']][0]['value'])
            : $this->columns([1])->executeGet();

        return $this->hasManyRelations($obj);
    }

    /**
     * Get random object
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    public function executeRandom() : mixed
    {
        $query = $this->getQuery();
        $query->random($this->mapper::PRIMARYFIELD);

        return $this->executeGet($query);
    }

    /**
     * Get mapper specific builder
     *
     * @param Builder $query   Query to fill
     * @param array   $columns Columns to use
     *
     * @return Builder
     *
     * @since 1.0.0
     */
    public function getQuery(?Builder $query = null, array $columns = []) : Builder
    {
        $query ??= $this->query ?? new Builder($this->db, true);

        if (empty($columns) && $this->type < MapperType::COUNT_MODELS) {
            $columns = empty($this->columns) ? $this->mapper::COLUMNS : $this->columns;
        }

        foreach ($columns as $key => $values) {
            if (\is_array($values)
                && (($values['writeonly'] ?? false) === false || isset($this->with[$values['internal']]))
            ) {
                if (\is_int($key)) {
                    $query->select($key);
                } else {
                    $query->selectAs(
                        $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $key,
                        $key . '_d' . $this->depth . $this->joinAlias
                    );
                }
            } elseif (\is_int($values)) {
                $query->select($values);
            } elseif (\is_string($values)) {
                $query->selectAs($key, $values);
            }
        }

        if (empty($query->from)) {
            $query->fromAs($this->mapper::TABLE, $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias);
        }

        // Join tables manually without using "with()" (NOT hasMany/owns one etc.)
        // This is necessary for special cases, e.g. when joining in the other direction
        // Example: Show all profiles who have written a news article.
        //          "with()" only allows to go from articles to accounts but we want to go the other way
        //
        // @feature Create join functionality for mappers which supports joining and filtering based on other tables
        //      Example: show all profiles which have written a news article
        //      https://github.com/Karaka-Management/phpOMS/issues/253
        foreach ($this->join as $member => $values) {
            if (($col = $this->mapper::getColumnByMember($member)) === null) {
                continue;
            }

            /* variable in model */
            // @todo join handling is extremely ugly, needs to be refactored
            foreach ($values as $join) {
                // @todo the hasMany, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
                if ($join['child'] !== '') {
                    continue;
                }

                if (isset($join['mapper']::HAS_MANY[$join['value']])) {
                    if (isset($join['mapper']::HAS_MANY[$join['value']]['external'])) {
                        $relJoinTable = $join['mapper']::HAS_MANY[$join['value']]['table'];

                        // join with relation table
                        $query->join($relJoinTable, $join['type'], $relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias)
                            ->on(
                                $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col,
                                '=',
                                $relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::HAS_MANY[$join['value']]['external'],
                                'AND',
                                $relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias
                            );

                        // join with model table
                        $query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias)
                            ->on(
                                $relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::HAS_MANY[$join['value']]['self'],
                                '=',
                                $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::PRIMARYFIELD,
                                'AND',
                                $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias
                            );

                        if (isset($this->on[$join['value']])) {
                            foreach ($this->on[$join['value']] as $on) {
                                $query->where(
                                    $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::getColumnByMember($on['member']),
                                    '=',
                                    $on['value'],
                                    'AND'
                                );
                            }
                        }
                    }
                } else {
                    $query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias)
                        ->on(
                            $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col,
                            '=',
                            $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::getColumnByMember($join['value']),
                            'AND',
                            $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias
                        );
                }
            }
        }

        // where
        foreach ($this->where as $member => $values) {
            // handle where query
            if ($member === '' && $values[0]['value'] instanceof Where) {
                $query->where($values[0]['value'], boolean: $values[0]['comparison']);

                continue;
            }

            if (($col = $this->mapper::getColumnByMember($member)) === null) {
                continue;
            }

            // In case alternative where values are allowed
            // This is different from normal or conditions as these are exclusive or conditions
            // This means they are only selected IFF the previous where clause fails
            $alt = [];

            /* variable in model */
            $previous = null;
            foreach ($values as $where) {
                // @todo the hasMany, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails
                if ($where['child'] !== '') {
                    continue;
                }

                $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'IN' : $where['logic'];
                if ($where['comparison'] === 'ALT') {
                    // This uses an alternative value if the previous value(s) in the where clause don't exist (e.g. for localized results where you allow a user language, alternatively a primary language, and then alternatively any language if the first two don't exist).

                    // is first value
                    if (empty($alt)) {
                        $alt[] = $previous['value'];
                    }

                    /*
                    select * from table_name
                        where // where starts here
                            field1 = 'value1' // comes from normal where
                            or ( // where1 starts here
                                field1 = 'default'
                                and NOT EXISTS ( // where2 starts here
                                    select 1 from table_name where field1 = 'value1'
                                )
                            )
                    */
                    $where1 = new Where($this->db);
                    $where1->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where['value'], 'and');

                    $where2 = new Builder($this->db);
                    $where2->select(1)
                        ->from($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias)
                        ->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, 'in', $alt);

                    $where1->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, 'not exists', $where2, 'and');

                    $query->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where1, 'or');

                    $alt[] = $where['value'];
                } else {
                    $previous = $where;
                    $query->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where['value'], $where['comparison']);
                }
            }
        }

        // load relations
        foreach ($this->with as $member => $data) {
            $rel = null;
            if ((isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member]))
                || (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column']))
            ) {
                $rel = $this->mapper::OWNS_ONE[$member] ?? (
                        $this->mapper::BELONGS_TO[$member] ?? (
                            $this->mapper::HAS_MANY[$member] ?? null
                        )
                    );
            } else {
                continue;
            }

            foreach ($data as $with) {
                if ($with['child'] !== '') {
                    continue;
                }

                if (isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member])) {
                    $tableAlias = $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '_' . $member;
                    $query->leftJoin($rel['mapper']::TABLE, $tableAlias)
                        ->on(
                            $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $rel['external'], '=',
                            $tableAlias . '.' . (
                                isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['mapper']::PRIMARYFIELD
                            ), 'and',
                            $tableAlias
                        );
                } elseif (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column'])) {
                    // get HasManyQuery (but only for elements which have a 'column' defined)
                    $tableAlias = $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '_' . $member;

                    // @todo handle self and self === null
                    $query->leftJoin($rel['mapper']::TABLE, $tableAlias)
                        ->on(
                            $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . ($rel['external'] ?? $this->mapper::PRIMARYFIELD), '=',
                            $tableAlias . '.' . (
                                isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['self']
                            ), 'and',
                            $tableAlias
                        );
                }

                /** @var self $relMapper */
                $relMapper            = $this->createRelationMapper($rel['mapper']::reader(db: $this->db), $member);
                $relMapper->depth     = $this->depth + 1;
                $relMapper->type      = $this->type;
                $relMapper->joinAlias = '_' . $member;

                // Here we go further into the depth of the model (e.g. a hasMany/ownsOne can again have ownsOne...)
                $query = $relMapper->getQuery(
                    $query,
                    isset($rel['column']) ? [$rel['mapper']::getColumnByMember($rel['column']) => []] : []
                );

                break; // there is only one root element (one element with child === '')
            }
        }

        // handle sort, the column name order is very important. Therefore it cannot be done in the foreach loop above!
        foreach ($this->sort as $member => $data) {
            foreach ($data as $sort) {
                if (($column = $this->mapper::getColumnByMember($member)) === null
                    || ($sort['child'] !== '')
                ) {
                    continue;
                }

                $query->orderBy($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $column, $sort['order']);

                // @bug It looks like that only one sort parameter is supported despite SQL supporting multiple
                //      https://github.com/Karaka-Management/phpOMS/issues/364
                break; // there is only one root element (one element with child === '')
            }
        }

        // handle limit
        foreach ($this->limit as $member => $data) {
            if ($member !== '') {
                continue;
            }

            foreach ($data as $limit) {
                if ($limit['child'] === '') {
                    $query->limit($limit['limit']);

                    break 2; // there is only one root element (one element with child === '')
                }
            }
        }

        return $query;
    }

    /**
     * Populate data.
     *
     * @param array  $result Query result set
     * @param object $obj    Object to populate
     *
     * @return object
     *
     * @since 1.0.0
     */
    public function populateAbstract(array $result, object $obj) : object
    {
        $refClass = null;

        $aValue    = null;
        $arrayPath = '';

        foreach ($this->mapper::COLUMNS as $column => $def) {
            $alias = $column . '_d' . $this->depth . $this->joinAlias;
            if (!\array_key_exists($alias, $result)) {
                continue;
            }

            $value     = $result[$alias];
            $hasPath   = false;
            $refProp   = null;
            $isPrivate = $def['private'] ?? false;
            $member    = $def['internal'];

            if ($isPrivate && $refClass === null) {
                $refClass = new \ReflectionClass($obj);
            }

            if (\stripos($member, '/') !== false) {
                $hasPath = true;
                $path    = \explode('/', \ltrim($member, '/'));
                $member  = $path[0];

                if ($isPrivate) {
                    $refProp = $refClass->getProperty($path[0]);
                    $aValue  = $refProp->getValue($obj);
                } else {
                    $aValue = $obj->{$path[0]};
                }

                \array_shift($path);
                $arrayPath = \implode('/', $path);
            } elseif ($isPrivate) {
                $refProp = $refClass->getProperty($member);
            }

            $type = $def['type'];
            if (isset($this->mapper::OWNS_ONE[$member])) {
                $default = null;
                if (!isset($this->with[$member])
                    && ($isPrivate ? $refProp->isInitialized($obj) : isset($obj->{$member}))
                ) {
                    $default = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
                }

                $value = $this->populateOwnsOne($member, $result, $default);
            } elseif (isset($this->mapper::BELONGS_TO[$member])) {
                $default = null;
                if (!isset($this->with[$member])
                    && ($isPrivate ? $refProp->isInitialized($obj) : isset($obj->{$member}))
                ) {
                    $default = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
                }

                $value = $this->populateBelongsTo($member, $result, $default);
            } elseif (\in_array($type, ['string', 'compress', 'int', 'float', 'bool'])) {
                if ($value !== null && $type === 'compress') {
                    $type  = 'string';
                    $value = \gzinflate($value);
                }

                if ($value !== null
                    || ($isPrivate ? $refProp->getValue($obj) !== null : $obj->{$member} !== null)
                ) {
                    \settype($value, $type);
                }

                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'DateTime') {
                $value = $value === null ? null : new \DateTime($value);
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'DateTimeImmutable') {
                $value = $value === null ? null : new \DateTimeImmutable($value);
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'Json') {
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }

                $value = \json_decode($value, true);
            } elseif ($type === 'Serializable') {
                $mObj = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};

                if ($mObj !== null && $value !== null) {
                    $mObj->unserialize($value);
                    $value = $mObj;
                }
            }

            if ($isPrivate) {
                $refProp->setValue($obj, $value);
            } else {
                $obj->{$member} = $value;
            }
        }

        if (empty($this->with)) {
            return $obj;
        }

        // This is only for hasMany elements where only one hasMany child object is loaded
        // Example: A model usually only loads one l11n element despite having localizations for multiple languages
        // @todo The code below is basically a copy of the foreach from above.
        //       Maybe we can combine them in a smart way without adding much overhead
        foreach ($this->mapper::HAS_MANY as $member => $def) {
            // Only if column is defined do we have a pseudo 1-to-1 relation
            // The content of the column will be loaded directly in the member variable
            if (!isset($this->with[$member])
                || !isset($def['column'])
            ) {
                continue;
            }

            $column = $def['mapper']::getColumnByMember($def['column'] ?? $member);
            $alias  = $column . '_d' . ($this->depth + 1) . '_' . $member;

            if (!\array_key_exists($alias, $result)) {
                continue;
            }

            $value     = $result[$alias];
            $hasPath   = false;
            $refProp   = null;
            $isPrivate = $def['private'] ?? false;

            if ($isPrivate && $refClass === null) {
                $refClass = new \ReflectionClass($obj);
            }

            if (\stripos($member, '/') !== false) {
                $hasPath = true;
                $path    = \explode('/', \ltrim($member, '/'));
                $member  = $path[0];

                if ($isPrivate) {
                    $refProp = $refClass->getProperty($path[0]);
                    $aValue  = $refProp->getValue($obj);
                } else {
                    $aValue = $obj->{$path[0]};
                }

                \array_shift($path);
                $arrayPath = \implode('/', $path);
            } elseif ($isPrivate) {
                $refProp = $refClass->getProperty($member);
            }

            $type = $def['mapper']::COLUMNS[$column]['type'];
            if (\in_array($type, ['string', 'compress', 'int', 'float', 'bool'])) {
                if ($value !== null && $type === 'compress') {
                    $type  = 'string';
                    $value = \gzinflate($value);
                }

                if ($value !== null
                    || ($isPrivate ? $refProp->getValue($obj) !== null : $obj->{$member} !== null)
                ) {
                    \settype($value, $type);
                }

                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'DateTime') {
                $value = $value === null ? null : new \DateTime($value);
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'DateTimeImmutable') {
                $value = $value === null ? null : new \DateTimeImmutable($value);
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }
            } elseif ($type === 'Json') {
                if ($hasPath) {
                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
                }

                $value = \json_decode($value, true);
            } elseif ($type === 'Serializable') {
                $mObj = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};

                if ($mObj !== null && $value !== null) {
                    $mObj->unserialize($value);
                    $value = $mObj;
                }
            }

            if ($isPrivate) {
                $refProp->setValue($obj, $value);
            } else {
                $obj->{$member} = $value;
            }
        }

        return $obj;
    }

    /**
     * Populate data.
     *
     * @param string $member  Member name
     * @param array  $result  Result data
     * @param mixed  $default Default value
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    public function populateOwnsOne(string $member, array $result, mixed $default = null) : mixed
    {
        /** @var class-string<DataMapperFactory> $mapper */
        $mapper = $this->mapper::OWNS_ONE[$member]['mapper'];

        if (!isset($this->with[$member])) {
            if (\array_key_exists($this->mapper::OWNS_ONE[$member]['external'] . '_d' . $this->depth . $this->joinAlias, $result)) {
                return isset($this->mapper::OWNS_ONE[$member]['column'])
                    ? $result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . $this->depth . $this->joinAlias]
                    : $mapper::createNullModel(
                        $result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . $this->depth . $this->joinAlias],
                        $this->mapper::OWNS_ONE[$member]['by'] ?? null
                    );
            } else {
                return $default;
            }
        } elseif (isset($this->mapper::OWNS_ONE[$member]['column'])) {
            return $result[$mapper::getColumnByMember($this->mapper::OWNS_ONE[$member]['column']) . '_d' . $this->depth . '_' . $member];
        } elseif (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1) . '_' . $member])) {
            return $mapper::createNullModel();
        }

        /** @var self $ownsOneMapper */
        $ownsOneMapper            = $this->createRelationMapper($mapper::get($this->db), $member);
        $ownsOneMapper->depth     = $this->depth + 1;
        $ownsOneMapper->joinAlias = '_' . $member;

        return $ownsOneMapper->populateAbstract($result, $mapper::createBaseModel($result));
    }

    /**
     * Populate data.
     *
     * @param string $member  Member name
     * @param array  $result  Result data
     * @param mixed  $default Default value
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    public function populateBelongsTo(string $member, array $result, mixed $default = null) : mixed
    {
        /** @var class-string<DataMapperFactory> $mapper */
        $mapper = $this->mapper::BELONGS_TO[$member]['mapper'];

        if (!isset($this->with[$member])) {
            if (\array_key_exists($this->mapper::BELONGS_TO[$member]['external'] . '_d' . $this->depth . $this->joinAlias, $result)) {
                return isset($this->mapper::BELONGS_TO[$member]['column'])
                    ? $result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . $this->depth . $this->joinAlias]
                    : $mapper::createNullModel(
                        $result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . $this->depth . $this->joinAlias],
                        $this->mapper::BELONGS_TO[$member]['by'] ?? null
                    );
            } else {
                return $default;
            }
        } elseif (isset($this->mapper::BELONGS_TO[$member]['column'])) {
            return $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['column']) . '_d' . $this->depth . '_' . $member];
        } elseif (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1) . '_' . $member])) {
            return $mapper::createNullModel();
        } elseif (isset($this->mapper::BELONGS_TO[$member]['by'])) {
            // get the belongs to based on a different column (not primary key)
            // this is often used if the value is actually a different model:
            //      you want the profile but the account id is referenced
            //      in this case you can get the profile by loading the profile based on the account reference column

            /** @var self $belongsToMapper */
            $belongsToMapper            = $this->createRelationMapper($mapper::get($this->db), $member);
            $belongsToMapper->depth     = $this->depth + 1;
            $belongsToMapper->joinAlias = '_' . $member;

            $belongsToMapper->where(
                $this->mapper::BELONGS_TO[$member]['by'],
                $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['by']) . '_d' . ($this->depth + 1) . '_' . $member],
                '='
            );

            return $belongsToMapper->execute();
        }

        /** @var self $belongsToMapper */
        $belongsToMapper            = $this->createRelationMapper($mapper::get($this->db), $member);
        $belongsToMapper->depth     = $this->depth + 1;
        $belongsToMapper->joinAlias = '_' . $member;

        return $belongsToMapper->populateAbstract($result, $mapper::createBaseModel($result));
    }

    /**
     * Fill object with relations
     *
     * @param object[] $objs Object to fill
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function loadHasManyRelations(array $objs) : void
    {
        $primaryKeys = [];
        foreach ($objs as $idx => $obj) {
            $key = $this->mapper::getObjectId($obj);

            if (!empty($key)) {
                $primaryKeys[$idx] = $key;
            }
        }

        if (empty($primaryKeys)) {
            return;
        }

        $refClass = null;

        // @todo Check if there are more cases where the relation is already loaded with joins etc.
        //      There can be pseudo hasMany elements like localizations.
        //      They are hasMany but these are already loaded with joins!
        foreach ($this->with as $member => $withData) {
            if (isset($this->mapper::HAS_MANY[$member])) {
                $many = $this->mapper::HAS_MANY[$member];
                if (isset($many['column'])) {
                    continue;
                }

                $isPrivate = $withData['private'] ?? false;

                $objectMapper = $this->createRelationMapper($many['mapper']::get(db: $this->db), $member);
                if ($many['external'] === null) {
                    $objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKeys);
                    $objectMapper->indexedBy($many['self']);
                } else {
                    $query = new Builder($this->db, true);
                    $query
                        ->selectAs($many['table'] . '.' . $many['self'], $many['self'] . '_d' . $this->depth . $this->joinAlias)
                        ->leftJoin($many['table'])
                        ->on($many['mapper']::TABLE . '_d1.' . $many['mapper']::PRIMARYFIELD, '=', $many['table'] . '.' . $many['external'])
                        ->where($many['table'] . '.' . $many['self'], 'IN', $primaryKeys);

                    // Cannot use join, because join only works on members and we don't have members for a relation table
                    // This is why we need to create a "base" query which contains the join on table columns
                    $objectMapper->query($query);
                    $objectMapper->indexedBy($many['self']);
                }

                $objects = $objectMapper->execute();
                if (empty($objects) || !\is_array($objects)) {
                    continue;
                }

                if ($isPrivate) {
                    $refClass ??= new \ReflectionClass($obj);

                    foreach ($primaryKeys as $idx => $key) {
                        if (!isset($objects[$key])) {
                            continue;
                        }

                        if (($many['conditional'] ?? false) && \is_array($objects[$key])) {
                            $objects[$key] = \reset($objects[$key]);
                        }

                        $refProp = $refClass->getProperty($member);
                        $refProp->setValue($objs[$idx], !\is_array($objects[$key]) && ($many['conditional'] ?? false) === false
                            ? [$many['mapper']::getObjectId($objects[$key]) => $objects[$key]]
                            : $objects[$key] // if conditional === true the obj will be assigned (e.g. hasMany localizations but only one is loaded for the model)
                        );
                    }
                } else {
                    foreach ($primaryKeys as $idx => $key) {
                        if (!isset($objects[$key])) {
                            continue;
                        }

                        if (($many['conditional'] ?? false) && \is_array($objects[$key])) {
                            $objects[$key] = \reset($objects[$key]);
                        }

                        $objs[$idx]->{$member} = !\is_array($objects[$key]) && ($many['conditional'] ?? false) === false
                            ? [$many['mapper']::getObjectId($objects[$key]) => $objects[$key]]
                            : $objects[$key]; // if conditional === true the obj will be assigned (e.g. hasMany localizations but only one is loaded for the model)
                    }
                }

                continue;
            } elseif (isset($this->mapper::OWNS_ONE[$member])
                || isset($this->mapper::BELONGS_TO[$member])
            ) {
                if (\count($withData) < 2) {
                    continue;
                }

                $relation = isset($this->mapper::OWNS_ONE[$member])
                    ? $this->mapper::OWNS_ONE[$member]
                    : $this->mapper::BELONGS_TO[$member];

                /** @var ReadMapper $relMapper */
                $relMapper = $this->createRelationMapper($relation['mapper']::reader($this->db), $member);

                $isPrivate = $relation['private'] ?? false;
                $tempObjs  = [];

                if ($isPrivate) {
                    $refClass ??= new \ReflectionClass($obj);

                    $refProp = $refClass->getProperty($member);

                    foreach ($objs as $obj) {
                        $tempObjs[] = $refProp->getValue($obj);
                    }
                } else {
                    foreach ($objs as $obj) {
                        $tempObjs[] = $obj->{$member};
                    }
                }

                $relMapper->loadHasManyRelations($tempObjs);
            }
        }
    }

    /**
     * Checks if object has certain relations
     *
     * @param object $obj Object to check
     *
     * @return bool
     *
     * @since 1.0.0
     */
    public function hasManyRelations(object $obj) : bool
    {
        if (empty($this->with)) {
            return true;
        }

        $primaryKey = $this->mapper::getObjectId($obj);
        if (empty($primaryKey)) {
            return false;
        }

        $refClass = null;

        // @performance Check if there are more cases where the relation is already loaded with joins etc.
        //      There can be pseudo hasMany elements like localizations. They are hasMany but these are already loaded with joins!
        //      Variation of https://github.com/Karaka-Management/phpOMS/issues/363
        foreach ($this->with as $member => $withData) {
            if (isset($this->mapper::HAS_MANY[$member])) {
                $many = $this->mapper::HAS_MANY[$member];
                if (isset($many['column'])) {
                    continue;
                }

                $isPrivate = $many['private'] ?? false;

                $objectMapper = $this->createRelationMapper($many['mapper']::exists(db: $this->db), $member);
                if ($many['external'] === null/* same as $many['table'] !== $many['mapper']::TABLE */) {
                    $objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKey);
                } else {
                    $query = new Builder($this->db, true);
                    $query->leftJoin($many['table'])
                        ->on($many['mapper']::TABLE . '_d1.' . $many['mapper']::PRIMARYFIELD, '=', $many['table'] . '.' . $many['external'])
                        ->where($many['table'] . '.' . $many['self'], '=', $primaryKey);

                    // Cannot use join, because join only works on members and we don't have members for a relation table
                    // This is why we need to create a "base" query which contains the join on table columns
                    $objectMapper->query($query);
                }

                $objects = $objectMapper->execute();

                return !empty($objects) && $objects !== false;
            } elseif (isset($this->mapper::OWNS_ONE[$member])
                || isset($this->mapper::BELONGS_TO[$member])
            ) {
                if (\count($withData) < 2) {
                    continue;
                }

                $relation = isset($this->mapper::OWNS_ONE[$member])
                    ? $this->mapper::OWNS_ONE[$member]
                    : $this->mapper::BELONGS_TO[$member];

                /** @var ReadMapper $relMapper */
                $relMapper = $this->createRelationMapper($relation['mapper']::reader($this->db), $member);

                $isPrivate = $relation['private'] ?? false;
                if ($isPrivate) {
                    $refClass ??= new \ReflectionClass($obj);

                    $refProp = $refClass->getProperty($member);
                    return $relMapper->hasManyRelations($refProp->getValue($obj));
                } else {
                    return $relMapper->hasManyRelations($obj->{$member});
                }
            }
        }
    }

    /**
     * Paginate results
     *
     * @param string      $member Member to use for pagination
     * @param null|string $ptype  Pagination type (previous/next)
     * @param mixed       $offset Offset
     *
     * @return self
     *
     * @since 1.0.0
     */
    public function paginate(string $member, ?string $ptype, mixed $offset) : self
    {
        if ($ptype === 'p') {
            $this->where($member, $offset ?? 0, '<');
        } elseif ($ptype === 'n') {
            $this->where($member, $offset ?? 0, '>');
        } else {
            $this->where($member, 0, '>');
        }

        return $this;
    }
}
