<?php

namespace App\Tests\Appointment;

use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Service\SlotCalculatorService;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;

/**
 * قرارداد نوبت‌دهی اسلاتی — منجمد.
 *
 * حالت `booking_mode = slot` منطق تولیدیِ زنده است و در فاز موتور چندمنبعی
 * ({@see docs/new_feture/taskes/_shared/red-lines.md}) هیچ تسکی اجازهٔ تغییرش را ندارد.
 * این تست سه چیز را قفل می‌کند:
 *
 *   ۱. شکل پاسخ `GET /api/v1/appointment-slots`
 *   ۲. شکل پاسخ `GET /api/v1/appointment-settings/month-availability/{doctorUuid}`
 *   ۳. امضای متدهای عمومی `SlotCalculatorService`
 *
 * ⛔ سه فایل `fixtures/slot-mode-*` و `fixtures/month-availability-*` و
 * `fixtures/slot-calculator-signatures.php` پس از تسک ۰۰ read-only اند. اگر این تست
 * قرمز شد، **کد باید برگردد، نه fixture**.
 *
 * چرا fixture ساختاری است و نه snapshot خام: `buildAllSessions()` تاریخ گذشته را رد
 * می‌کند (`isWithinBookingWindow`) و `getAllSlotsWithAvailability()` مقدار
 * `is_available` را با `time()` می‌سنجد. پس snapshot با تاریخ ثابتِ گذشته همیشه خالی
 * است و چیزی را تضمین نمی‌کند. به‌جایش برنامهٔ قطعی روی تاریخِ محاسبه‌شدهٔ نزدیک ساخته
 * می‌شود و مقادیر epoch با placeholder جایگزین می‌شوند؛ آنچه می‌ماند دقیقاً همان چیزی
 * است که قرارداد را می‌سازد: کلیدها، ترتیب، نوع‌ها و ساعت‌های محلی.
 */
#[Group('slot-mode-frozen')]
class SlotModeFrozenTest extends ApiTestCase
{
    private const EPOCH_PLACEHOLDER = '<epoch>';
    private const UUID_PLACEHOLDER  = '<uuid>';

    /** روزِ مبنا: نه امروز (تا `is_available` با گذر ساعت نلرزد) و داخل بازهٔ رزرو. */
    private const OFFSET_DAYS = 3;

    /**
     * برنامهٔ هفتگیِ قطعی: هر هفت روز یک شیفت یکسان، تا نتیجه به روزِ هفتهٔ اجرای تست
     * وابسته نباشد.
     */
    private function makeSlotModeDoctor(): array
    {
        $owner  = $this->createUser(['ROLE_DOCTOR']);
        $doctor = new Doctor($owner, 'دکتر قرارداد اسلاتی');
        $this->em->persist($doctor);

        $day = [
            'sessions' => [[
                'active'               => true,
                'start_time'           => '09:00',
                'end_time'             => '11:00',
                'duration_per_patient' => 30,
                'has_rest'             => false,
                'patient_limit'        => null,
                'location_id'          => 1,
            ]],
        ];

        $setting = [];
        foreach (range(0, 6) as $dayKey) {
            $setting[(string) $dayKey] = $day;
        }

        $schedule = $this->newWeeklySchedule($doctor, $setting);
        $schedule->setMeta([
            'booking_mode'           => WeeklySchedule::MODE_SLOT,
            'online_booking_enabled' => true,
            'booking_window_value'   => 3,
            'booking_window_unit'    => 'month',
            'buffer_minutes'         => 0,
        ]);
        $this->em->persist($schedule);
        $this->em->flush();

        return [$doctor, date('Y-m-d', strtotime('+' . self::OFFSET_DAYS . ' days'))];
    }

    public function testAppointmentSlotsContractIsFrozen(): void
    {
        [$doctor, $date] = $this->makeSlotModeDoctor();

        $this->client->request('GET', '/api/v1/appointment-slots?' . http_build_query([
            'doctor_uuid' => $doctor->getUuid(),
            'date'        => $date,
        ]));

        self::assertSame(200, $this->client->getResponse()->getStatusCode());

        $actual = $this->normalize(
            json_decode($this->client->getResponse()->getContent(), true),
            ['date' => '<date>'],
        );

        self::assertSame(
            $this->fixture('slot-mode-contract.json'),
            $actual,
            'قرارداد appointment-slots عوض شده است. کد را برگردان، نه fixture را.',
        );
    }

    public function testMonthAvailabilityContractIsFrozen(): void
    {
        [$doctor, $date] = $this->makeSlotModeDoctor();
        $ts = (int) strtotime($date);

        $this->client->request('GET', sprintf(
            '/api/v1/appointment-settings/month-availability/%s?year=%d&month=%d',
            $doctor->getUuid(),
            (int) date('Y', $ts),
            (int) date('n', $ts),
        ));

        self::assertSame(200, $this->client->getResponse()->getStatusCode());

        $body = json_decode($this->client->getResponse()->getContent(), true);

        // فهرست روزها داده است نه قرارداد (به «امروز» وابسته است)؛ قرارداد این است که
        // هر روزِ ماه دقیقاً در یکی از دو فهرست باشد.
        $daysInMonth = (int) date('t', $ts);
        self::assertCount(
            $daysInMonth,
            array_merge($body['data']['enabled_dates'], $body['data']['disabled_dates']),
            'هر روزِ ماه باید دقیقاً در یکی از enabled/disabled باشد',
        );

        $actual = $this->normalize($body, [
            'year'           => '<year>',
            'month'          => '<month>',
            'enabled_dates'  => '<dates>',
            'disabled_dates' => '<dates>',
        ]);

        self::assertSame(
            $this->fixture('month-availability-contract.json'),
            $actual,
            'قرارداد month-availability عوض شده است. کد را برگردان، نه fixture را.',
        );
    }

    /**
     * امضای متدهای عمومیِ SlotCalculatorService. افزودن پارامتر اختیاری مجاز است ولی
     * باید **یک بار** در fixture ثبت شود؛ تغییر یا حذف پارامتر موجود ممنوع.
     */
    public function testSlotCalculatorPublicApiIsFrozen(): void
    {
        self::assertSame(
            require __DIR__ . '/fixtures/slot-calculator-signatures.php',
            $this->publicSignaturesOf(SlotCalculatorService::class),
            'امضای عمومی SlotCalculatorService عوض شده است. کد را برگردان، نه fixture را.',
        );
    }

    /** @return array<string, array<string, string>> */
    private function publicSignaturesOf(string $class): array
    {
        $out = [];
        foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
            if ($method->isConstructor() || $method->getDeclaringClass()->getName() !== $class) {
                continue;
            }

            $params = [];
            foreach ($method->getParameters() as $p) {
                $params[$p->getName()] = sprintf(
                    '%s%s',
                    (string) ($p->getType() ?? 'mixed'),
                    $p->isDefaultValueAvailable()
                        ? ' = ' . var_export($p->getDefaultValue(), true)
                        : '',
                );
            }

            $out[$method->getName()] = [
                'returns' => (string) ($method->getReturnType() ?? 'mixed'),
                'params'  => $params,
            ];
        }

        ksort($out);

        return $out;
    }

    /**
     * مقادیر داده‌ایِ وابسته به زمان را با placeholder جایگزین می‌کند تا آنچه مقایسه
     * می‌شود فقط قرارداد بماند: کلیدها، ترتیب، نوع‌ها و ساعت‌های محلی.
     *
     * @param array<string, string> $overrides کلید → placeholder
     */
    private function normalize(mixed $value, array $overrides = [], ?string $key = null): mixed
    {
        if ($key !== null && array_key_exists($key, $overrides)) {
            return $overrides[$key];
        }

        if (is_array($value)) {
            $out = [];
            foreach ($value as $k => $v) {
                $out[$k] = $this->normalize($v, $overrides, is_string($k) ? $k : $key);
            }

            return $out;
        }

        if (is_int($value) && $value > 1_000_000_000) {
            return self::EPOCH_PLACEHOLDER;
        }

        if (is_string($value) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/', $value) === 1) {
            return self::UUID_PLACEHOLDER;
        }

        return $value;
    }

    /**
     * fixture را می‌خواند و کلید `_readme` (هشدار read-only بودن فایل) را کنار می‌گذارد
     * تا مقایسه فقط روی قرارداد انجام شود.
     *
     * @return array<mixed>
     */
    private function fixture(string $name): array
    {
        $path = __DIR__ . '/fixtures/' . $name;
        self::assertFileExists($path, 'fixture قرارداد وجود ندارد: ' . $name);

        $decoded = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
        unset($decoded['_readme']);

        return $decoded;
    }
}
