<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\DocStoreBundle\GenericDoc;

use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\Exception\AssociatedStoredObjectNotFound;
use Chill\DocStoreBundle\GenericDoc\Exception\NotNormalizableGenericDocException;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Types\Types;

final readonly class Manager implements ManagerInterface
{
    private FetchQueryToSqlBuilder $builder;

    public function __construct(
        /**
         * @var iterable<GenericDocForAccompanyingPeriodProviderInterface>
         */
        private iterable $providersForAccompanyingPeriod,

        /**
         * @var iterable<GenericDocForPersonProviderInterface>
         */
        private iterable $providersForPerson,

        /**
         * @var iterable<GenericDocNormalizerInterface>
         */
        private iterable $genericDocNormalizers,
        private Connection $connection,
    ) {
        $this->builder = new FetchQueryToSqlBuilder();
    }

    public function countDocForAccompanyingPeriod(
        AccompanyingPeriod $accompanyingPeriod,
        ?\DateTimeImmutable $startDate = null,
        ?\DateTimeImmutable $endDate = null,
        ?string $content = null,
        array $places = [],
    ): int {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places);

        return $this->countDoc($sql, $params, $types);
    }

    private function countDoc(string $sql, array $params, array $types): int
    {
        if ('' === $sql) {
            return 0;
        }

        $countSql = "SELECT count(*) AS c FROM ({$sql}) AS sq";
        $result = $this->connection->executeQuery($countSql, $params, $types);

        $number = $result->fetchOne();

        if (false === $number) {
            throw new \UnexpectedValueException('number of documents failed to load');
        }

        return $number;
    }

    public function countDocForPerson(
        Person $person,
        ?\DateTimeImmutable $startDate = null,
        ?\DateTimeImmutable $endDate = null,
        ?string $content = null,
        array $places = [],
    ): int {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places);

        return $this->countDoc($sql, $params, $types);
    }

    public function findDocForAccompanyingPeriod(
        AccompanyingPeriod $accompanyingPeriod,
        int $offset = 0,
        int $limit = 20,
        ?\DateTimeImmutable $startDate = null,
        ?\DateTimeImmutable $endDate = null,
        ?string $content = null,
        array $places = [],
    ): iterable {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places);

        return $this->findDocs($accompanyingPeriod, $sql, $params, $types, $offset, $limit);
    }

    /**
     * @throws \JsonException
     * @throws Exception
     */
    private function findDocs(AccompanyingPeriod|Person $linked, string $sql, array $params, array $types, int $offset, int $limit): iterable
    {
        if ('' === $sql) {
            return [];
        }

        $runSql = "{$sql} ORDER BY doc_date DESC LIMIT ? OFFSET ?";
        $runParams = [...$params, ...[$limit, $offset]];
        $runTypes = [...$types, ...[Types::INTEGER, Types::INTEGER]];

        foreach ($this->connection->iterateAssociative($runSql, $runParams, $runTypes) as $row) {
            yield new GenericDocDTO(
                $row['key'],
                json_decode((string) $row['identifiers'], true, 512, JSON_THROW_ON_ERROR),
                new \DateTimeImmutable($row['doc_date']),
                $linked,
            );
        }
    }

    /**
     * Fetch a generic doc, if it does exists.
     *
     * Currently implemented only on generic docs linked with accompanying period
     */
    public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
    {
        foreach ($this->providersForAccompanyingPeriod as $provider) {
            if ($provider->supportsKeyAndIdentifiers($key, $identifiers)) {
                return $provider->buildOneGenericDoc($key, $identifiers);
            }
        }

        return null;
    }

    /**
     * @throws AssociatedStoredObjectNotFound if no stored object can be found
     */
    public function fetchStoredObject(GenericDocDTO $genericDocDTO): StoredObject
    {
        foreach ($this->providersForAccompanyingPeriod as $provider) {
            if ($provider->supportsGenericDoc($genericDocDTO)) {
                return $provider->fetchAssociatedStoredObject($genericDocDTO);
            }
        }

        throw new AssociatedStoredObjectNotFound($genericDocDTO->key, $genericDocDTO->identifiers);
    }

    public function findDocForPerson(
        Person $person,
        int $offset = 0,
        int $limit = 20,
        ?\DateTimeImmutable $startDate = null,
        ?\DateTimeImmutable $endDate = null,
        ?string $content = null,
        array $places = [],
    ): iterable {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places);

        return $this->findDocs($person, $sql, $params, $types, $offset, $limit);
    }

    public function placesForPerson(Person $person): array
    {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person);

        return $this->places($sql, $params, $types);
    }

    public function placesForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): array
    {
        ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod);

        return $this->places($sql, $params, $types);
    }

    public function isGenericDocNormalizable(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
    {
        foreach ($this->genericDocNormalizers as $genericDocNormalizer) {
            if ($genericDocNormalizer->supportsNormalization($genericDocDTO, $format, $context)) {
                return true;
            }
        }

        return false;
    }

    public function normalizeGenericDoc(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
    {
        foreach ($this->genericDocNormalizers as $genericDocNormalizer) {
            if ($genericDocNormalizer->supportsNormalization($genericDocDTO, $format, $context)) {
                return $genericDocNormalizer->normalize($genericDocDTO, $format, $context);
            }
        }

        throw new NotNormalizableGenericDocException();
    }

    private function places(string $sql, array $params, array $types): array
    {
        if ('' === $sql) {
            return [];
        }

        $runSql = "SELECT DISTINCT key FROM ({$sql}) AS sq ORDER BY key";

        $keys = [];

        foreach ($this->connection->iterateAssociative($runSql, $params, $types) as $k) {
            $keys[] = $k['key'];
        }

        return $keys;
    }

    /**
     * @param list<string> $places places to search. When empty, search in all places
     */
    private function buildUnionQuery(
        AccompanyingPeriod|Person $linked,
        ?\DateTimeImmutable $startDate = null,
        ?\DateTimeImmutable $endDate = null,
        ?string $content = null,
        array $places = [],
    ): array {
        $queries = [];

        if ($linked instanceof AccompanyingPeriod) {
            foreach ($this->providersForAccompanyingPeriod as $provider) {
                if (!$provider->isAllowedForAccompanyingPeriod($linked)) {
                    continue;
                }
                $queries[] = $provider->buildFetchQueryForAccompanyingPeriod($linked, $startDate, $endDate, $content);
            }
        } else {
            foreach ($this->providersForPerson as $provider) {
                if (!$provider->isAllowedForPerson($linked)) {
                    continue;
                }
                $queries[] = $provider->buildFetchQueryForPerson($linked, $startDate, $endDate, $content);
            }
        }
        $sql = [];
        $params = [];
        $types = [];

        foreach ($queries as $query) {
            if ([] !== $places and !in_array($query->getSelectKeyString(), $places, true)) {
                continue;
            }

            ['sql' => $q, 'params' => $p, 'types' => $t] = $this->builder->toSql($query);

            $sql[] = $q;
            $params = [...$params, ...$p];
            $types = [...$types, ...$t];
        }

        return ['sql' => implode(' UNION ', $sql), 'params' => $params, 'types' => $types];
    }
}
