<?php

namespace Tests\Concurrency;

use App\Enums\AppointmentStatus;
use App\Enums\PaymentStatus;
use App\Exceptions\DomainException;
use App\Models\Appointment;
use App\Models\DoctorLocationAssignment;
use App\Models\Patient;
use App\Models\QueueEntry;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\QueueService;
use Carbon\CarbonImmutable;
use Database\Seeders\ClinicDemoSeeder;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class MySqlConcurrencyTest extends TestCase
{
    use DatabaseMigrations;

    protected function setUp(): void
    {
        parent::setUp();

        if (DB::connection()->getDriverName() !== 'mysql') {
            $this->markTestSkipped('The concurrency suite must run against MySQL/MariaDB.');
        }

        if (! function_exists('pcntl_fork')) {
            $this->markTestSkipped('The pcntl extension is required for true process concurrency tests.');
        }

        $this->seed(ClinicDemoSeeder::class);
    }

    public function test_two_concurrent_auto_token_allocations_never_receive_the_same_token(): void
    {
        $assistant = User::where('email', 'assistant.two@smartclinic.test')->firstOrFail();
        $assignment = DoctorLocationAssignment::query()
            ->whereHas('doctor.user', fn ($q) => $q->where('email', 'dr.sara@smartclinic.test'))
            ->firstOrFail();
        $patients = Patient::query()->limit(2)->pluck('id')->all();
        $timezone = $assistant->organization->timezone;
        $start = CarbonImmutable::now($timezone)->startOfDay()->setTime(10, 0);

        $appointmentIds = [];
        foreach ($patients as $index => $patientId) {
            $local = $start->addMinutes($index * 20);
            $appointment = Appointment::create([
                'organization_id' => $assistant->organization_id,
                'appointment_code' => 'CON-TKN-'.($index + 1),
                'patient_id' => $patientId,
                'doctor_id' => $assignment->doctor_id,
                'location_id' => $assignment->location_id,
                'doctor_location_assignment_id' => $assignment->id,
                'created_by_user_id' => $assistant->id,
                'created_by_assistant_id' => $assistant->assistant->id,
                'scheduled_start_at' => $local->utc(),
                'scheduled_end_at' => $local->addMinutes($assignment->slot_minutes)->utc(),
                'type' => 'new_consultation',
                'base_fee' => $assignment->consultation_fee,
                'discount_amount' => 0,
                'final_fee' => $assignment->consultation_fee,
                'payment_status' => PaymentStatus::PENDING,
                'status' => AppointmentStatus::CONFIRMED,
            ]);
            $appointmentIds[] = $appointment->id;
        }

        $results = $this->runInParallel([
            fn () => $this->allocateToken($assistant->id, $appointmentIds[0]),
            fn () => $this->allocateToken($assistant->id, $appointmentIds[1]),
        ]);

        $this->assertCount(2, $results);
        $this->assertTrue(collect($results)->every(fn ($r) => ($r['ok'] ?? false) === true), json_encode($results));
        $tokens = collect($results)->pluck('token')->sort()->values()->all();
        $this->assertCount(2, array_unique($tokens));
        $this->assertSame(2, QueueEntry::query()
            ->where('doctor_id', $assignment->doctor_id)
            ->where('location_id', $assignment->location_id)
            ->whereDate('queue_date', CarbonImmutable::now($timezone)->toDateString())
            ->count());
    }

    public function test_two_concurrent_bookings_for_same_doctor_slot_produce_one_success_and_one_conflict(): void
    {
        $assistant = User::where('email', 'assistant.one@smartclinic.test')->firstOrFail();
        $assignment = DoctorLocationAssignment::query()
            ->whereHas('doctor.user', fn ($q) => $q->where('email', 'dr.ali@smartclinic.test'))
            ->whereHas('location', fn ($q) => $q->where('name', 'Main Clinic'))
            ->firstOrFail();
        $patients = Patient::query()->limit(2)->pluck('id')->all();
        $timezone = $assistant->organization->timezone;

        $day = CarbonImmutable::now($timezone)->addDay()->startOfDay();
        while ($day->isoWeekday() === 7) {
            $day = $day->addDay();
        }
        $slot = $day->setTime(11, 0)->utc()->toIso8601String();

        $payload = fn (int $patientId) => [
            'patient_id' => $patientId,
            'doctor_id' => $assignment->doctor_id,
            'location_id' => $assignment->location_id,
            'scheduled_start_at' => $slot,
            'type' => 'new_consultation',
            'reason' => 'Concurrency test',
            'discount_amount' => 0,
        ];

        $results = $this->runInParallel([
            fn () => $this->book($assistant->id, $payload($patients[0])),
            fn () => $this->book($assistant->id, $payload($patients[1])),
        ]);

        $successful = collect($results)->where('ok', true);
        $failed = collect($results)->where('ok', false);

        $this->assertCount(1, $successful, json_encode($results));
        $this->assertCount(1, $failed, json_encode($results));
        $this->assertSame('SLOT_NOT_AVAILABLE', $failed->first()['code'] ?? null);
        $this->assertSame(1, Appointment::query()
            ->where('doctor_id', $assignment->doctor_id)
            ->where('location_id', $assignment->location_id)
            ->where('scheduled_start_at', CarbonImmutable::parse($slot)->utc())
            ->count());
    }

    private function allocateToken(int $userId, int $appointmentId): array
    {
        try {
            $actor = User::with(['organization', 'assistant'])->findOrFail($userId);
            $appointment = Appointment::findOrFail($appointmentId);
            $entry = app(QueueService::class)->checkIn($actor, $appointment, 'auto');

            return ['ok' => true, 'token' => $entry->token_number];
        } catch (DomainException $e) {
            return ['ok' => false, 'code' => $e->errorCode, 'message' => $e->getMessage()];
        } catch (\Throwable $e) {
            return ['ok' => false, 'code' => get_class($e), 'message' => $e->getMessage()];
        }
    }

    private function book(int $userId, array $payload): array
    {
        try {
            $actor = User::with(['organization', 'assistant'])->findOrFail($userId);
            $appointment = app(AppointmentService::class)->book($actor, $payload);

            return ['ok' => true, 'appointment_id' => $appointment->id];
        } catch (DomainException $e) {
            return ['ok' => false, 'code' => $e->errorCode, 'message' => $e->getMessage()];
        } catch (\Throwable $e) {
            return ['ok' => false, 'code' => get_class($e), 'message' => $e->getMessage()];
        }
    }

    /**
     * @param  array<int, callable(): array<string, mixed>>  $operations
     * @return array<int, array<string, mixed>>
     */
    private function runInParallel(array $operations): array
    {
        $directory = sys_get_temp_dir().'/smart-clinic-concurrency-'.bin2hex(random_bytes(8));
        mkdir($directory, 0700, true);
        $children = [];

        foreach ($operations as $index => $operation) {
            $path = $directory.'/result-'.$index.'.json';
            $pid = pcntl_fork();

            if ($pid === -1) {
                $this->fail('Unable to fork concurrency test process.');
            }

            if ($pid === 0) {
                DB::disconnect();
                DB::purge();
                try {
                    $result = $operation();
                } catch (\Throwable $e) {
                    $result = ['ok' => false, 'code' => get_class($e), 'message' => $e->getMessage()];
                }
                file_put_contents($path, json_encode($result, JSON_THROW_ON_ERROR));
                exit(0);
            }

            $children[] = [$pid, $path];
        }

        $results = [];
        foreach ($children as [$pid, $path]) {
            pcntl_waitpid($pid, $status);
            $this->assertTrue(pcntl_wifexited($status), "Child process {$pid} did not exit normally.");
            $this->assertFileExists($path);
            $results[] = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
            @unlink($path);
        }
        @rmdir($directory);

        DB::disconnect();
        DB::purge();
        DB::reconnect();

        return $results;
    }
}
