<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Tenant;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Ferienpass\CoreBundle\Entity\Tenant;
use Ferienpass\CoreBundle\Entity\TenantAwareInterface;

/**
 * Bulk operations across every tenant-scoped table, derived from the Doctrine
 * metadata rather than a hand-kept list — add {@see TenantAwareInterface} to an
 * entity and it is covered here too.
 *
 * These go through DBAL rather than DQL on purpose: adopting orphans has no
 * entities to load, and counting a tenant's rows must not be reshaped by the
 * very filter that would hide them.
 */
class TenantTables
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    /**
     * Hands every row that belongs to no tenant over to $tenant.
     *
     * This is the one-way door between the two worlds: an installation that has
     * been running single-tenant has `tenant_id IS NULL` everywhere, and those
     * rows turn invisible the moment the filter comes on. Adopting them makes
     * the existing municipality the first tenant.
     *
     * @return array<string, int> table name => rows updated
     */
    public function adoptOrphans(Tenant $tenant): array
    {
        $tenantId = $this->requireId($tenant);
        $connection = $this->entityManager->getConnection();
        $adopted = [];

        foreach ($this->map() as $table => $column) {
            $adopted[$table] = (int) $connection->executeStatement(
                \sprintf(
                    'UPDATE %s SET %s = :tenant WHERE %s IS NULL',
                    $connection->quoteSingleIdentifier($table),
                    $connection->quoteSingleIdentifier($column),
                    $connection->quoteSingleIdentifier($column),
                ),
                ['tenant' => $tenantId],
            );
        }

        return $adopted;
    }

    /**
     * How many rows each table holds for $tenant, tables with none omitted.
     * Used to keep a tenant that still owns data from being deleted.
     *
     * @return array<string, int> table name => row count
     */
    public function countRows(Tenant $tenant): array
    {
        $tenantId = $this->requireId($tenant);
        $connection = $this->entityManager->getConnection();
        $counts = [];

        foreach ($this->map() as $table => $column) {
            $count = (int) $connection->fetchOne(
                \sprintf(
                    'SELECT COUNT(*) FROM %s WHERE %s = :tenant',
                    $connection->quoteSingleIdentifier($table),
                    $connection->quoteSingleIdentifier($column),
                ),
                ['tenant' => $tenantId],
            );

            if ($count > 0) {
                $counts[$table] = $count;
            }
        }

        return $counts;
    }

    /**
     * @return array<string, string> table name => tenant column, deduplicated so
     *                               single-table inheritance children do not visit the shared table twice
     */
    public function map(): array
    {
        $tables = [];

        foreach ($this->tenantAwareMetadata() as $metadata) {
            $tables[$metadata->getTableName()] = $metadata->getSingleAssociationJoinColumnName('tenant');
        }

        ksort($tables);

        return $tables;
    }

    /**
     * Which entity a table in {@see map()} belongs to — the inheritance root
     * for shared single-table-inheritance tables. This is what lets the admin
     * name a tenant's leftover rows ("Teilnehmende", "Angebote") instead of
     * showing raw table names.
     *
     * @return array<string, class-string> table name => entity class
     */
    public function classByTable(): array
    {
        $classes = [];

        foreach ($this->tenantAwareMetadata() as $metadata) {
            $classes[$metadata->getTableName()] = $metadata->rootEntityName;
        }

        ksort($classes);

        return $classes;
    }

    /**
     * @return iterable<ClassMetadata>
     */
    private function tenantAwareMetadata(): iterable
    {
        foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadata) {
            \assert($metadata instanceof ClassMetadata);

            if ($metadata->isMappedSuperclass || !$metadata->getReflectionClass()->implementsInterface(TenantAwareInterface::class)) {
                continue;
            }

            if (!$metadata->hasAssociation('tenant')) {
                continue;
            }

            yield $metadata;
        }
    }

    private function requireId(Tenant $tenant): int
    {
        $id = $tenant->getId();
        if (null === $id) {
            throw new \LogicException('The tenant must be persisted first.');
        }

        return $id;
    }
}
