<?php

namespace App\Tests\Appointment;

use App\Appointment\Availability\Picker\FirstAvailablePicker;
use App\Appointment\Availability\Picker\LeastGapPicker;
use App\Appointment\Availability\Picker\LeastLoadedPicker;
use App\Appointment\Availability\Picker\PickContext;
use App\Appointment\Availability\Picker\ResourcePickerRegistry;
use App\Appointment\Availability\Picker\SameAsPreviousPicker;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Shared\Time\TimeInterval;
use App\Tests\ApiTestCase;

/**
 * استراتژی‌های ترتیب منابع — تسک ۰۶.
 *
 * استراتژی **مرتب می‌کند، انتخاب نمی‌کند**؛ پس هر تست فقط ترتیب خروجی را می‌سنجد و
 * هیچ‌کدام نباید کاندیدی را حذف کند.
 */
class ResourcePickerTest extends ApiTestCase
{
    private int $idCursor = 0;

    /** منبعی با شناسهٔ واقعی، چون استراتژی‌ها با `getId()` کار می‌کنند. */
    private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource
    {
        $resource = new ClinicResource($address, $type, $name);
        $this->em->persist($resource);
        $this->em->flush();

        return $resource;
    }

    /** @return array{0: DoctorAddress, 1: ResourceType} */
    private function branch(): array
    {
        $user   = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
        $clinic = new \App\Clinic\Entity\Clinic($user);
        $clinic->setName('کلینیک استراتژی ' . (++$this->idCursor));
        $this->em->persist($clinic);
        $this->em->flush();

        $address = DoctorAddress::forClinic($clinic->getId());
        $address->setName('شعبه');
        $this->em->persist($address);

        $type = new ResourceType('clinic', (int) $clinic->getId(), 'operator', 'اپراتور');
        $this->em->persist($type);
        $this->em->flush();

        return [$address, $type];
    }

    private function namesOf(array $ordered): array
    {
        return array_map(static fn (ClinicResource $r): string => $r->getName(), $ordered);
    }

    public function testFirstAvailableOrdersByName(): void
    {
        [$address, $type] = $this->branch();

        $candidates = [
            $this->resource($address, $type, 'ج'),
            $this->resource($address, $type, 'الف'),
            $this->resource($address, $type, 'ب'),
        ];

        $ordered = (new FirstAvailablePicker())->order(
            $candidates,
            new PickContext([], [], time()),
        );

        self::assertSame(['الف', 'ب', 'ج'], $this->namesOf($ordered));
    }

    /** ⭐ منبعی که کمترین وقت مرده جا می‌گذارد، اول امتحان می‌شود. */
    public function testLeastGapPrefersTheTightestWindow(): void
    {
        [$address, $type] = $this->branch();

        $tight = $this->resource($address, $type, 'دقیق');
        $loose = $this->resource($address, $type, 'گشاد');

        $start = 1_800_000_000;
        $end   = $start + 3600;

        $free = [
            // پنجرهٔ دقیقاً به اندازهٔ نوبت — هیچ وقتی هدر نمی‌رود.
            (int) $tight->getId() => [new TimeInterval($start, $end)],
            // پنجرهٔ چهارساعته — سه ساعت خالی می‌ماند.
            (int) $loose->getId() => [new TimeInterval($start - 3600, $end + 7200)],
        ];

        $ordered = (new LeastGapPicker())->order(
            [$loose, $tight],
            new PickContext($free, [new TimeInterval($start, $end)], $start),
        );

        self::assertSame(['دقیق', 'گشاد'], $this->namesOf($ordered));
    }

    /** پخش بار عکسِ کمترین‌شکاف عمل می‌کند — و هر دو درست‌اند. */
    public function testLeastLoadedPrefersTheEmptiestResource(): void
    {
        [$address, $type] = $this->branch();

        $busy = $this->resource($address, $type, 'پرکار');
        $free = $this->resource($address, $type, 'خلوت');

        $start = 1_800_000_000;

        $windows = [
            (int) $busy->getId() => [new TimeInterval($start, $start + 3600)],
            (int) $free->getId() => [new TimeInterval($start, $start + 6 * 3600)],
        ];

        $ordered = (new LeastLoadedPicker())->order(
            [$busy, $free],
            new PickContext($windows, [new TimeInterval($start, $start + 1800)], $start),
        );

        self::assertSame(['خلوت', 'پرکار'], $this->namesOf($ordered));
    }

    /** ⭐ ترجیح است نه فیلتر: منبع ترجیحی جلو می‌آید، بقیه حذف نمی‌شوند. */
    public function testSameAsPreviousLiftsThePreferredResourceWithoutDroppingOthers(): void
    {
        [$address, $type] = $this->branch();

        $other     = $this->resource($address, $type, 'اپراتور دیگر');
        $preferred = $this->resource($address, $type, 'اپراتور جلسهٔ قبل');

        $start = 1_800_000_000;

        $ordered = (new SameAsPreviousPicker())->order(
            [$other, $preferred],
            new PickContext([], [new TimeInterval($start, $start + 1800)], $start, [(int) $preferred->getId()]),
        );

        self::assertSame(['اپراتور جلسهٔ قبل', 'اپراتور دیگر'], $this->namesOf($ordered));
        self::assertCount(2, $ordered, 'هیچ کاندیدی نباید حذف شود');
    }

    /** بدون ترجیح، همان ترتیب استراتژی پایه می‌ماند. */
    public function testSameAsPreviousFallsBackWhenNothingIsPreferred(): void
    {
        [$address, $type] = $this->branch();

        $a = $this->resource($address, $type, 'الف');
        $b = $this->resource($address, $type, 'ب');

        $start = 1_800_000_000;

        $ordered = (new SameAsPreviousPicker())->order(
            [$a, $b],
            new PickContext([], [new TimeInterval($start, $start + 1800)], $start),
        );

        self::assertCount(2, $ordered);
    }

    // ── رجیستری ─────────────────────────────────────────────────────────────

    public function testRegistryResolvesEveryStrategy(): void
    {
        $registry = static::getContainer()->get(ResourcePickerRegistry::class);

        foreach (['first_available', 'least_gap', 'least_loaded', 'same_as_previous'] as $code) {
            self::assertTrue($registry->has($code), $code);
            self::assertSame($code, $registry->get($code)::code());
        }

        self::assertCount(4, $registry->describe());
    }

    /** ⭐ کلید ناشناخته نوبت‌دهی را نمی‌خواباند؛ به پیش‌فرض برمی‌گردد. */
    public function testAnUnknownStrategyFallsBackToTheDefault(): void
    {
        $registry = static::getContainer()->get(ResourcePickerRegistry::class);

        self::assertSame('first_available', $registry->get('nonsense')::code());
        self::assertSame('first_available', $registry->get(null)::code());
    }

    /** ولی هنگام **ذخیره** رد می‌شود، وگرنه کاربر فکر می‌کند اعمال شده. */
    public function testSavingAnUnknownStrategyIsRejected(): void
    {
        $owner  = $this->createUser(['ROLE_DOCTOR']);
        $doctor = new \App\Doctor\Entity\Doctor($owner, 'دکتر استراتژی');
        $this->em->persist($doctor);
        $this->em->flush();

        $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
            'doctor_uuid' => $doctor->getUuid(),
            'schedule'    => [],
            'meta'        => ['booking_mode' => 'slot', 'resource_strategy' => 'nonsense'],
        ]);

        self::assertSame(422, $this->responseCode());
    }

    public function testStrategyListIsExposedForThePanel(): void
    {
        $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);

        $body = $this->authJson('GET', '/api/v1/appointment-settings/resource-strategies', $user);

        self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
        self::assertCount(4, $body['data']);
        self::assertContains('least_gap', array_column($body['data'], 'code'));
        self::assertNotEmpty($body['data'][0]['label']);
    }
}
