<?php

declare(strict_types=1);

namespace Doctrine\Migrations\Version;

use Doctrine\Migrations\Exception\MigrationClassNotFound;
use Doctrine\Migrations\Metadata;
use Doctrine\Migrations\Metadata\AvailableMigration;
use Doctrine\Migrations\Metadata\AvailableMigrationsList;
use Doctrine\Migrations\Metadata\ExecutedMigrationsList;
use Doctrine\Migrations\Metadata\MigrationPlan;
use Doctrine\Migrations\Metadata\MigrationPlanList;
use Doctrine\Migrations\Metadata\Storage\MetadataStorage;
use Doctrine\Migrations\MigrationsRepository;

use function array_diff;
use function array_filter;
use function array_map;
use function array_reverse;
use function count;
use function in_array;
use function reset;
use function uasort;

/**
 * The MigrationPlanCalculator is responsible for calculating the plan for migrating from the current
 * version to another version.
 *
 * @internal
 */
final class SortedMigrationPlanCalculator implements MigrationPlanCalculator
{
    public function __construct(
        private readonly MigrationsRepository $migrationRepository,
        private readonly MetadataStorage $metadataStorage,
        private readonly Comparator $sorter,
    ) {
    }

    /** @param Version[] $versions */
    public function getPlanForVersions(array $versions, string $direction): MigrationPlanList
    {
        $migrationsToCheck   = $this->arrangeMigrationsForDirection($direction, $this->getMigrations());
        $availableMigrations = array_filter(
            $migrationsToCheck,
            // in_array third parameter is intentionally false to force object to string casting
            static fn (AvailableMigration $availableMigration): bool => in_array($availableMigration->getVersion(), $versions, false)
        );

        $planItems = array_map(static fn (AvailableMigration $availableMigration): MigrationPlan => new MigrationPlan($availableMigration->getVersion(), $availableMigration->getMigration(), $direction), $availableMigrations);

        if (count($planItems) !== count($versions)) {
            $plannedVersions = array_map(static fn (MigrationPlan $migrationPlan): Version => $migrationPlan->getVersion(), $planItems);
            $diff            = array_diff($versions, $plannedVersions);

            throw MigrationClassNotFound::new((string) reset($diff));
        }

        return new MigrationPlanList($planItems, $direction);
    }

    public function getPlanUntilVersion(Version $to): MigrationPlanList
    {
        if ((string) $to !== '0' && ! $this->migrationRepository->hasMigration((string) $to)) {
            throw MigrationClassNotFound::new((string) $to);
        }

        $availableMigrations = $this->getMigrations(); // migrations are sorted at this point
        $executedMigrations  = $this->metadataStorage->getExecutedMigrations();

        $direction = $this->findDirection($to, $executedMigrations, $availableMigrations);

        $migrationsToCheck = $this->arrangeMigrationsForDirection($direction, $availableMigrations);

        $toExecute = $this->findMigrationsToExecute($to, $migrationsToCheck, $direction, $executedMigrations);

        return new MigrationPlanList(array_map(static fn (AvailableMigration $migration): MigrationPlan => new MigrationPlan($migration->getVersion(), $migration->getMigration(), $direction), $toExecute), $direction);
    }

    public function getMigrations(): AvailableMigrationsList
    {
        $availableMigrations = $this->migrationRepository->getMigrations()->getItems();
        uasort($availableMigrations, fn (AvailableMigration $a, AvailableMigration $b): int => $this->sorter->compare($a->getVersion(), $b->getVersion()));

        return new AvailableMigrationsList($availableMigrations);
    }

    private function findDirection(Version $to, ExecutedMigrationsList $executedMigrations, AvailableMigrationsList $availableMigrations): string
    {
        if ((string) $to === '0') {
            return Direction::DOWN;
        }

        foreach ($availableMigrations->getItems() as $availableMigration) {
            if ($availableMigration->getVersion()->equals($to)) {
                break;
            }

            if (! $executedMigrations->hasMigration($availableMigration->getVersion())) {
                return Direction::UP;
            }
        }

        if ($executedMigrations->hasMigration($to) && ! $executedMigrations->getLast()->getVersion()->equals($to)) {
            return Direction::DOWN;
        }

        return Direction::UP;
    }

    /** @return  AvailableMigration[] */
    private function arrangeMigrationsForDirection(string $direction, Metadata\AvailableMigrationsList $availableMigrations): array
    {
        return $direction === Direction::UP ? $availableMigrations->getItems() : array_reverse($availableMigrations->getItems());
    }

    /**
     * @param AvailableMigration[] $migrationsToCheck
     *
     * @return AvailableMigration[]
     */
    private function findMigrationsToExecute(Version $to, array $migrationsToCheck, string $direction, ExecutedMigrationsList $executedMigrations): array
    {
        $toExecute = [];
        foreach ($migrationsToCheck as $availableMigration) {
            if ($direction === Direction::DOWN && $availableMigration->getVersion()->equals($to)) {
                break;
            }

            if ($direction === Direction::UP && ! $executedMigrations->hasMigration($availableMigration->getVersion())) {
                $toExecute[] = $availableMigration;
            } elseif ($direction === Direction::DOWN && $executedMigrations->hasMigration($availableMigration->getVersion())) {
                $toExecute[] = $availableMigration;
            }

            if ($direction === Direction::UP && $availableMigration->getVersion()->equals($to)) {
                break;
            }
        }

        return $toExecute;
    }
}
