autowp/autowp

View on GitHub
module/Application/src/Model/Item.php

Summary

Maintainability
F
2 wks
Test Coverage
D
60%
<?php

namespace Application\Model;

use Application\Module;
use ArrayAccess;
use ArrayObject;
use Autowp\TextStorage\Service as TextStorage;
use DateTime;
use Exception;
use Laminas\Db\Adapter\Adapter;
use Laminas\Db\Sql;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\Paginator;
use Location\Coordinate;

use function array_diff;
use function array_keys;
use function array_merge;
use function array_replace;
use function array_search;
use function array_splice;
use function array_unique;
use function array_values;
use function Autowp\Commons\currentFromResultSetInterface;
use function Autowp\Commons\parsePointWkb;
use function count;
use function is_array;
use function is_numeric;
use function is_scalar;
use function key;
use function min;
use function str_repeat;

use const SORT_STRING;

class Item
{
    public const
        VEHICLE   = 1,
        ENGINE    = 2,
        CATEGORY  = 3,
        TWINS     = 4,
        BRAND     = 5,
        FACTORY   = 6,
        MUSEUM    = 7,
        PERSON    = 8,
        COPYRIGHT = 9;

    public const MAX_NAME = 100;

    private TableGateway $specTable;

    private TableGateway $itemTable;

    private TableGateway $itemPointTable;

    private TableGateway $vehicleTypeParentTable;

    private TableGateway $itemLanguageTable;

    private TextStorage $textStorage;

    private TableGateway $itemParentCacheTable;

    private TableGateway $itemParentTable;

    private TableGateway $itemParentLanguageTable;

    private LanguagePriority $languagePriority;

    public function __construct(
        TableGateway $specTable,
        TableGateway $itemPointTable,
        TableGateway $vehicleTypeParentTable,
        TableGateway $itemLanguageTable,
        TextStorage $textStorage,
        TableGateway $itemTable,
        TableGateway $itemParentTable,
        TableGateway $itemParentLanguageTable,
        TableGateway $itemParentCacheTable
    ) {
        $this->specTable               = $specTable;
        $this->itemTable               = $itemTable;
        $this->itemPointTable          = $itemPointTable;
        $this->vehicleTypeParentTable  = $vehicleTypeParentTable;
        $this->itemLanguageTable       = $itemLanguageTable;
        $this->textStorage             = $textStorage;
        $this->itemParentTable         = $itemParentTable;
        $this->itemParentLanguageTable = $itemParentLanguageTable;
        $this->itemParentCacheTable    = $itemParentCacheTable;

        $this->languagePriority = new LanguagePriority();
    }

    public function getEngineVehiclesGroups(int $engineId, array $options = []): array
    {
        $defaults = [
            'groupJoinLimit' => null,
        ];
        $options  = array_replace($defaults, $options);

        $select = new Sql\Select($this->itemTable->getTable());
        $select->columns(['id'])
            ->join('item_parent_cache', 'item.engine_item_id = item_parent_cache.item_id', [])
            ->where(['item_parent_cache.parent_id' => $engineId]);
        $vehicleIds = [];
        foreach ($this->itemTable->selectWith($select) as $row) {
            $vehicleIds[] = (int) $row['id'];
        }

        $vectors = [];
        foreach ($vehicleIds as $vehicleId) {
            $select = new Sql\Select($this->itemParentCacheTable->getTable());
            $select->columns(['parent_id'])
                ->join('item', 'item_parent_cache.parent_id = item.id', [])
                ->where([
                    'item.item_type_id'         => self::VEHICLE,
                    'item_parent_cache.item_id' => $vehicleId,
                    'item_parent_cache.item_id != item_parent_cache.parent_id',
                ])
                ->order('item_parent_cache.diff desc');
            $parentIds = [];
            foreach ($this->itemParentCacheTable->selectWith($select) as $row) {
                $parentIds[] = (int) $row['parent_id'];
            }

            // remove parents
            foreach ($parentIds as $parentId) {
                $index = array_search($parentId, $vehicleIds);
                if ($index !== false) {
                    unset($vehicleIds[$index]);
                }
            }

            $vector   = $parentIds;
            $vector[] = (int) $vehicleId;

            $vectors[] = $vector;
        }

        if ($options['groupJoinLimit'] && count($vehicleIds) <= $options['groupJoinLimit']) {
            return $vehicleIds;
        }

        do {
            // look for same root

            $matched = false;
            for ($i = 0; ($i < count($vectors) - 1) && ! $matched; $i++) {
                for ($j = $i + 1; $j < count($vectors) && ! $matched; $j++) {
                    if ($vectors[$i][0] === $vectors[$j][0]) {
                        $matched = true;
                        // matched root
                        $newVector = [];
                        $length    = min(count($vectors[$i]), count($vectors[$j]));
                        for ($k = 0; $k < $length && $vectors[$i][$k] === $vectors[$j][$k]; $k++) {
                            $newVector[] = $vectors[$i][$k];
                        }
                        $vectors[$i] = $newVector;
                        array_splice($vectors, $j, 1);
                    }
                }
            }
        } while ($matched);

        $resultIds = [];
        foreach ($vectors as $vector) {
            $resultIds[] = $vector[count($vector) - 1];
        }

        return $resultIds;
    }

    public function setLanguageName(int $id, string $language, string $name): void
    {
        $primaryKey = [
            'item_id'  => $id,
            'language' => $language,
        ];
        $set        = [
            'name' => $name,
        ];

        $row = currentFromResultSetInterface($this->itemLanguageTable->select($primaryKey));

        if (! $row) {
            $this->itemLanguageTable->insert(array_replace($set, $primaryKey));
            return;
        }

        $this->itemLanguageTable->update($set, $primaryKey);
    }

    /**
     * @throws Exception
     */
    public function getUsedLanguagesCount(int $id): int
    {
        $select = new Sql\Select($this->itemLanguageTable->getTable());
        $select->columns(['count' => new Sql\Expression('count(1)')])
            ->where([
                'item_id'       => $id,
                'language != ?' => 'xx',
            ]);

        $row = currentFromResultSetInterface($this->itemLanguageTable->selectWith($select));
        return $row ? (int) $row['count'] : 0;
    }

    public function getTextsOfItem(int $id, string $language): array
    {
        $select = new Sql\Select($this->itemLanguageTable->getTable());

        $select
            ->where(['item_id' => $id])
            ->order([new Sql\Expression('language = ? desc', [$language])]);

        $rows = $this->itemLanguageTable->selectWith($select);

        $textIds     = [];
        $fullTextIds = [];
        foreach ($rows as $row) {
            if ($row['text_id']) {
                $textIds[] = $row['text_id'];
            }
            if ($row['full_text_id']) {
                $fullTextIds[] = $row['full_text_id'];
            }
        }

        $description = null;
        if ($textIds) {
            $description = $this->textStorage->getFirstText($textIds);
        }

        $text = null;
        if ($fullTextIds) {
            $text = $this->textStorage->getFirstText($fullTextIds);
        }

        return [
            'full_text' => $text,
            'text'      => $description,
        ];
    }

    public function hasFullText(int $id): bool
    {
        $rows = $this->itemLanguageTable->select([
            'item_id' => $id,
            new Sql\Predicate\IsNotNull('full_text_id'),
        ]);

        $ids = [];
        foreach ($rows as $row) {
            $ids[] = $row['full_text_id'];
        }

        if (! $ids) {
            return false;
        }

        return (bool) $this->textStorage->getFirstText($ids);
    }

    public function getNames(int $itemId): array
    {
        $rows   = $this->itemLanguageTable->select([
            'item_id' => $itemId,
            new Sql\Predicate\Expression('length(name) > 0'),
        ]);
        $result = [];
        foreach ($rows as $row) {
            $result[$row['language']] = $row['name'];
        }

        return $result;
    }

    /**
     * @throws Exception
     */
    public function getLanguageName(int $itemId, string $language): string
    {
        $select = new Sql\Select($this->itemLanguageTable->getTable());
        $select->columns(['name'])
            ->where([
                'item_id'  => $itemId,
                'language' => $language,
            ]);

        $row = currentFromResultSetInterface($this->itemLanguageTable->selectWith($select));

        return $row ? $row['name'] : '';
    }

    /**
     * @throws Exception
     */
    public function getName(int $itemId, string $language): string
    {
        $select = $this->getNameSelect($itemId, Sql\ExpressionInterface::TYPE_VALUE, $language);

        $row = currentFromResultSetInterface($this->itemLanguageTable->selectWith($select));

        return $row ? $row['name'] : '';
    }

    /**
     * @param array|ArrayAccess $row
     * @throws Exception
     */
    public function getNameData($row, string $language): array
    {
        $name = $this->getName($row['id'], $language);

        $spec     = null;
        $specFull = null;
        if ($row['spec_id']) {
            $specRow = currentFromResultSetInterface($this->specTable->select(['id' => (int) $row['spec_id']]));
            if ($specRow) {
                $spec     = $specRow['short_name'];
                $specFull = $specRow['name'];
            }
        }

        return [
            'begin_model_year'          => $row['begin_model_year'],
            'end_model_year'            => $row['end_model_year'],
            'begin_model_year_fraction' => $row['begin_model_year_fraction'],
            'end_model_year_fraction'   => $row['end_model_year_fraction'],
            'spec'                      => $spec,
            'spec_full'                 => $specFull,
            'body'                      => $row['body'],
            'name'                      => $name,
            'begin_year'                => $row['begin_year'],
            'end_year'                  => $row['end_year'],
            'today'                     => $row['today'],
            'begin_month'               => $row['begin_month'],
            'end_month'                 => $row['end_month'],
        ];
    }

    private function getAncestorsId(int $itemId, array $itemTypes): array
    {
        $select = new Sql\Select($this->itemParentCacheTable->getTable());

        $select->columns(['parent_id'])
            ->join('item', 'item_parent_cache.parent_id = item.id', [])
            ->where([
                new Sql\Predicate\In('item.item_type_id', $itemTypes),
                'item_parent_cache.item_id' => $itemId,
                'item_parent_cache.item_id != item_parent_cache.parent_id',
            ])
            ->order('item_parent_cache.diff desc');
        $parentIds = [];
        foreach ($this->itemParentCacheTable->selectWith($select) as $row) {
            $parentIds[] = (int) $row['parent_id'];
        }

        return $parentIds;
    }

    private function getChildItemsId(int $itemId): array
    {
        $select = new Sql\Select($this->itemParentTable->getTable());
        $select->columns(['item_id'])
            ->where(['item_parent.parent_id' => $itemId]);

        $result = [];
        foreach ($this->itemParentTable->selectWith($select) as $row) {
            $result[] = (int) $row['item_id'];
        }

        return $result;
    }

    public function getRelatedCarGroups(int $itemId): array
    {
        $carIds = $this->getChildItemsId($itemId);

        $vectors = [];
        foreach ($carIds as $carId) {
            $parentIds = $this->getAncestorsId($carId, [
                self::VEHICLE,
                self::ENGINE,
            ]);

            // remove parents
            foreach ($parentIds as $parentId) {
                $index = array_search($parentId, $carIds);
                if ($index !== false) {
                    unset($carIds[$index]);
                }
            }

            $vector   = $parentIds;
            $vector[] = $carId;

            $vectors[] = [
                'parents' => $vector,
                'childs'  => [$carId],
            ];
        }

        do {
            // look for same root

            $matched = false;
            for ($i = 0; ($i < count($vectors) - 1) && ! $matched; $i++) {
                for ($j = $i + 1; $j < count($vectors) && ! $matched; $j++) {
                    if ($vectors[$i]['parents'][0] === $vectors[$j]['parents'][0]) {
                        $matched = true;
                        // matched root
                        $newVector = [];
                        $length    = min(count($vectors[$i]['parents']), count($vectors[$j]['parents']));
                        for (
                            $k = 0;
                            $k < $length && $vectors[$i]['parents'][$k] === $vectors[$j]['parents'][$k];
                            $k++
                        ) {
                            $newVector[] = $vectors[$i]['parents'][$k];
                        }
                        $vectors[$i] = [
                            'parents' => $newVector,
                            'childs'  => array_merge($vectors[$i]['childs'], $vectors[$j]['childs']),
                        ];
                        array_splice($vectors, $j, 1);
                    }
                }
            }
        } while ($matched);

        $result = [];
        foreach ($vectors as $vector) {
            $carId          = $vector['parents'][count($vector['parents']) - 1];
            $result[$carId] = $vector['childs'];
        }

        return $result;
    }

    private function fractionToMonth(?string $fraction): int
    {
        switch ($fraction) {
            case '¼':
                return 13;
            case '½':
                return 16;
            case '¾':
                return 19;
        }

        return 10;
    }

    public function updateOrderCache(int $itemId): bool
    {
        $primaryKey = ['id' => $itemId];

        $row = currentFromResultSetInterface($this->itemTable->select($primaryKey));
        if (! $row) {
            return false;
        }

        $begin = null;
        if ($row['begin_year']) {
            $begin = new DateTime();
            $begin->setDate(
                $row['begin_year'],
                $row['begin_month'] ? $row['begin_month'] : 1,
                1
            );
        } elseif ($row['begin_model_year']) {
            $begin = new DateTime();
            $begin->setDate( // approximation
                $row['begin_model_year'] - 1,
                $this->fractionToMonth($row['begin_model_year_fraction']),
                1
            );
        } else {
            $begin = new DateTime();
            $begin->setDate(
                2100,
                1,
                1
            );
        }

        $end = null;
        if ($row['end_year']) {
            $end = new DateTime();
            $end->setDate(
                $row['end_year'],
                $row['end_month'] ? $row['end_month'] : 12,
                1
            );
        } elseif ($row['end_model_year']) {
            $end = new DateTime();
            $end->setDate( // approximation
                $row['end_model_year'],
                $this->fractionToMonth($row['end_model_year_fraction']) - 1,
                30
            );
        } else {
            $end = $begin;
        }

        $this->itemTable->update([
            'begin_order_cache' => $begin->format(Module::MYSQL_DATETIME_FORMAT),
            'end_order_cache'   => $end->format(Module::MYSQL_DATETIME_FORMAT),
        ], $primaryKey);

        return true;
    }

    private function getChildVehicleTypesByWhitelist(int $parentId, array $whitelist): array
    {
        if (count($whitelist) <= 0) {
            return [];
        }

        $select = new Sql\Select($this->vehicleTypeParentTable->getTable());
        $select->columns(['id'])
            ->where([
                new Sql\Predicate\In('id', $whitelist),
                'parent_id' => $parentId,
                'id <> parent_id',
            ]);

        $result = [];
        foreach ($this->vehicleTypeParentTable->selectWith($select) as $row) {
            $result[] = (int) $row['id'];
        }

        return $result;
    }

    /**
     * @throws Exception
     */
    public function updateInteritance(int $itemId): void
    {
        $item = currentFromResultSetInterface($this->itemTable->select(['id' => $itemId]));
        if (! $item) {
            throw new Exception("Item `$itemId` not found");
        }

        $this->updateItemInteritance($item);
    }

    /**
     * @param array|ArrayAccess $car
     * @throws Exception
     */
    private function updateItemInteritance($car): void
    {
        $parents = $this->getRows([
            'child' => $car['id'],
        ]);

        $somethingChanged = false;

        $set = [];

        if ($car['is_concept_inherit']) {
            $isConcept = false;
            foreach ($parents as $parent) {
                if ($parent['is_concept']) {
                    $isConcept = true;
                }
            }

            $oldIsConcept = (bool) $car['is_concept'];

            if ($oldIsConcept !== $isConcept) {
                $set['is_concept'] = $isConcept ? 1 : 0;
                $somethingChanged  = true;
            }
        }

        if ($car['engine_inherit']) {
            $map = [];
            foreach ($parents as $parent) {
                $engineId = $parent['engine_item_id'];
                if ($engineId) {
                    if (isset($map[$engineId])) {
                        $map[$engineId]++;
                    } else {
                        $map[$engineId] = 1;
                    }
                }
            }

            // select top
            $maxCount   = null;
            $selectedId = null;
            foreach ($map as $id => $count) {
                if ($maxCount === null || ($count > $maxCount)) {
                    $maxCount   = $count;
                    $selectedId = (int) $id;
                }
            }

            $oldEngineId = isset($car['engine_item_id']) ? (int) $car['engine_item_id'] : null;

            if ($oldEngineId !== $selectedId) {
                $set['engine_item_id'] = $selectedId;
                $somethingChanged      = true;
            }
        }

        if ($car['car_type_inherit']) {
            $map = [];
            foreach ($parents as $parent) {
                $typeId = $parent['car_type_id'];
                if ($typeId) {
                    if (isset($map[$typeId])) {
                        $map[$typeId]++;
                    } else {
                        $map[$typeId] = 1;
                    }
                }
            }

            foreach ($map as $id => $count) {
                $otherIds = array_diff(array_keys($map), [$id]);

                $isParentOf = $this->getChildVehicleTypesByWhitelist($id, $otherIds);

                if (count($isParentOf)) {
                    foreach ($isParentOf as $childId) {
                        $map[$childId] += $count;
                    }

                    unset($map[$id]);
                }
            }

            // select top
            $maxCount   = null;
            $selectedId = null;
            foreach ($map as $id => $count) {
                if ($maxCount === null || ($count > $maxCount)) {
                    $maxCount   = $count;
                    $selectedId = (int) $id;
                }
            }

            $oldCarTypeId = isset($car['car_type_id']) ? (int) $car['car_type_id'] : null;

            if ($oldCarTypeId !== $selectedId) {
                $set['car_type_id'] = $selectedId;
                $somethingChanged   = true;
            }
        }

        if ($car['spec_inherit']) {
            $map = [];
            foreach ($parents as $parent) {
                $specId = $parent['spec_id'];
                if ($specId) {
                    if (isset($map[$specId])) {
                        $map[$specId]++;
                    } else {
                        $map[$specId] = 1;
                    }
                }
            }

            // select top
            $maxCount   = null;
            $selectedId = null;
            foreach ($map as $id => $count) {
                if ($maxCount === null || ($count > $maxCount)) {
                    $maxCount   = $count;
                    $selectedId = (int) $id;
                }
            }

            $oldSpecId = isset($car['spec_id']) ? (int) $car['spec_id'] : null;

            if ($oldSpecId !== $selectedId) {
                $set['spec_id']   = $selectedId;
                $somethingChanged = true;
            }
        }

        if ($somethingChanged || ! $car['car_type_inherit']) {
            if ($set) {
                $this->itemTable->update($set, [
                    'id' => $car['id'],
                ]);
            }

            $childItems = $this->getRows([
                'parent' => $car['id'],
            ]);

            foreach ($childItems as $child) {
                $this->updateItemInteritance($child);
            }
        }
    }

    /**
     * @throws Exception
     */
    public function setPoint(int $itemId, ?Coordinate $point): void
    {
        $primaryKey = ['item_id' => $itemId];

        if (! $point) {
            $this->itemPointTable->delete($primaryKey);
            return;
        }

        $set = [
            'point' => new Sql\Expression('Point(?, ?)', [$point->getLng(), $point->getLat()]),
        ];

        $row = currentFromResultSetInterface($this->itemPointTable->select($primaryKey));
        if ($row) {
            $this->itemPointTable->update($set, $primaryKey);
            return;
        }
        $this->itemPointTable->insert(array_replace($set, $primaryKey));
    }

    /**
     * @return Coordinate|null
     * @throws Exception
     */
    public function getPoint(int $itemId)
    {
        $point = null;
        $row   = currentFromResultSetInterface($this->itemPointTable->select(['item_id' => $itemId]));
        if ($row && $row['point']) {
            $point = parsePointWkb($row['point']);
        }

        return $point;
    }

    public function getTable(): TableGateway
    {
        return $this->itemTable;
    }

    /**
     * @throws Exception
     */
    private function applyColumns(array $columns, string $itemParentAlias, string $language): array
    {
        $result = [];

        foreach ($columns as $key => $column) {
            switch ($column) {
                case 'parent_id':
                    if (is_numeric($key)) {
                        $result[] = $column;
                    } else {
                        $result[$key] = $column;
                    }
                    break;
                case 'link_catname':
                    if (is_numeric($key)) {
                        $result[] = 'catname';
                    } else {
                        $result[$key] = 'catname';
                    }
                    break;
                case 'link_name':
                    if (! $language) {
                        throw new Exception("Language is required for `name` select");
                    }
                    $nameSelect = $this->getItemParentNameSelect(
                        $itemParentAlias,
                        $language
                    );
                    if (is_numeric($key)) {
                        $result['link_name'] = $nameSelect;
                    } else {
                        $result[$key] = $nameSelect;
                    }
                    break;
                case 'link_type':
                    if (is_numeric($key)) {
                        $result['link_type'] = 'type';
                    } else {
                        $result[$key] = 'type';
                    }
                    break;
            }
        }

        return $result;
    }

    /**
     * @param array|int $options
     * @throws Exception
     */
    private function applyChildFilters(
        Sql\Select $select,
        $options,
        string $prefix,
        ?string $language,
        string $id
    ): array {
        if (! is_array($options)) {
            $options = [
                'id' => $options,
            ];
        }

        $alias = $prefix . 'ip2';

        $columns = [];

        if (isset($options['columns']) && $options['columns']) {
            $columns = $this->applyColumns($options['columns'], $alias, $language);
        }

        $select->join([$alias => 'item_parent'], $id . ' = ' . $alias . '.parent_id', $columns);

        if (isset($options['link_catname']) && $options['link_catname']) {
            $select->where([$alias . '.catname' => $options['link_catname']]);
        }

        if (isset($options['link_type'])) {
            $this->applyLinkTypeFilter($select, $alias, $options['link_type']);
        }

        return $this->applyFilters($select, array_replace(
            ['language' => $language],
            $options
        ), $alias . '.item_id', $alias);
    }

    /**
     * @param array|string $options
     * @throws Exception
     */
    private function applyParentFilters(
        Sql\Select $select,
        $options,
        string $prefix,
        ?string $language,
        string $id
    ): array {
        if (! is_array($options)) {
            $options = [
                'id' => $options,
            ];
        }

        $alias = $prefix . 'ip1';

        $columns = [];

        if (isset($options['columns']) && $options['columns']) {
            $columns = $this->applyColumns($options['columns'], $alias, $language);
        }

        $select->join([$alias => 'item_parent'], $id . ' = ' . $alias . '.item_id', $columns);

        if (isset($options['linked_in_days']) && $options['linked_in_days']) {
            $select->where([
                new Sql\Predicate\Expression(
                    $alias . '.timestamp > DATE_SUB(NOW(), INTERVAL ? DAY)',
                    [$options['linked_in_days']]
                ),
            ]);
        }

        if (isset($options['link_catname']) && $options['link_catname']) {
            $select->where([$alias . '.catname' => $options['link_catname']]);
        }

        if (isset($options['link_type'])) {
            $this->applyLinkTypeFilter($select, $alias, $options['link_type']);
        }

        return $this->applyFilters($select, array_replace(
            ['language' => $language],
            $options
        ), $alias . '.parent_id', $alias);

        /*

        if ($group) {
            foreach ($columns as $column) {
                $group[] = $column;
            }
        }

        return $group;*/
    }

    /**
     * @param array|int $options
     * @throws Exception
     */
    private function applyDescendantFilters(
        Sql\Select $select,
        $options,
        string $prefix,
        ?string $language,
        string $id
    ): array {
        if (! is_array($options)) {
            $options = [
                'id' => $options,
            ];
        }

        $alias = $prefix . 'ipc1';

        $group   = [];
        $columns = [];
        if (isset($options['columns'])) {
            foreach ((array) $options['columns'] as $key => $column) {
                switch ($column) {
                    case 'id':
                        if (is_numeric($key)) {
                            $columns[] = 'item_id';
                            $group[]   = 'item_id';
                        } else {
                            $columns[$key] = 'item_id';
                            $group[]       = $key;
                        }
                        break;
                    case 'diff':
                        if (is_numeric($key)) {
                            $columns[] = 'diff';
                            $group[]   = 'diff';
                        } else {
                            $columns[$key] = 'diff';
                            $group[]       = $key;
                        }
                        break;
                    default:
                        throw new Exception("Unexpected column `$column`");
                }
            }
        }

        $select->join([$alias => 'item_parent_cache'], $id . ' = ' . $alias . '.parent_id', $columns)
            ->where([$alias . '.item_id != ' . $alias . '.parent_id']);

        if (isset($options['link_type'])) {
            switch ($options['link_type']) {
                case ItemParent::TYPE_DEFAULT:
                    $select->where([
                        'not ' . $alias . '.sport',
                        'not ' . $alias . '.tuning',
                        'not ' . $alias . '.design',
                    ]);
                    break;
                case ItemParent::TYPE_SPORT:
                    $select->where([$alias . '.sport']);
                    break;
                case ItemParent::TYPE_TUNING:
                    $select->where([$alias . '.tuning']);
                    break;
                case ItemParent::TYPE_DESIGN:
                    $select->where([$alias . '.design']);
                    break;
                default:
                    throw new Exception("Unexpected link_type");
            }
        }

        $subGroup = $this->applyFilters($select, array_replace(
            ['language' => $language],
            $options
        ), $alias . '.item_id', $alias);
        $group    = array_merge($group, $subGroup);

        return $group;
    }

    /**
     * @param array|int $value
     * @throws Exception
     */
    private function applyLinkTypeFilter(Sql\Select $select, string $alias, $value): void
    {
        $column = $alias . '.type';
        if (is_array($value)) {
            if (count($value) <= 0) {
                throw new Exception("Empty link_type value");
            }
            $select->where([new Sql\Predicate\In($column, $value)]);
        } else {
            $select->where([$column => $value]);
        }
    }

    /**
     * @param array|int $value
     * @throws Exception
     */
    private function applyIdFilter(Sql\Select $select, $value, string $id): void
    {
        if (is_array($value)) {
            $value = array_values($value);

            if (count($value) === 1) {
                $this->applyIdFilter($select, $value[0], $id);
                return;
            }

            if (count($value) < 1) {
                $this->applyIdFilter($select, 0, $id);
                return;
            }

            $select->where([new Sql\Predicate\In($id, $value)]);
            return;
        }

        if (! is_scalar($value)) {
            throw new Exception('`id` must be scalar or array of scalar');
        }

        $select->where([$id => $value]);
    }

    /**
     * @throws Exception
     */
    private function applyFilters(Sql\Select $select, array $options, string $id, string $prefix): array
    {
        $defaults = [
            'id'                 => null,
            'item_type_id'       => null,
            'descendant'         => null,
            'descendant_or_self' => null,
            'ancestor'           => null,
            'ancestor_or_self'   => null,
            'parent'             => null,
            'child'              => null,
            'has_specs_of_user'  => null,
            'linked_in_days'     => null,
            'catname'            => null,
            'language'           => null,
        ];
        $options  = array_replace($defaults, $options);

        $language = $options['language'] ?? null;

        $group = [];

        if ($options['id'] !== null) {
            $this->applyIdFilter($select, $options['id'], $id);
        }

        if ($options['item_type_id']) {
            $alias = $prefix . 'i1';
            $select->join([$alias => 'item'], $id . ' = ' . $alias . '.id', []);
            if (is_array($options['item_type_id'])) {
                $select->where([new Sql\Predicate\In($alias . '.item_type_id', $options['item_type_id'])]);
            } else {
                $select->where([$alias . '.item_type_id' => $options['item_type_id']]);
            }
        }

        if ($options['descendant']) {
            $group[] = 'item.id';

            $this->applyDescendantFilters($select, $options['descendant'], $prefix, $language, $id);
        }

        if ($options['descendant_or_self']) {
            $group[] = 'item.id';
            $alias   = $prefix . 'ipc2';

            $columns = [];
            if (is_array($options['descendant_or_self'])) {
                if (isset($options['descendant_or_self']['columns'])) {
                    foreach ((array) $options['descendant_or_self']['columns'] as $key => $column) {
                        switch ($column) {
                            case 'id':
                                if (is_numeric($key)) {
                                    $columns[] = 'item_id';
                                    $group[]   = 'item_id';
                                } else {
                                    $columns[$key] = 'item_id';
                                    $group[]       = $key;
                                }
                                break;
                            case 'diff':
                                if (is_numeric($key)) {
                                    $columns[] = 'diff';
                                    $group[]   = 'diff';
                                } else {
                                    $columns[$key] = 'diff';
                                    $group[]       = $key;
                                }
                                break;
                            default:
                                throw new Exception("Unexpected column `$column`");
                        }
                    }
                }
            }

            $select->join([$alias => 'item_parent_cache'], $id . ' = ' . $alias . '.parent_id', $columns);

            if (is_array($options['descendant_or_self'])) {
                $subGroup = $this->applyFilters($select, array_replace(
                    ['language' => $language],
                    $options['descendant_or_self']
                ), $alias . '.item_id', $alias);
                $group    = array_merge($group, $subGroup);
            } else {
                $select->where([$alias . '.item_id' => $options['descendant_or_self']]);
            }
        }

        if ($options['ancestor']) {
            $group[] = 'item.id';
            $alias   = $prefix . 'ipc3';
            $select->join([$alias => 'item_parent_cache'], $id . ' = ' . $alias . '.item_id', [])
                ->where([$alias . '.item_id != ' . $alias . '.parent_id']);

            if (is_array($options['ancestor'])) {
                $subGroup = $this->applyFilters($select, array_replace(
                    ['language' => $language],
                    $options['ancestor']
                ), $alias . '.parent_id', $alias);
                $group    = array_merge($group, $subGroup);
            } else {
                $select->where([$alias . '.parent_id' => $options['ancestor']]);
            }
        }

        if ($options['ancestor_or_self']) {
            $group[] = 'item.id';
            $alias   = $prefix . 'ipc4';
            $select->join([$alias => 'item_parent_cache'], $id . ' = ' . $alias . '.item_id', []);

            if (is_array($options['ancestor_or_self'])) {
                if (isset($options['ancestor_or_self']['max_diff']) && $options['ancestor_or_self']['max_diff']) {
                    $select->where([$alias . '.diff <= ?' => $options['ancestor_or_self']['max_diff']]);
                }

                if (isset($options['ancestor_or_self']['stock_only']) && $options['ancestor_or_self']['stock_only']) {
                    $select->where('not ' . $alias . '.tuning')
                           ->where('not ' . $alias . '.sport');
                }

                $subGroup = $this->applyFilters($select, array_replace(
                    ['language' => $language],
                    $options['ancestor_or_self']
                ), $alias . '.parent_id', $alias);
                $group    = array_merge($group, $subGroup);
            } else {
                $select->where([$alias . '.parent_id' => $options['ancestor_or_self']]);
            }
        }

        if ($options['parent']) {
            $subGroup = $this->applyParentFilters($select, $options['parent'], $prefix, $language, $id);
            $group    = array_merge($group, $subGroup);
        }

        if ($options['child']) {
            $subGroup = $this->applyChildFilters($select, $options['child'], $prefix, $language, $id);
            $group    = array_merge($group, $subGroup);
        }

        if ($options['has_specs_of_user']) {
            $group[] = 'item.id';
            $select->join('attrs_user_values', $id . ' = attrs_user_values.item_id', [])
                ->where(['attrs_user_values.user_id' => $options['has_specs_of_user']]);
        }

        if (isset($options['pictures']) && $options['pictures']) {
            $group[] = 'item.id';

            $this->applyPicturesFilter($select, $id, $options['pictures']);
        }

        return $group;
    }

    private function applyPicturesFilter(Sql\Select $select, string $id, array $options): void
    {
        $defaults = [
            'user'   => null,
            'status' => null,
            'id'     => null,
            'type'   => null,
        ];
        $options  = array_replace($defaults, $options);

        $select->join(['pi1' => 'picture_item'], $id . ' = pi1.item_id', [])
            ->join(['p1' => 'pictures'], 'pi1.picture_id = p1.id', []);

        if ($options['user']) {
            $select->where(['p1.owner_id' => $options['user']]);
        }

        if ($options['status']) {
            $select->where(['p1.status' => $options['status']]);
        }

        if ($options['type']) {
            $select->where(['pi1.type' => $options['type']]);
        }

        if ($options['id']) {
            $select->where(['p1.id' => $options['id']]);
        }
    }

    private function getNameSelect(int $value, string $valueType, string $language): Sql\Select
    {
        $predicate = new Sql\Predicate\Operator(
            'item_id',
            Sql\Predicate\Operator::OP_EQ,
            $value,
            Sql\ExpressionInterface::TYPE_IDENTIFIER,
            $valueType
        );

        $languages = $this->languagePriority->getList($language);

        $select = new Sql\Select($this->itemLanguageTable->getTable());
        $select->columns(['name'])
            ->where([
                $predicate,
                new Sql\Predicate\Expression('length(item_language.name) > 0'),
            ])
            ->order([
                new Sql\Expression(
                    'FIELD(item_language.language' . str_repeat(', ?', count($languages)) . ')',
                    $languages
                ),
            ])
            ->limit(1);

        return $select;
    }

    private function getItemParentNameSelect(string $itemParentAlias, string $language): Sql\Select
    {
        $predicate1 = new Sql\Predicate\Operator(
            'item_id',
            Sql\Predicate\Operator::OP_EQ,
            $itemParentAlias . '.item_id',
            Sql\ExpressionInterface::TYPE_IDENTIFIER,
            Sql\ExpressionInterface::TYPE_IDENTIFIER
        );
        $predicate2 = new Sql\Predicate\Operator(
            'parent_id',
            Sql\Predicate\Operator::OP_EQ,
            $itemParentAlias . '.parent_id',
            Sql\ExpressionInterface::TYPE_IDENTIFIER,
            Sql\ExpressionInterface::TYPE_IDENTIFIER
        );

        $languages = $this->languagePriority->getList($language);

        $select = $this->itemParentLanguageTable->getSql()->select();
        $select->columns(['name'])
            ->where([
                $predicate1,
                $predicate2,
                new Sql\Predicate\Expression('length(item_parent_language.name) > 0'),
            ])
            ->order([
                new Sql\Expression(
                    'FIELD(item_parent_language.language' . str_repeat(', ?', count($languages)) . ')',
                    $languages
                ),
            ])
            ->limit(1);

        return $select;
    }

    /**
     * @throws Exception
     */
    public function getSelect(array $options): Sql\Select
    {
        $defaults = [
            'id'                   => null,
            'columns'              => null,
            'language'             => null,
            'item_type_id'         => null,
            'item_type_id_exclude' => null,
            'exclude_id'           => null,
            'limit'                => null,
            'order'                => null,
            'created_in_days'      => null,
            'engine_id'            => null,
            'dateless'             => null,
            'dateful'              => null,
            'is_group'             => null,
            'is_concept'           => null,
            'is_concept_inherit'   => null,
            'no_parents'           => null,
            'catname'              => null,
            'vehicle_type_id'      => null,
            'has_logo'             => null,
            'has_begin_year'       => null,
            'has_end_year'         => null,
            'has_begin_month'      => null,
            'has_end_month'        => null,
            'position'             => null,
        ];
        $options  = array_replace($defaults, $options);

        $select = new Sql\Select($this->itemTable->getTable());

        $language = $options['language'] ?? null;

        if ($options['columns']) {
            $columns = [];
            foreach ((array) $options['columns'] as $key => $column) {
                if ($column instanceof Sql\Expression) {
                    $columns[$key] = $column;
                    continue;
                }

                if ($column instanceof Sql\Select) {
                    $columns[$key] = $column;
                    continue;
                }

                switch ($column) {
                    case 'id':
                    case 'catname':
                    case 'is_group':
                    case 'is_concept':
                    case 'item_type_id':
                    case 'full_name':
                    case 'logo_id':
                    case 'position':
                    case 'produced':
                    case 'produced_exactly':
                        $columns[] = $column;
                        break;
                    case 'name':
                        if (! $language) {
                            throw new Exception("Language is required for `name` select");
                        }

                        /** @var Adapter $adapter */
                        $adapter   = $this->itemTable->getAdapter();
                        $subSelect = $this->languagePriority->getSelectItemName($language, $adapter);

                        $columns = array_merge($columns, [
                            'begin_year',
                            'end_year',
                            'today',
                            'begin_model_year',
                            'end_model_year',
                            'begin_model_year_fraction',
                            'end_model_year_fraction',
                            'body',
                            /*'name' => $this->getNameSelect(
                                'item.id',
                                Sql\ExpressionInterface::TYPE_IDENTIFIER,
                                $language
                            )*/
                            'name' => new Sql\Expression('(' . $subSelect . ')'),
                        ]);

                        $select->join('spec', 'item.spec_id = spec.id', [
                            'spec'      => 'short_name',
                            'spec_full' => 'name',
                        ], $select::JOIN_LEFT);

                        break;
                }
            }

            $select->columns($columns);
        }

        $recursiveOptions = $options;
        unset($recursiveOptions['item_type_id']);
        unset($recursiveOptions['item_type_id_exclude']);
        unset($recursiveOptions['catname']);
        unset($recursiveOptions['columns']);

        $group = $this->applyFilters($select, array_replace(
            ['language' => $language],
            $recursiveOptions
        ), 'item.id', '');

        if ($options['item_type_id']) {
            if (is_array($options['item_type_id'])) {
                $select->where([new Sql\Predicate\In('item.item_type_id', $options['item_type_id'])]);
            } else {
                $select->where(['item.item_type_id' => $options['item_type_id']]);
            }
        }

        if (isset($options['id'])) {
            $select->where(['item.id' => $options['id']]);
        }

        if (isset($options['position'])) {
            $select->where(['item.position' => $options['position']]);
        }

        if ($options['has_logo']) {
            $select->where('item.logo_id is not null');
        }

        if ($options['has_begin_year']) {
            $select->where('item.begin_year');
        }

        if ($options['has_end_year']) {
            $select->where('item.end_year');
        }

        if ($options['has_begin_month']) {
            $select->where('item.begin_month');
        }

        if ($options['has_end_month']) {
            $select->where('item.end_month');
        }

        if ($options['item_type_id_exclude']) {
            if (is_array($options['item_type_id_exclude'])) {
                $select->where([new Sql\Predicate\NotIn('item.item_type_id', $options['item_type_id_exclude'])]);
            } else {
                $select->where(['item.item_type_id != ?' => $options['item_type_id_exclude']]);
            }
        }

        if ($options['exclude_id']) {
            $select->where(['item.id != ?' => $options['exclude_id']]);
        }

        if ($options['limit']) {
            $select->limit($options['limit']);
        }

        if ($options['created_in_days']) {
            $select->where([
                new Sql\Predicate\Expression(
                    'item.add_datetime > DATE_SUB(NOW(), INTERVAL ? DAY)',
                    [$options['created_in_days']]
                ),
            ]);
        }

        if ($options['engine_id']) {
            $select->where(['item.engine_item_id' => $options['engine_id']]);
        }

        if ($options['dateless']) {
            $select->where([
                'item.begin_year is null',
                'item.begin_model_year is null',
            ]);
        }

        if ($options['dateful']) {
            $select->where([
                '(item.begin_year is not null or item.begin_model_year is not null)',
            ]);
        }

        if (isset($options['is_group'])) {
            if ($options['is_group']) {
                $select->where(['item.is_group']);
            } else {
                $select->where(['not item.is_group']);
            }
        }

        if (isset($options['is_concept'])) {
            if ($options['is_concept']) {
                $select->where(['item.is_concept']);
            } else {
                $select->where(['not item.is_concept']);
            }
        }

        if (isset($options['is_concept_inherit'])) {
            if ($options['is_concept_inherit']) {
                $select->where(['item.is_concept_inherit']);
            } else {
                $select->where(['not item.is_concept_inherit']);
            }
        }

        if ($options['no_parents']) {
            $select
                ->join(['ip3' => 'item_parent'], 'item.id = ip3.item_id', [], $select::JOIN_LEFT)
                ->where(['ip3.parent_id is null']);
        }

        if (isset($options['catname'])) {
            $select->where(['item.catname' => $options['catname']]);
        }

        if ($options['vehicle_type_id']) {
            $group[] = 'item.id';
            $select
                ->join('vehicle_vehicle_type', 'item.id = vehicle_vehicle_type.vehicle_id', [])
                ->where(['vehicle_vehicle_type.vehicle_type_id' => $options['vehicle_type_id']]);
        }

        if ($options['order']) {
            $select->order($options['order']);
        }

        $group = array_unique($group, SORT_STRING);

        if ($group) {
            $joins = $select->getRawState($select::JOINS);
            foreach ($joins as $join) {
                if ($join['type'] !== $select::JOIN_LEFT) {
                    foreach ($join['columns'] as $column) {
                        if (is_array($join['name'])) {
                            $column = key($join['name']) . '.' . $column;
                        } else {
                            $column = $join['name'] . '.' . $column;
                        }
                        $group[] = $column;
                    }
                }
            }

            $select->group($group);
        }

        return $select;
    }

    /**
     * @throws Exception
     */
    public function getPaginator(array $options): Paginator\Paginator
    {
        /** @var Adapter $adapter */
        $adapter = $this->itemTable->getAdapter();
        return new Paginator\Paginator(
            new Paginator\Adapter\LaminasDb\DbSelect($this->getSelect($options), $adapter)
        );
    }

    /**
     * @throws Exception
     */
    public function getCount(array $options): int
    {
        return $this->getPaginator($options)->getTotalItemCount();
    }

    /**
     * @return array|ArrayObject|null
     * @throws Exception
     */
    public function getRow(array $options)
    {
        $select = $this->getSelect($options);
        $select->limit(1);

        return currentFromResultSetInterface($this->itemTable->selectWith($select));
    }

    /**
     * @throws Exception
     */
    public function isExists(array $options): bool
    {
        $select = $this->getSelect($options);
        $select->reset($select::COLUMNS);
        $select->reset($select::ORDER);
        $select->reset($select::GROUP);
        $select->columns(['id']);
        $select->limit(1);

        return (bool) currentFromResultSetInterface($this->itemTable->selectWith($select));
    }

    /**
     * @throws Exception
     */
    public function getRows(array $options): array
    {
        $select = $this->getSelect($options);
        $result = [];
        foreach ($this->itemTable->selectWith($select) as $row) {
            $result[] = $row;
        }

        return $result;
    }

    /**
     * @throws Exception
     */
    public function getIds(array $options): array
    {
        $select = $this->getSelect($options);
        $select->reset($select::COLUMNS);
        $select->columns(['id']);

        $result = [];
        foreach ($this->itemTable->selectWith($select) as $row) {
            $result[] = (int) $row['id'];
        }

        return $result;
    }

    /**
     * @throws Exception
     */
    public function getDesignInfo(int $itemId, string $language): ?array
    {
        $brand = $this->getRow([
            'language'     => $language,
            'columns'      => ['catname', 'name'],
            'item_type_id' => self::BRAND,
            'child'        => [
                'id'        => $itemId,
                'link_type' => ItemParent::TYPE_DESIGN,
                'columns'   => [
                    'brand_item_catname' => 'link_catname',
                ],
            ],
        ]);

        if ($brand) {
            return [
                'name'  => $brand['name'], //TODO: formatter
                'route' => ['/', $brand['catname'], $brand['brand_item_catname']],
            ];
        }

        $brand = $this->getRow([
            'language'     => $language,
            'columns'      => ['catname', 'name'],
            'item_type_id' => self::BRAND,
            'child'        => [
                'columns'    => [
                    'brand_item_catname' => 'link_catname',
                ],
                'link_type'  => ItemParent::TYPE_DESIGN,
                'descendant' => [
                    'id'      => $itemId,
                    'columns' => ['diff'],
                ],
            ],
            'order'        => 'ip2ipc1.diff ASC',
        ]);

        if ($brand) {
            return [
                'name'  => $brand['name'], //TODO: formatter
                'route' => ['/', $brand['catname'], $brand['brand_item_catname']],
            ];
        }

        $brand = $this->getRow([
            'language'     => $language,
            'columns'      => ['catname', 'name'],
            'item_type_id' => self::BRAND,
            'child'        => [
                'columns'    => [
                    'brand_item_catname' => 'link_catname',
                ],
                'descendant' => [
                    'link_type' => ItemParent::TYPE_DESIGN,
                    'id'        => $itemId,
                    'columns'   => ['diff'],
                ],
            ],
            'order'        => 'ip2ipc1.diff ASC',
        ]);

        if ($brand) {
            return [
                'name'  => $brand['name'], //TODO: formatter
                'route' => ['/', $brand['catname'], $brand['brand_item_catname']],
            ];
        }

        return null;
    }
}