<?php

namespace App\Tests;

use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\DateOverride;
use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
 * Base class for functional API tests.
 *
 * Provides a booted KernelBrowser, the EntityManager, and helpers to create
 * users and issue JWTs so tests can hit authenticated /api and /oauth endpoints.
 * Runs against the dedicated db_test database (see .env.test / doctrine when@test).
 */
abstract class ApiTestCase extends WebTestCase
{
    /**
     * محیطِ رکوردهایی که موضوعِ تست، محیطشان نیست. عمداً یک ثابت است تا هیچ تستی
     * تصادفاً با محیطِ واقعیِ تستِ دیگری برخورد نکند.
     */
    protected const TENANTLESS_TEST_ENTITY_ID = 1;

    protected KernelBrowser $client;
    protected EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = static::createClient();

        // هر تستی که عمداً یا تصادفی یک قید یکتا را می‌شکند، EntityManager را می‌بندد
        // و آن نمونهٔ بسته به تست بعدی ارث می‌رسد — چون کانتینر همان را برمی‌گرداند.
        // نتیجه‌اش خطای «EntityManager is closed» روی تستی کاملاً بی‌ربط بود که هر بار
        // جای دیگری می‌افتاد و در اجرای زیرمجموعه هرگز تکرار نمی‌شد.
        //
        // ریست اینجا ارزان است و تضمین می‌کند شروع هر تست مستقل از خرابیِ تست قبلی باشد.
        $registry = static::getContainer()->get('doctrine');

        if (!$registry->getManager()->isOpen()) {
            $registry->resetManager();
        }

        $this->em = static::getContainer()->get(EntityManagerInterface::class);
        $this->ensureFreePlan();
    }

    /**
     * The «free» plan is SubscriptionService::getEffectivePlan's fallback for any
     * tenant without a paid subscription. Without it every hasFeature() is false
     * and the patient / service / insurance endpoints answer 403 instead of doing
     * their job. db_test is never reset, so the insert is idempotent.
     *
     * Mirrors the row shipped in the dev database.
     */
    private function ensureFreePlan(): void
    {
        $repo = $this->em->getRepository(SubscriptionPlan::class);
        if ($repo->findOneBy(['name' => 'free']) !== null) {
            return;
        }

        $this->em->persist(new SubscriptionPlan('free', 0, 1, [
            'patient_records' => true,
            'services'        => true,
            'sms_panel'       => true,
            'insurance'       => true,
        ]));
        $this->em->flush();
    }

    /**
     * Persist a user with the given roles. Mobile is randomised per test to
     * avoid unique-constraint clashes across cases without a full DB reset.
     */
    protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User
    {
        if ($mobile !== null) {
            return $this->persistUser($mobile, $roles);
        }

        // 9 random digits after 09 (full ^09\d{9}$ space). db_test is never reset and
        // already holds tens of thousands of users, so a draw does collide now and
        // then; retry rather than fail an unrelated test on a birthday collision.
        for ($attempt = 0; ; $attempt++) {
            try {
                return $this->persistUser(
                    '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
                    $roles,
                );
            } catch (UniqueConstraintViolationException $e) {
                if ($attempt >= 4) {
                    throw $e;
                }

                // The failed INSERT closes the EntityManager, and asking the container
                // for it again hands back the *same closed instance* — Doctrine only
                // builds a fresh one when the registry is reset. Without this the retry
                // throws EntityManagerClosed, and every later test in the same process
                // inherits a dead manager: the intermittent, always-somewhere-else
                // failure that made full runs flaky.
                static::getContainer()->get('doctrine')->resetManager();
                $this->em = static::getContainer()->get(EntityManagerInterface::class);
            }
        }
    }

    private function persistUser(string $mobile, array $roles): User
    {
        $user = new User($mobile);
        $user->setRoles($roles);
        $user->setStatus(1);
        $this->em->persist($user);
        $this->em->flush();

        return $user;
    }

    /**
     * A booking with its owning tenant already assigned — the test-side mirror of
     * what the booking controllers do after resolving the environment. Constructing
     * an Appointment without it fails at flush, since entity_type/entity_id are NOT
     * NULL and have no default.
     */
    protected function newAppointment(
        Doctor $doctor,
        User $patient,
        int $slotStart,
        int $slotEnd,
        ?Clinic $clinic = null,
    ): Appointment {
        $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
        $appointment->setClinic($clinic);
        $appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));

        return $appointment;
    }

    /**
     * A weekly schedule with its owning tenant assigned. The doctor is flushed
     * first when it has no id yet: the tenant pair stores scalars, so the owner
     * must already exist in the database.
     */
    protected function newWeeklySchedule(Doctor $doctor, array $setting, ?Clinic $clinic = null): WeeklySchedule
    {
        $this->flushIfNew($doctor, $clinic);

        $schedule = new WeeklySchedule($doctor, $setting, $clinic);
        $schedule->assignTenant(EntityContext::forBooking($doctor, $clinic));

        return $schedule;
    }

    /**
     * محیط پرداخت را می‌گذارد: از نوبتش اگر داشته باشد، وگرنه محیطِ ثابتی که
     * موضوع تست نیست. مثل بقیهٔ جدول‌های محیط‌دار، ستون‌ها NOT NULL‌اند و پرداختِ
     * بی‌محیط سرِ flush می‌شکند — همان رفتاری که کد واقعی هم دارد.
     */
    protected function stampTenant(Payment $payment): Payment
    {
        $appointment = $payment->getAppointment();
        if ($appointment !== null) {
            $payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));

            return $payment;
        }

        $payment->assignTenantPair(EntityContext::TYPE_DOCTOR, self::TENANTLESS_TEST_ENTITY_ID);

        return $payment;
    }

    protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride
    {
        $this->flushIfNew($doctor, $clinic);

        $override = new DateOverride($doctor, $date, $active, $clinic);
        $override->assignTenant(EntityContext::forBooking($doctor, $clinic));

        return $override;
    }

    /**
     * The tenant pair stores scalars, so the owner needs a database id before it
     * can be assigned. persist() on an already-managed entity is a no-op, which
     * makes this safe for owners the test never persisted itself.
     */
    private function flushIfNew(Doctor $doctor, ?Clinic $clinic): void
    {
        $pending = array_filter(
            [$doctor, $clinic],
            static fn(?object $owner) => $owner !== null && $owner->getId() === null,
        );

        if ($pending === []) {
            return;
        }

        foreach ($pending as $owner) {
            $this->em->persist($owner);
        }

        $this->em->flush();
    }

    protected function jwtFor(User $user): string
    {
        return static::getContainer()
            ->get(JWTTokenManagerInterface::class)
            ->create($user);
    }

    /**
     * Issue an authenticated JSON request and return the decoded response body.
     */
    protected function authJson(string $method, string $uri, User $user, array $body = []): array
    {
        $this->client->request(
            $method,
            $uri,
            server: [
                'HTTP_AUTHORIZATION' => 'Bearer ' . $this->jwtFor($user),
                'CONTENT_TYPE'       => 'application/json',
            ],
            content: $body ? json_encode($body) : null,
        );

        return json_decode($this->client->getResponse()->getContent(), true) ?? [];
    }

    protected function responseCode(): int
    {
        return $this->client->getResponse()->getStatusCode();
    }

    /**
     * Count the SQL queries executed while running $fn. Used to assert that a
     * list endpoint's query count does not grow with the number of rows (N+1).
     */
    protected function countQueries(callable $fn): int
    {
        $holder = static::getContainer()->get('doctrine.debug_data_holder');
        $holder->reset();
        $fn();

        return array_sum(array_map('count', $holder->getData()));
    }
}
