<?php

namespace App\Tests\Package;

use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;

/**
 * پکیج و دفتر اعتبار جلسات — تسک ۱۱.
 *
 * محور همهٔ تست‌ها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه می‌داریم،
 * نه یک عدد شمارنده.» پس مانده هیچ‌جا ذخیره نمی‌شود و هر تغییر یک ردیف است.
 */
class PackageLedgerTest extends ApiTestCase
{
    private int $slotCursor = 0;

    /** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
    private function clinicWithPatient(): array
    {
        $user   = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
        $clinic = new Clinic($user);
        $clinic->setName('کلینیک پکیج');
        $this->em->persist($clinic);
        $this->em->flush();

        $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
        $this->em->persist($section);

        $address = DoctorAddress::forClinic($clinic->getId());
        $address->setName('شعبهٔ مرکزی');
        $this->em->persist($address);

        $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
        $doctor     = new Doctor($doctorUser, 'دکتر پکیج');
        $this->em->persist($doctor);

        $patientUser = $this->createUser(['ROLE_USER']);
        $patient     = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
        $this->em->persist($patient);
        $this->em->flush();

        return [$user, $section, $address, $doctor, $patient];
    }

    private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem
    {
        $item = new ServiceItem($section, $name);
        $item->setSoloDurationMinutes(20);
        $item->setPriceRials($price);
        $this->em->persist($item);
        $this->em->flush();

        return $item;
    }

    /** @param array<string, mixed> $extra */
    private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
    {
        $body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
            'name'          => '۶ جلسه لیزر فول‌بادی',
            'session_count' => $sessions,
            'price_rials'   => 25_000_000,
            'service_uuids' => [$service->getUuid()],
        ]);

        self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));

        return $body['data'];
    }

    private function sell(User $user, PatientRecord $patient, string $packageUuid): array
    {
        $body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
            'package_uuid' => $packageUuid,
        ]);

        self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));

        return $body['data'];
    }

    /**
     * هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های
     * قبلی detached اند و باید دوباره خوانده شوند.
     */
    private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
    {
        // یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
        $start = time() + 86400 + (++$this->slotCursor) * 3600;

        $doctor  = $this->em->getRepository(Doctor::class)->find($doctor->getId());
        $patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
        $service = $this->em->getRepository(ServiceItem::class)->find($service->getId());

        $appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
        $appointment->assignTenantPair('clinic', $clinicId);
        $appointment->setServiceItem($service);
        $appointment->setPatientName('بیمار پکیج');

        $this->em->persist($appointment);
        $this->em->flush();

        return $appointment;
    }

    private function ledgerService(): CreditLedgerService
    {
        return static::getContainer()->get(CreditLedgerService::class);
    }

    /**
     * سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست
     * باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود.
     */
    private function reload(Appointment $appointment): Appointment
    {
        return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
            ->getRepository(Appointment::class)
            ->find($appointment->getId());
    }

    private function consumption(): \App\Package\Service\PackageConsumptionService
    {
        return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
    }

    // ── تعریف و فروش ────────────────────────────────────────────────────────

    public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر فول‌بادی');

        $package = $this->definePackage($user, $service);
        $sold    = $this->sell($user, $patient, $package['uuid']);

        self::assertSame(6, $sold['balance']);

        $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
        self::assertSame(6, $list['data'][0]['balance']);

        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);

        self::assertCount(1, $ledger['data']['rows']);
        self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
        self::assertSame(6, $ledger['data']['rows'][0]['delta']);
        self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
    }

    /** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */
    public function testAPackageWithoutServicesIsRejected(): void
    {
        [$user] = $this->clinicWithPatient();

        $this->authJson('POST', '/api/v1/packages', $user, [
            'name'          => 'پکیج بی‌سرویس',
            'session_count' => 3,
            'price_rials'   => 1_000_000,
            'service_uuids' => [],
        ]);

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

    /** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */
    public function testNoStoredBalanceColumnExists(): void
    {
        $columns = $this->em->getConnection()
            ->createSchemaManager()
            ->listTableColumns('patient_packages');

        $names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);

        foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
            self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
        }
    }

    // ── مصرف و بازگشت ───────────────────────────────────────────────────────

    public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $package = $this->definePackage($user, $service);
        $sold    = $this->sell($user, $patient, $package['uuid']);

        $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());

        $appointment = $this->reload($appointment);
        self::assertTrue($this->consumption()->consumeFor($appointment));

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
        self::assertSame(5, $this->ledgerService()->balance($entity));

        self::assertTrue($this->ledgerService()->refund($appointment));
        self::assertSame(6, $this->ledgerService()->balance($entity));

        // ردیف `consume` **حذف نمی‌شود** — دفتر append-only است.
        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
        $kinds  = array_column($ledger['data']['rows'], 'kind');

        self::assertSame(['purchase', 'consume', 'refund'], $kinds);
        self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
    }

    /**
     * ⭐ `credit_refundable: false` اعتبار برگشته را پس می‌گیرد — **بدون** حذف ردیف.
     *
     * دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن
     * `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد.
     */
    public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        // نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان
        // نیست بلکه درونِ محدودهٔ جریمه می‌افتد — تنها حالتی که سیاست اعتبار اثر دارد.
        $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
            'free_window_hours' => 96,
            'penalty_mode'      => 'none',
            'credit_refundable' => false,
        ]);
        self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));

        $sold        = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
        $appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));

        self::assertTrue($this->consumption()->consumeFor($appointment));

        $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']);
        self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
        self::assertFalse($body['data']['credit_refundable']);

        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
        $kinds  = array_column($ledger['data']['rows'], 'kind');

        self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمی‌شود');

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
        self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد');
    }

    /** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
    public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
        $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());

        $appointment = $this->reload($appointment);
        $this->consumption()->consumeFor($appointment);
        $this->consumption()->consumeFor($appointment);

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);

        self::assertSame(5, $this->ledgerService()->balance($entity));
    }

    /** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */
    public function testAnEmptyPackageIsSimplyNotApplied(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);

        $first  = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
        $second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));

        self::assertTrue($this->consumption()->consumeFor($first));
        self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود');

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
        self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود');
    }

    // ── قیمت ────────────────────────────────────────────────────────────────

    public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
    {
        [$user, $section, $address, , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر', 5_000_000);

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
            'service_uuid' => $service->getUuid(),
            'branch_uuid'  => $address->getUuid(),
            'patient_uuid' => $patient->getUuid(),
        ]);

        self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
        self::assertTrue($quote['data']['package_will_be_consumed']);
        self::assertSame(0, $quote['data']['final_rials']);

        // پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد.
        $this->authJson('POST', '/api/v1/pricing/quote', $user, [
            'service_uuid' => $service->getUuid(),
            'branch_uuid'  => $address->getUuid(),
            'patient_uuid' => $patient->getUuid(),
        ]);

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
        self::assertSame(6, $this->ledgerService()->balance($entity));
    }

    public function testQuoteWithoutAPatientChargesTheFullPrice(): void
    {
        [$user, $section, $address, , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر', 5_000_000);

        $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
            'service_uuid' => $service->getUuid(),
            'branch_uuid'  => $address->getUuid(),
        ]);

        self::assertFalse($quote['data']['package_will_be_consumed']);
        self::assertSame(5_000_000, $quote['data']['final_rials']);
    }

    // ── FIFO و انقضا ────────────────────────────────────────────────────────

    /** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */
    public function testTheOldestUnexpiredPackageIsUsedFirst(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $definition = $this->definePackage($user, $service);
        $older      = $this->sell($user, $patient, $definition['uuid']);
        $newer      = $this->sell($user, $patient, $definition['uuid']);

        $repo   = static::getContainer()->get(PatientPackageRepository::class);
        $olderE = $repo->findByUuid($older['uuid']);

        // خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد.
        $this->em->getConnection()->executeStatement(
            'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
            [$newer['uuid']],
        );
        $this->em->clear();

        $chosen = $this->consumption()->firstUsable(
            $this->em->getRepository(PatientRecord::class)->find($patient->getId()),
            $this->em->getRepository(ServiceItem::class)->find($service->getId()),
        );

        self::assertSame($olderE->getUuid(), $chosen?->getUuid());
    }

    public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $this->em->getConnection()->executeStatement(
            'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
            [time() - 86400, $sold['uuid']],
        );
        $this->em->clear();

        $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);

        self::assertTrue($list['data'][0]['expired']);
        self::assertSame(0, $list['data'][0]['balance']);

        // دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد.
        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
        self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
    }

    public function testExpiryCommandWritesTheClosingRow(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $this->em->getConnection()->executeStatement(
            'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
            [time() - 86400, $sold['uuid']],
        );
        $this->em->clear();

        $command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
        $tester  = new \Symfony\Component\Console\Tester\CommandTester($command);
        $tester->execute([]);

        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
        $kinds  = array_column($ledger['data']['rows'], 'kind');

        self::assertSame(['purchase', 'expiry'], $kinds);
        self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
        self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
    }

    // ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────

    public function testAdjustmentNeedsAReasonAndIsRecorded(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
        self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
            'delta'  => 2,
            'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
        ]);
        self::assertSame(201, $this->responseCode());

        $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
        $row    = $ledger['data']['rows'][1];

        self::assertSame('adjustment', $row['kind']);
        self::assertSame(2, $row['delta']);
        self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
        self::assertNotNull($row['created_by']);
        self::assertSame(8, $row['running_balance']);
    }

    public function testAdjustmentCannotDriveTheBalanceNegative(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');

        $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
            'delta'  => -10,
            'reason' => 'اشتباه اپراتور',
        ]);

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

    public function testAnotherClinicCannotSeeOrTouchThePackage(): void
    {
        [$owner, $section, , , $patient] = $this->clinicWithPatient();
        [$other] = $this->clinicWithPatient();

        $service = $this->service($section, 'لیزر');
        $sold    = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);

        $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
        self::assertSame(404, $this->responseCode());

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
            'delta'  => 5,
            'reason' => 'تلاش از محیط دیگر',
        ]);
        self::assertSame(404, $this->responseCode());
    }

    /** منشی نباید بتواند اعتبار را دستی عوض کند. */
    public function testASecretaryCannotAdjustTheLedger(): void
    {
        [$owner, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');
        $sold    = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);

        $secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
            'delta'  => 5,
            'reason' => 'تلاش منشی',
        ]);

        self::assertContains($this->responseCode(), [403, 404]);
    }

    public function testLedgerRowsNeverHaveAZeroDelta(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section, 'لیزر');
        $sold    = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);

        $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
            'delta'  => 0,
            'reason' => 'بی‌اثر',
        ]);

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

        $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);

        self::expectException(\InvalidArgumentException::class);
        new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
    }
}
