<?php

declare(strict_types=1);

namespace Gls\GlsPoland\PrestaShop\ObjectModel\Repository;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Gls\GlsPoland\Doctrine\DBAL\Query\TablePrefixingQueryBuilder;
use Gls\GlsPoland\PrestaShop\ObjectModel\Hydrator;
use Gls\GlsPoland\PrestaShop\ObjectModel\HydratorInterface;

/**
 * @template T of \ObjectModel
 */
abstract class AbstractObjectModelRepository
{
    protected $modelClass;
    protected $definition;
    protected $connection;
    protected $dbPrefix;
    protected $hydrator;

    /**
     * @var array<int, T>
     */
    protected $objectsById = [];

    /**
     * @param class-string<T> $modelClass
     */
    public function __construct(string $modelClass, Connection $connection, string $dbPrefix, ?HydratorInterface $hydrator = null)
    {
        if (!is_subclass_of($modelClass, \ObjectModel::class)) {
            throw new \InvalidArgumentException(sprintf('%s is not a %s.', $modelClass, \ObjectModel::class));
        }

        $this->modelClass = $modelClass;
        $this->definition = $modelClass::getDefinition($modelClass);
        $this->connection = $connection;
        $this->dbPrefix = $dbPrefix;
        $this->hydrator = $hydrator ?? new Hydrator();
    }

    /**
     * @return class-string<T>
     */
    public function getClassName(): string
    {
        return $this->modelClass;
    }

    /**
     * @param int $id
     *
     * @return T|null
     */
    public function find($id): ?\ObjectModel
    {
        if (0 >= $id = (int) $id) {
            return null;
        }

        if (array_key_exists($id, $this->objectsById)) {
            return $this->objectsById[$id];
        }

        $statement = $this
            ->createFindQueryBuilder('a')
            ->andWhere(sprintf('a.%s = :id', $this->definition['primary']))
            ->setParameter('id', $id)
            ->execute();

        $data = $this->fetchAllAssociative($statement);

        return $this->objectsById[$id] = $this->hydrate($data);
    }

    /**
     * @return T[]
     */
    public function findAll(): array
    {
        return $this->findBy([], [$this->definition['primary'] => 'ASC']);
    }

    /**
     * @return T|null
     */
    public function findOneBy(array $criteria, ?array $orderBy = null)
    {
        $collection = $this->findBy($criteria, $orderBy, 1);

        return current($collection);
    }

    /**
     * @return T[]
     */
    public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
    {
        $data = $this->getRawData($criteria, $orderBy, $limit, $offset);

        return $this->hydrateCollection($data);
    }

    protected function createQueryBuilder(string $alias): QueryBuilder
    {
        return (new TablePrefixingQueryBuilder($this->connection, $this->dbPrefix))
            ->select($alias . '.*')
            ->from($this->definition['table'], $alias);
    }

    /**
     * Creates a common query builder used by find and findBy.
     */
    protected function createFindQueryBuilder(string $alias): QueryBuilder
    {
        return $this->createQueryBuilder($alias);
    }

    protected function getRawData(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
    {
        $qb = $this
            ->createFindQueryBuilder('a')
            ->setMaxResults($limit)
            ->setFirstResult($offset ?? 0);

        $this->applySearchCriteria($qb, $criteria);
        if (null !== $orderBy) {
            $this->applyOrderBy($qb, $orderBy);
        }

        $statement = $qb->execute();

        return $this->fetchAllAssociative($statement);
    }

    /**
     * @return T|null
     */
    protected function hydrate(array $data): ?\ObjectModel
    {
        if ([] === $data) {
            return null;
        }

        return $this->hydrator->hydrate($data, $this->modelClass);
    }

    /**
     * @return T[]
     */
    protected function hydrateCollection(array $data): array
    {
        return $this->hydrator->hydrateCollection($data, $this->modelClass);
    }

    protected function generateAlias(string $field, string $alias): string
    {
        return $alias;
    }

    private function applySearchCriteria(QueryBuilder $qb, array $criteria): void
    {
        $i = 0;

        foreach ($criteria as $field => $value) {
            $type = $this->getFieldType($field);
            $alias = $this->generateAlias($field, 'a');
            $placeholder = sprintf('param_%d', $i++);

            if (is_array($value)) {
                $qb
                    ->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $placeholder))
                    ->setParameter($placeholder, $value, \ObjectModel::TYPE_INT === $type ? Connection::PARAM_INT_ARRAY : Connection::PARAM_STR_ARRAY);
            } else {
                $qb
                    ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $placeholder))
                    ->setParameter($placeholder, $value);
            }
        }
    }

    private function applyOrderBy(QueryBuilder $qb, array $orderBy): void
    {
        foreach ($orderBy as $field => $order) {
            $order = \Tools::strtoupper($order);
            if ('ASC' !== $order && 'DESC' !== $order) {
                throw new \InvalidArgumentException(sprintf('"%s" is not a valid order.', $order));
            }

            // check that field exists in model
            $this->getFieldType($field);

            $alias = $this->generateAlias($field, 'a');
            $qb->addOrderBy(sprintf('%s.%s', $alias, $field), $order);
        }
    }

    /**
     * @param int|string $field
     */
    protected function getFieldType($field): int
    {
        if ($field === $this->definition['primary']) {
            return \ObjectModel::TYPE_INT;
        }

        if (!isset($this->definition['fields'][$field])) {
            throw new \InvalidArgumentException(sprintf('Field "%s" does not exist in %s.', $field, $this->modelClass));
        }

        return $this->definition['fields'][$field]['type'];
    }

    /**
     * @internal
     */
    protected function fetchAllAssociative($statement): array
    {
        if (is_callable([$statement, 'fetchAllAssociative'])) {
            return $statement->fetchAllAssociative();
        }

        if (is_callable([$statement, 'fetchAll'])) {
            return $statement->fetchAll(\PDO::FETCH_ASSOC);
        }

        if (is_callable([$statement, 'fetchAssociative'])) {
            $rows = [];
            while (false !== ($row = $statement->fetchAssociative())) {
                $rows[] = $row;
            }

            return $rows;
        }

        throw new \RuntimeException(sprintf('Unsupported DBAL result type: %s', is_object($statement) ? get_class($statement) : gettype($statement)));
    }

    /**
     * @internal
     */
    protected function fetchOne($statement)
    {
        if (is_callable([$statement, 'fetchOne'])) {
            return $statement->fetchOne();
        }

        if (is_callable([$statement, 'fetch'])) {
            $row = $statement->fetch(\PDO::FETCH_NUM);
        } elseif (is_callable([$statement, 'fetchNumeric'])) {
            $row = $statement->fetchNumeric();
        } else {
            throw new \RuntimeException(sprintf('Unsupported DBAL result type: %s', is_object($statement) ? get_class($statement) : gettype($statement)));
        }

        if (false === $row || null === $row) {
            return false;
        }

        return $row[0];
    }
}
